genetics-mcp 2.0.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.
package/genetics.mjs ADDED
@@ -0,0 +1,2141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lim 보고서 발행 MCP — 데이터를 주면 규격 보고서가 나온다.
4
+ *
5
+ * Copyright (c) 2026 주식회사 LIM (LIM Inc.) — All Rights Reserved.
6
+ * 이용 조건은 함께 배포되는 LICENSE 를 따른다. 공개 레지스트리에 있다는 것이
7
+ * 이용 허락을 뜻하지 않는다 — 게시는 정당한 이용자가 어느 기계에서든 받게 하기 위함이다.
8
+ *
9
+ * **이 파일은 일부러 난독화하지 않는다.** 자격증명(쓰기 권한 API 키)을 읽어 네트워크로
10
+ * 보내는 도구이므로, 받는 쪽이 "무엇을 어디로 보내는지" 읽어서 확인할 수 있어야 한다.
11
+ * 난독화는 그 확인을 막을 뿐 공격자는 못 막는다 — 실행되는 이상 요청은 프록시로 그대로 보인다.
12
+ *
13
+ * 이 서버는 모양을 만들지 않는다. 서버(build.py)가 규격대로 렌더하므로,
14
+ * 여기서 보내는 것은 **내용뿐**이다. 그래서 CSS 가 제각각일 수 없다.
15
+ *
16
+ * **키 하나가 고객사 하나다.** 여기에 더해 발행 도구는 `tenant`(대상 고객사 이름)를
17
+ * 반드시 받아 키와 대조한다 — 키만 믿었다가 실제로 남의 고객사에 발행한 적이 있다
18
+ * (2026-08-12). 키와 의도가 둘 다 맞아야 나간다.
19
+ * 키는 사람이 관리 화면에서 발급해 자격증명 파일(권한 600)에 둔다 — 경로는 아래 ENV_FILE.
20
+ *
21
+ * 의존성 0 — MCP 는 stdio 위의 JSON-RPC 2.0 이라 표준 라이브러리로 충분하다.
22
+ */
23
+
24
+ import { spawnSync } from 'node:child_process';
25
+ import crypto from 'node:crypto';
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ /**
32
+ * 판을 **package.json 에서 읽는다.** 전에는 serverInfo 에 '1.0.0' 이 상수로 박혀 있어서
33
+ * 몇 번을 고쳐도 핸드셰이크가 같은 값을 말했다 — 그래서 "이 세션의 MCP 가 옛 코드인가" 를
34
+ * 스크립트를 직접 태워서야 알았다(2026-08-14). 판을 물어볼 수 있어야 그 질문이 끝난다.
35
+ */
36
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
37
+ const VERSION = (() => {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(path.join(HERE, 'package.json'), 'utf8')).version || '0.0.0';
40
+ } catch {
41
+ return '0.0.0'; // 패키지 밖으로 파일만 복사해 간 경우
42
+ }
43
+ })();
44
+
45
+ // 사람이 손으로 물어볼 수 있는 길. stdio 프로토콜을 몰라도 판을 확인할 수 있어야 한다.
46
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
47
+ process.stdout.write(`genetics-mcp ${VERSION}\n`);
48
+ process.exit(0);
49
+ }
50
+
51
+ /*
52
+ * 짝인 스킬을 편다.
53
+ *
54
+ * **서버만 받고 스킬을 못 받으면 반경이 또 어긋난다.** install.sh 머리말의 2026-08-14 사고가
55
+ * 그것이었다 — 도구와 방법이 서로 다른 반경에 있으면 한쪽만 뜨고, 사람은 엉뚱한 원인을 짚는다.
56
+ * 그래서 **한 편에 싣고, 펴는 것도 이 실행체가 한다.** 레지스트리로 받은 쪽은 패키지가 어디에
57
+ * 풀렸는지 사람이 알 수 없어서, 바깥 스크립트로는 자리를 짚을 수가 없다.
58
+ */
59
+ function installSkill(link) {
60
+ const src = path.join(HERE, 'skills');
61
+ const dst = path.join(os.homedir(), '.claude/skills');
62
+ if (!fs.existsSync(src)) {
63
+ process.stderr.write(`\u2717 이 판에는 스킬이 동봉되지 않았다: ${src}\n`);
64
+ process.exit(1);
65
+ }
66
+ fs.mkdirSync(dst, { recursive: true });
67
+ let n = 0;
68
+ for (const name of fs.readdirSync(src)) {
69
+ const to = path.join(dst, name);
70
+ // 있던 것은 지우고 다시 놓는다. 겹쳐 쓰면 옛 판의 파일이 남아 무엇이 도는지 모르게 된다.
71
+ fs.rmSync(to, { recursive: true, force: true });
72
+ if (link) fs.symlinkSync(path.join(src, name), to);
73
+ else fs.cpSync(path.join(src, name), to, { recursive: true });
74
+ process.stdout.write(` ${link ? '링크' : '복사'} ~/.claude/skills/${name}\n`);
75
+ n += 1;
76
+ }
77
+ return n;
78
+ }
79
+
80
+ // genetics-mcp --install-skill 복사한다 — 레지스트리판
81
+ // genetics-mcp --install-skill --link 심볼릭 링크 — 레포에서 고치며 쓰는 개발 기계
82
+ if (process.argv.includes('--install-skill')) {
83
+ const n = installSkill(process.argv.includes('--link'));
84
+ process.stdout.write(`\u25b8 스킬 ${n}개를 폈다. **세션을 다시 열어야** 잡힌다.\n`);
85
+ process.exit(0);
86
+ }
87
+
88
+ // ── 설정 ────────────────────────────────────────────────────────────
89
+ // 경로 자체도 환경으로 덮을 수 있어야 한다 — 아래 규칙(환경이 파일을 이긴다)과 같은 이유다.
90
+ const cfg0 = process.env;
91
+ // 자격증명 파일. **경로에 조직 이름을 박지 않는다** — 출하물이 "그 조직의 키가 어디 있는지"를
92
+ // 알려 주는 지도가 되면 안 된다. 옛 자리는 폴백으로만 남긴다(당장 쓰는 기계가 안 깨지게).
93
+ const ENV_FILE = cfg0.GENETICS_ENV || path.join(os.homedir(), '.config/genetics/api.env');
94
+
95
+ // **환경변수가 파일을 이긴다.** 반대로 짰다가 실제 사고를 냈다(2026-08-12):
96
+ // 검증용 키를 env 로 주고 돌렸는데 파일의 운영 키가 이겨서 **실제 고객사에 발행**됐다.
97
+ // 오버라이드가 안 먹는 설정은 "안전한 기본값"이 아니라 함정이다.
98
+ const cfg = {};
99
+ try {
100
+ for (const line of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) {
101
+ const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*)\s*$/);
102
+ if (m) cfg[m[1]] = m[2];
103
+ }
104
+ } catch { /* 파일이 없으면 env 로만 동작한다 */ }
105
+ Object.assign(cfg, Object.fromEntries(
106
+ Object.entries(process.env).filter(([k]) => k.startsWith('GENETICS_'))
107
+ ));
108
+
109
+ // **기본 주소를 두지 않는다.** 값을 박아 두면 출하물이 "그 서버를 아는 클라이언트" 가 되고,
110
+ // 공개 배포 시 운영 엔드포인트가 그대로 공개된다. 주소는 쓰는 쪽이 정한다.
111
+ const BASE = (cfg.GENETICS_API_BASE || '').replace(/\/$/, '');
112
+ const KEY = cfg.GENETICS_API_KEY || '';
113
+
114
+ /* ── 자격은 두 갈래다 ────────────────────────────────────────────────────
115
+ *
116
+ * 웹에서 키를 발급받지 않았어도 **SSO 로그인만으로** 쓸 수 있어야 한다(사용자 결정
117
+ * 2026-08-19). 그래서 이 서버는 둘 중 하나를 쥔다:
118
+ *
119
+ * 키(rpk_) `api.env` 에 사람이 넣는다. **키 하나가 고객사 하나**다.
120
+ * 토큰(rpu_) `login` 도구가 기기 코드 흐름으로 받아 아래 파일에 넣는다. 사람 하나다.
121
+ *
122
+ * **둘 다 있으면 키가 이긴다.** 값이 아니라 **범위**로 고른 규칙이다 — 키는 고객사
123
+ * 하나에 매여 있고 토큰은 담당 전부에 닿는다. 좁은 쪽을 기본으로 두면 잘못 쥐었을 때
124
+ * 덜 다친다. 어느 쪽으로 나가는지는 `whoami` 가 말해 주고, 실패문에도 적는다.
125
+ */
126
+ const CRED_FILE = cfg0.GENETICS_CRED
127
+ || path.join(path.dirname(ENV_FILE), 'credentials.json');
128
+
129
+ function credRead() {
130
+ try {
131
+ const j = JSON.parse(fs.readFileSync(CRED_FILE, 'utf8'));
132
+ return j && typeof j.token === 'string' ? j : null;
133
+ } catch { return null; }
134
+ }
135
+
136
+ function credWrite(obj) {
137
+ fs.mkdirSync(path.dirname(CRED_FILE), { recursive: true, mode: 0o700 });
138
+ // **먼저 좁히고 쓴다.** 열린 채로 쓰고 나중에 chmod 하면 그 사이에 읽힐 수 있다.
139
+ const fd = fs.openSync(CRED_FILE, 'w', 0o600);
140
+ try { fs.writeFileSync(fd, JSON.stringify(obj, null, 2) + '\n'); } finally { fs.closeSync(fd); }
141
+ fs.chmodSync(CRED_FILE, 0o600); // 이미 있던 파일이면 openSync 의 mode 가 안 먹는다
142
+ }
143
+
144
+ /* ── 진행 중인 로그인 ────────────────────────────────────────────────────
145
+ *
146
+ * `device_code` 는 **자격이다** — 승인만 되면 그것으로 토큰을 받아 간다. 그런데 첫 판은
147
+ * 그 값을 사람이 읽는 안내문에 그대로 실었다(48자). 그 글을 어디든 붙여 넣는 순간
148
+ * 승인 대기 중인 자격이 통째로 새어 나간다.
149
+ *
150
+ * 그래서 로컬에 두고, 마무리할 때 여기서 읽는다. 사람이 읽는 글에는 **사람이 칠 코드**만
151
+ * 남는다(그건 승인 화면이 로그인을 요구하므로 그 자체로는 자격이 아니다).
152
+ */
153
+ const PENDING_FILE = path.join(path.dirname(CRED_FILE), 'pending.json');
154
+
155
+ function pendingWrite(obj) {
156
+ fs.mkdirSync(path.dirname(PENDING_FILE), { recursive: true, mode: 0o700 });
157
+ const fd = fs.openSync(PENDING_FILE, 'w', 0o600);
158
+ try { fs.writeFileSync(fd, JSON.stringify(obj) + '\n'); } finally { fs.closeSync(fd); }
159
+ fs.chmodSync(PENDING_FILE, 0o600);
160
+ }
161
+ function pendingRead() {
162
+ try { return JSON.parse(fs.readFileSync(PENDING_FILE, 'utf8')); } catch { return null; }
163
+ }
164
+ function pendingClear() { try { fs.rmSync(PENDING_FILE, { force: true }); } catch {} }
165
+
166
+ /** 지금 무엇으로 부를 것인가. 키가 먼저다(위 주석의 이유). */
167
+ function credential() {
168
+ if (KEY) return { kind: 'key', token: KEY };
169
+ const c = credRead();
170
+ if (c) return { kind: 'user', token: c.token, user: c.user, expires_at: c.expires_at };
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * **키를 아무 데나 보내지 않는다.**
176
+ *
177
+ * 이 키는 쓰기 권한이다 — 문서를 새로 내고 기존 문서를 갈아 끼운다. 그런데 주소는 설정 파일
178
+ * 한 줄이라, 그 줄만 바꾸면 키가 통째로 남의 서버로 간다(오설정이든, "새 주소로 바꾸세요" 든).
179
+ * 그래서 보낼 수 있는 곳을 **미리 정해 두고**, 아니면 요청 자체를 안 만든다.
180
+ *
181
+ * 주소를 평문으로 안 적고 해시로 둔다.
182
+ *
183
+ * ⚠ **이 해시는 주소를 숨기지 못한다. 실측이다.** 공개될 정보(패키지 이름 `genetics-mcp`,
184
+ * 설명, 자격증명 경로 `~/.config/genetics/`)로 후보 사전을 만들면 브랜드 4 × TLD 10 ×
185
+ * 서브도메인 14 = 560개 시도에서 **1밀리초 만에 둘 다 복원됐다**(2026-08-15).
186
+ * 그러니 이 목록의 값어치는 **감추기가 아니라 막기**다 — 목록에 없는 주소로는 키가 안 나간다.
187
+ * 그 기능은 해시든 평문이든 똑같이 작동한다. 다음 사람이 이걸 기밀로 착각하지 않게 적어 둔다.
188
+ *
189
+ * 응답이 진짜 우리 서버에서 왔는지는 여기서 확인할 수 없다 — 그건 TLS 가 하는 일이고,
190
+ * 그 위에 얹는 자체 검사는 같은 기계에서 고칠 수 있으므로 아무것도 더 막지 못한다.
191
+ * **그래서 TLS 를 강제하는 것이 이 함수의 진짜 방어다**(아래 protocol 검사).
192
+ */
193
+ const HOST_ALLOW = [
194
+ '9ecbd5eaddc69508cfa8a672dc091ea1eb22136f981f903b15670240d04a9340',
195
+ '999db90e08def6b1900bec9290d3b951350741218375786e740ab6eec637b394',
196
+ ];
197
+
198
+ function hostAllowed(base) {
199
+ let u;
200
+ try { u = new URL(base); } catch { return false; }
201
+ const h = u.hostname;
202
+ // 로컬은 운영자 자기 기계다. 시험대(dev/journey)가 여기로 붙으므로 여기만 http 를 허용한다.
203
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1') return true;
204
+ /*
205
+ * **평문으로는 안 보낸다.**
206
+ *
207
+ * 이 키는 `Authorization: Bearer` 헤더에 그대로 실린다. TLS 가 없으면 같은 망에 있는 누구든
208
+ * 그대로 가져가고, 위 허용목록은 **아무 도움이 안 된다**(주소는 맞으니까).
209
+ * 호스트만 보던 동안 허용 호스트의 `http://…` 와 `http://…:8080` 이 **그대로 통과했다**
210
+ * (실측 2026-08-15). 목록에 있는 주소여도 스킴이 틀리면 거절한다.
211
+ */
212
+ if (u.protocol !== 'https:') return false;
213
+ return HOST_ALLOW.includes(crypto.createHash('sha256').update(h).digest('hex'));
214
+ }
215
+
216
+ /* ── 자산 원본 취득 — 계약 §7.1 「손 없는 경로」 ──────────────────────────
217
+ *
218
+ * 에이전트는 `id·role·alt·provider·origin·source` 만 채운다. **바이트는 여기서 처음
219
+ * 생긴다** — 계약 §7 「생성 도구와 발행 도구 사이에서 이미지 바이트를 도구 호출 인자로
220
+ * 실어 나르지 않는다」.
221
+ *
222
+ * ⚠ 위 HOST_ALLOW 와 **절대 합치지 않는다.** 저것은 「쓰기 권한 API 키를 어디로 보낼 수
223
+ * 있는가」이고 이것은 「어디서 바이트를 받아오는가」다. 합치면 이미지 호스트를 하나
224
+ * 추가할 때마다 API 키를 보낼 수 있는 곳이 하나 늘어난다.
225
+ *
226
+ * **평문으로 적는다.** 위 해시가 1밀리초에 복원됐듯 감추기는 값어치가 없고, 여기서
227
+ * 중요한 것은 이 도구가 어디서 바이트를 가져오는지를 **읽어서 확인할 수 있는 것**이다.
228
+ *
229
+ * **로컬·사설 주소 예외가 없다.** 위 hostAllowed 의 localhost 예외는 「운영자 자기
230
+ * 기계의 API」라서 성립한다. 이미지 취득에 그 예외를 옮기면 에이전트가 고른 주소 하나로
231
+ * 내부망과 클라우드 메타데이터를 긁어 고객 문서에 박을 수 있다.
232
+ */
233
+ const IMAGE_HOST_DEFAULT = [
234
+ 'cdn.higgsfield.ai',
235
+ 'files.higgsfield.ai',
236
+ // Higgsfield 가 실제로 산출물을 돌려주는 배포 도메인(2026-08-16 실측).
237
+ // CloudFront 배포 이름이라 언젠가 바뀔 수 있다 — 바뀌면 여기서 거절되고,
238
+ // 그때 사람이 실물 주소를 확인해 고친다. 자동으로 넓히지 않는다.
239
+ 'd8j0ntlcm91z4.cloudfront.net',
240
+ ];
241
+
242
+ /*
243
+ * 목록을 **설정으로 넓힐 수 있게** 한다(2026-08-18 검토 H8).
244
+ *
245
+ * 왜: 이 셋은 한 생성기의 주소다. 「다른 고객사·다른 도메인에서도 써야 한다」는 요구와
246
+ * 정면으로 부딪힌다 — 생성기를 바꾸는 순간 이 도구는 그림을 못 받는다.
247
+ *
248
+ * **허용 목록 자체는 없애지 않는다.** 그것이 SSRF 방어이고, 없애면 에이전트가 고른
249
+ * 주소 하나로 내부망을 긁어 고객 문서에 박을 수 있다. 바꾸는 것은 **목록의 내용**뿐이고,
250
+ * 그것도 코드가 아니라 운영자의 파일(600)에서 온다 — 에이전트가 못 고치는 자리다.
251
+ *
252
+ * ~/.config/genetics/api.env
253
+ * GENETICS_IMAGE_HOSTS=cdn.example.com,files.example.net
254
+ *
255
+ * 주면 **대체한다**(더하지 않는다). 더하기로 두면 지우려던 호스트가 남는다.
256
+ */
257
+ const IMAGE_HOST_ALLOW = (() => {
258
+ const raw = (cfg.GENETICS_IMAGE_HOSTS || '').trim();
259
+ if (!raw) return IMAGE_HOST_DEFAULT;
260
+ const list = raw.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean)
261
+ // 호스트 이름만 받는다 — 주소·포트·와일드카드는 거절한다.
262
+ .filter((x) => /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/.test(x));
263
+ return list.length ? list : IMAGE_HOST_DEFAULT;
264
+ })();
265
+
266
+ const IMAGE_MAX_DOWNLOAD = 8_000_000; // 원본 상한
267
+ const IMAGE_TIMEOUT_MS = 15_000;
268
+ const ASSET_MAX_ONE = 80_000; // 계약 §7 — 서버 상한과 같은 값
269
+ const ASSET_LONG_EDGE = 1400; // 계약 §7 — hero·챕터의 긴 변
270
+
271
+ function imageHostAllowed(url) {
272
+ let u;
273
+ try { u = new URL(url); } catch { return false; }
274
+ if (u.protocol !== 'https:') return false; // 평문으로는 안 받는다
275
+ if (u.username || u.password) return false; // 자격증명 밀수 금지
276
+ // IP 리터럴은 이름이 아니다 — 이름 목록으로 막는 방어가 안 통하는 입력이라 아예 거절한다.
277
+ if (/^\[/.test(u.hostname) || /^\d{1,3}(\.\d{1,3}){3}$/.test(u.hostname)) return false;
278
+ return IMAGE_HOST_ALLOW.includes(u.hostname.toLowerCase());
279
+ }
280
+
281
+ /** PNG·WebP·JPEG 의 픽셀 크기를 헤더에서 읽는다. 선언을 믿지 않기 위한 것이다. */
282
+ function imageDims(b) {
283
+ if (b.length > 24 && b[0] === 0x89 && b.toString('ascii', 1, 4) === 'PNG') {
284
+ return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
285
+ }
286
+ if (b.length > 30 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') {
287
+ const c = b.toString('ascii', 12, 16);
288
+ if (c === 'VP8X') return { w: (b.readUIntLE(24, 3) & 0xffffff) + 1, h: (b.readUIntLE(27, 3) & 0xffffff) + 1 };
289
+ if (c === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff };
290
+ if (c === 'VP8L') {
291
+ const n = b.readUInt32LE(21);
292
+ return { w: (n & 0x3fff) + 1, h: ((n >>> 14) & 0x3fff) + 1 };
293
+ }
294
+ }
295
+ if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
296
+ let i = 2;
297
+ while (i < b.length - 9) {
298
+ if (b[i] !== 0xff) { i++; continue; }
299
+ const m = b[i + 1];
300
+ if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
301
+ return { h: b.readUInt16BE(i + 5), w: b.readUInt16BE(i + 7) };
302
+ }
303
+ i += 2 + b.readUInt16BE(i + 2);
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
309
+ /** 원본을 **스트림으로** 받으며 상한을 넘는 순간 끊는다. */
310
+ async function fetchImage(url) {
311
+ if (!imageHostAllowed(url)) {
312
+ throw new Error(`자산 원본을 받아올 수 있는 주소가 아닙니다: ${url}\n`
313
+ + `허용 호스트: ${IMAGE_HOST_ALLOW.join(' · ')} (https 만, IP 주소 불가).`);
314
+ }
315
+ const res = await fetch(url, {
316
+ redirect: 'error', // 따라가면 허용목록이 무의미해진다
317
+ headers: { Accept: 'image/*' }, // Authorization 은 **붙이지 않는다**
318
+ signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS),
319
+ });
320
+ if (!res.ok) throw new Error(`자산 원본을 받지 못했습니다(HTTP ${res.status}): ${url}`);
321
+ const chunks = [];
322
+ let n = 0;
323
+ for await (const c of res.body) {
324
+ n += c.length;
325
+ if (n > IMAGE_MAX_DOWNLOAD) {
326
+ throw new Error(`자산 원본이 ${IMAGE_MAX_DOWNLOAD.toLocaleString()}B 를 넘습니다: ${url}`);
327
+ }
328
+ chunks.push(c);
329
+ }
330
+ return Buffer.concat(chunks);
331
+ }
332
+
333
+ /**
334
+ * 계약 §7 포맷 규정대로 **굳힌다.** 여기가 §7.1 ④ 의 「발행 도구의 프로세스」다.
335
+ * hero·chapter : WebP 손실 q78 · 긴 변 1400px
336
+ * eof·figure : WebP 무손실 · 원본 크기 (알파를 평탄화하지 않는다 — 계약 §9)
337
+ *
338
+ * cwebp 를 쓴다. npm 의존성 0 이라는 이 파일의 원칙을 지키면서 계약이 지정한 인코더를
339
+ * 그대로 쓰는 유일한 길이다(§9 의 무손실 실측치도 cwebp 기준이다).
340
+ * **없으면 조용히 넘어가지 않는다** — 계약 §7.1 「우회하지 말고 상태를 알린다」.
341
+ */
342
+ /**
343
+ * SVG 를 래스터로 굳힌다.
344
+ *
345
+ * 생성기가 벡터를 돌려줄 수 있는데(Recraft 의 vector 갈래), 계약 §7 은 `image/svg+xml` 을
346
+ * **발행 데이터로 받지 않는다** — 문서 안에서 실행 맥락을 갖는 포맷이라 열람 화면의
347
+ * 방어를 늘려야 하기 때문이다. 그래서 굳히는 것은 여기서 한다: 계약 §7.1 ④ 의
348
+ * 「발행 도구의 프로세스가 원본을 받아 포맷 규정대로 굳힌다」가 정확히 이 자리다.
349
+ */
350
+ function rasterizeSvg(buf) {
351
+ const r = spawnSync('rsvg-convert',
352
+ ['-w', String(ASSET_LONG_EDGE), '-b', 'white', '-f', 'png'],
353
+ { input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
354
+ if (r.error && r.error.code === 'ENOENT') {
355
+ throw new Error('rsvg-convert 가 없어 벡터 원본을 래스터로 굳힐 수 없습니다.\n'
356
+ + 'librsvg 를 설치하세요(brew install librsvg · apt install librsvg2-bin). '
357
+ + 'SVG 를 그대로 싣지 않습니다 — 계약이 발행 데이터로 받는 형식을 두 래스터로 못박았습니다.');
358
+ }
359
+ if (r.status !== 0) throw new Error(`벡터 래스터화 실패: ${String(r.stderr).slice(0, 200)}`);
360
+ return r.stdout;
361
+ }
362
+
363
+ const isSvg = (b) => {
364
+ const head = b.slice(0, 400).toString('utf8').trimStart();
365
+ return head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'));
366
+ };
367
+
368
+ function encodeAsset(buf, role) {
369
+ // 벡터가 오면 먼저 래스터로 굳힌다. 그 뒤는 래스터와 완전히 같은 길을 간다.
370
+ if (isSvg(buf)) buf = rasterizeSvg(buf);
371
+ const lossless = role === 'eof' || role === 'figure';
372
+ let args;
373
+ if (lossless) {
374
+ args = ['-quiet', '-lossless', '-exact', '-o', '-', '--', '-'];
375
+ } else {
376
+ const d = imageDims(buf);
377
+ args = ['-quiet', '-q', '78', '-sharp_yuv'];
378
+ // 계약은 「**긴 변** 1400px」이다. -resize 는 0 인 쪽을 비율로 채우므로,
379
+ // 세로가 긴 원본에 폭을 고정하면 긴 변이 1400 을 넘는다.
380
+ //
381
+ // **줄이기만 한다.** 원본이 이미 작으면 늘려도 정보가 안 늘고 화질만 나빠진다 —
382
+ // 1400 은 채워야 할 할당량이 아니라 상한이다(A4 180mm 에서 약 200dpi).
383
+ if (d && Math.max(d.w, d.h) > ASSET_LONG_EDGE) {
384
+ const tall = d.h > d.w;
385
+ args.push('-resize', tall ? '0' : String(ASSET_LONG_EDGE),
386
+ tall ? String(ASSET_LONG_EDGE) : '0');
387
+ }
388
+ args.push('-o', '-', '--', '-');
389
+ }
390
+ const r = spawnSync('cwebp', args, { input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
391
+ if (r.error && r.error.code === 'ENOENT') {
392
+ throw new Error('cwebp 가 없어 자산을 계약 규격(WebP q78 · 긴 변 1400px)으로 굳힐 수 없습니다.\n'
393
+ + 'libwebp 를 설치하세요(brew install webp · apt install webp). '
394
+ + '다른 포맷으로 우회하지 않습니다 — 계약이 포맷을 규정으로 못박았습니다.');
395
+ }
396
+ if (r.status !== 0) throw new Error(`자산 인코딩 실패: ${String(r.stderr).slice(0, 200)}`);
397
+ if (r.stdout.length > ASSET_MAX_ONE) {
398
+ throw new Error(`자산이 ${r.stdout.length.toLocaleString()}B 로 장당 상한 `
399
+ + `${ASSET_MAX_ONE.toLocaleString()}B 를 넘습니다. 해상도·품질이 아니라 **그림을 단순하게** `
400
+ + '하세요 — 계약이 요구하는 것은 넓은 여백의 4색 선화이고, 예산을 넘는 그림은 대개 그 '
401
+ + '요구를 이미 어긴 그림입니다.');
402
+ }
403
+ return r.stdout;
404
+ }
405
+
406
+ /**
407
+ * `source` 가 있는 자산을 받아와 굳혀 넣는다.
408
+ *
409
+ * `mime`·`width`·`height` 는 에이전트에게 받지 않고 **인코딩 결과에서 읽는다.**
410
+ * 운영 문서가 1400x934 그림을 1400x1050 이라 선언해 브라우저가 세로로 12.4% 늘여
411
+ * 그리고 있었다(2026-08-16 실측). 선언을 안 받으면 그 결함이 구조적으로 안 생긴다.
412
+ */
413
+ async function materializeAssets(data) {
414
+ if (!data || !Array.isArray(data.assets) || !data.assets.length) return data;
415
+ const out = [];
416
+ for (const a of data.assets) {
417
+ const { data: dropped, ...meta } = a ?? {};
418
+ if (!a?.source) {
419
+ // source 가 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
420
+ out.push(dropped === undefined ? meta : { ...meta, data: dropped });
421
+ continue;
422
+ }
423
+ const raw = await fetchImage(String(a.source));
424
+ const enc = encodeAsset(raw, String(a.role ?? ''));
425
+ const d = imageDims(enc);
426
+ out.push({
427
+ ...meta,
428
+ mime: 'image/webp',
429
+ ...(d ? { width: d.w, height: d.h } : {}),
430
+ data: 'data:image/webp;base64,' + enc.toString('base64'),
431
+ });
432
+ }
433
+ return { ...data, assets: out };
434
+ }
435
+
436
+ /** 자격 없이 부르는 경로(기기 인증). 주소 검사는 **똑같이** 건다 — 여기로 토큰이 돌아온다. */
437
+ async function apiOpen(method, pathname, body) {
438
+ if (!BASE) throw new Error(`발행 주소가 없습니다. ${ENV_FILE} 에 GENETICS_API_BASE 를 넣으세요(예: https://api.example.com).`);
439
+ if (!hostAllowed(BASE)) {
440
+ throw new Error(`보낼 수 있는 주소가 아닙니다: ${BASE}\n`
441
+ + `${ENV_FILE} 의 GENETICS_API_BASE 를 확인하세요(https 여야 합니다).`);
442
+ }
443
+ const res = await fetch(BASE + pathname, {
444
+ method,
445
+ headers: { 'Content-Type': 'application/json' },
446
+ body: body === undefined ? undefined : JSON.stringify(body),
447
+ });
448
+ const text = await res.text();
449
+ let data;
450
+ try { data = JSON.parse(text); } catch { throw new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`); }
451
+ return { status: res.status, data };
452
+ }
453
+
454
+ async function api(method, pathname, body) {
455
+ if (!BASE) throw new Error(`발행 주소가 없습니다. ${ENV_FILE} 에 GENETICS_API_BASE 를 넣으세요(예: https://api.example.com).`);
456
+ const cred = credential();
457
+ if (!cred) {
458
+ throw new Error('자격이 없습니다. 둘 중 하나를 하세요 — '
459
+ + `① \`login\` 도구로 SSO 로그인(웹에서 키를 안 받아도 됩니다) `
460
+ + `② ${ENV_FILE} 에 GENETICS_API_KEY 를 넣기.`);
461
+ }
462
+ // 어디로 보낼 수 있는지는 **말하지 않는다** — 틀린 주소를 쥔 쪽에게 정답을 알려 줄 이유가 없다.
463
+ if (!hostAllowed(BASE)) {
464
+ throw new Error(`이 자격을 보낼 수 있는 주소가 아닙니다: ${BASE}\n`
465
+ + `${ENV_FILE} 의 GENETICS_API_BASE 를 확인하세요(https 여야 합니다). `
466
+ + '주소를 바꾸라는 안내를 받았다면 의심할 것.');
467
+ }
468
+ const res = await fetch(BASE + pathname, {
469
+ method,
470
+ headers: { Authorization: `Bearer ${cred.token}`, 'Content-Type': 'application/json' },
471
+ body: body === undefined ? undefined : JSON.stringify(body),
472
+ });
473
+ const text = await res.text();
474
+ let data;
475
+ try { data = JSON.parse(text); } catch { throw new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`); }
476
+ if (!res.ok || data.ok === false) {
477
+ /*
478
+ * **다음에 할 일을 말한다.** 사람 토큰이 끝난 것은 「고장」이 아니라 「다시 로그인할 때」다.
479
+ * 그냥 401 을 옮기면 에이전트는 키를 의심하며 두드린다.
480
+ */
481
+ if (cred.kind === 'user' && (data.expired || data.revoked)) {
482
+ throw new Error(`${data.error || `HTTP ${res.status}`}\n→ \`login\` 도구로 다시 로그인하세요.`);
483
+ }
484
+ throw new Error(data.error || `HTTP ${res.status}`);
485
+ }
486
+ return data;
487
+ }
488
+
489
+ // ── 도구 ────────────────────────────────────────────────────────────
490
+ // 이 도구가 발행하는 것은 **"수정 내역 보고" 서식 하나**다(서버의 build.py 가 그것만 안다).
491
+ // 포털 자체는 문서 종류를 가리지 않지만, 데이터로 발행하는 길은 아직 이 서식뿐이다.
492
+ //
493
+ // 필수 칸을 세우지 않는 이유: 전에는 표지 4칸과 건마다 6칸이 다 차 있어야 발행이 됐고,
494
+ // 그래서 **초안을 올려 두고 다듬는 일이 아예 불가능했다.** 비어 있는 것은 사람이 정할 일이다.
495
+ // 서버가 여전히 막는 것은 **조용히 사라지는 것**뿐이다 — 모르는 kind 의 절, 없는 그룹의 건.
496
+ const REPORT_DATA = {
497
+ type: 'object',
498
+ description: '보고서 내용. 짝인 스킬의 report.json + cases 를 하나로 합친 형태.',
499
+ properties: {
500
+ /*
501
+ * 서식·팔레트 인자는 **여기 없다.** 서버가 정한다(report_publish.php 의
502
+ * REPORT_TEMPLATE_DEFAULT, 그리고 서식이 팔레트를 강제하는 결합).
503
+ * 값이 하나뿐이고 서버가 덮어쓰는 것을 도구 표면에 올려 두면, 부르는 쪽은 고를 수 있다고
504
+ * 믿는데 실제로는 무시된다 — 거짓말하는 인자다. 고를 것이 생기면 그때 연다.
505
+ */
506
+ title_tag: { type: 'string', description: '브라우저 탭 제목' },
507
+ title: { type: 'string', description: '표지 제목' },
508
+ kicker: { type: 'string', description: '표지 윗줄 (예: "회사명 · 프로젝트")' },
509
+ subtitle: { type: 'string', description: '표지 한 문단 요약' },
510
+ description: { type: 'string' },
511
+ meta: { type: 'array', description: '표지 메타 [[라벨, 값], ...]' },
512
+ stats: { type: 'array', description: '표지 숫자 [{value, unit, label}]' },
513
+ grades: { type: 'object', description: '확인 등급 정의 {이름: [클래스, 설명]}' },
514
+ groups: { type: 'array', description: '건 묶음 [{key, no}]' },
515
+ sections: {
516
+ type: 'array',
517
+ description: '절 구성 [{no, id, name, kind, group?, chapter?, principles?, logic?}] · kind 는 summary|status|cases|asks|note '
518
+ + '중 하나여야 한다 — 그 밖의 값은 렌더러가 절을 통째로 빠뜨리므로 서버가 거절한다. '
519
+ + 'chapter 에 아래 chapters 의 id 를 적으면 그 절 앞에 챕터 오프닝이 펼쳐진다. '
520
+ + 'name 은 목차에 쓰는 **짧은 이름**이고, 본문의 큰 제목은 챕터의 title_lines 가 낸다.\n'
521
+ + '\n'
522
+ + '절 앞에 세울 수 있는 블록 둘(선택):\n'
523
+ + '· `principles`: [[짧은제목, 설명], …] — 3열 카드. 번호는 자리로 매겨지니 주지 않는다. '
524
+ + '개선 원칙처럼 **셋 안팎으로 나뉘는 규칙**에 쓴다. 긴 문단은 그대로 paragraphs 에 둔다.\n'
525
+ + '· `logic`: {kicker, title_lines[], lede, models:[{label, flow[], body}, …]} — 대조 도해. '
526
+ + '`flow` 는 「현재 상태 → 인접 등급 → 도착점」 같은 단계이고 **마지막 칸이 강조**된다. '
527
+ + '첫 모델이 「현재」, 그다음이 「개선」으로 그려진다(자리로 정한다).\n'
528
+ + '둘 다 **데이터가 있을 때만** 그려진다 — 없는 문구를 지어내지 말 것.',
529
+ },
530
+ chapters: {
531
+ type: 'array',
532
+ description: '챕터 오프닝. 절 앞에 「번호 · 짧은 제목 · 예상 읽기 시간 → 큰 제목 → 핵심 문장 → '
533
+ + '일러스트 → 이 장에서 다룰 내용」 순서로 펼쳐진다. 챕터를 하나라도 주면 문서 앞에 '
534
+ + '번호형 전체 목차가 함께 선다.',
535
+ items: {
536
+ type: 'object',
537
+ properties: {
538
+ id: { type: 'string', description: 'sections[].chapter 가 가리키는 이름' },
539
+ no: { type: 'string', description: '"01" 처럼 두 자리' },
540
+ short_title: { type: 'string', description: '목차와 오프닝 머리에 쓰는 짧은 이름' },
541
+ reading_minutes: { type: 'integer', description: '예상 읽기 시간(분)' },
542
+ title_lines: {
543
+ type: 'array', items: { type: 'string' },
544
+ description: '큰 제목을 **의미 단위로 끊어** 준다. 브라우저가 임의로 줄을 나누면 '
545
+ + '핵심 명사만 떨어져 나가므로 줄바꿈은 데이터가 정한다.',
546
+ },
547
+ lead: {
548
+ type: 'string',
549
+ description: '이 장을 읽어야 하는 이유 1–2문장. 제목을 반복하지 않는다. '
550
+ + '이 값이 있으면 같은 절의 lede 는 싣지 않는다(핵심 문장은 하나다).',
551
+ },
552
+ index_line: { type: 'string', description: '전체 목차의 한 줄 설명. 없으면 lead 를 쓴다' },
553
+ asset: {
554
+ type: 'string',
555
+ description: '이 장의 일러스트 id (assets[] 의 id). 장 머리에서 글 기둥과 '
556
+ + '**좌우로 나란히** 서고 장마다 자리를 바꾼다(홀수 장 글 왼쪽 / 짝수 장 글 오른쪽). '
557
+ + '글이 그림 위에 얹히지 않으므로 그림은 **자기 칸을 꽉 채우면 된다** — '
558
+ + '한쪽을 비우지 말 것. 판정은 tools/visual-baseline.py.',
559
+ },
560
+ side: {
561
+ type: 'string', enum: ['l', 'r'],
562
+ description: '이 장에서 **글이 서는 쪽**. 생략하면 홀수 장 왼쪽 / 짝수 장 오른쪽으로 '
563
+ + '교차한다(정본 `is-reverse`). 그림의 구도와 어긋날 때만 지정한다 — '
564
+ + '교차는 기본값이지 법이 아니다.',
565
+ },
566
+ topics: { type: 'array', items: { type: 'string' }, description: '이 장에서 다룰 내용' },
567
+ },
568
+ required: ['id', 'no'],
569
+ },
570
+ },
571
+ summary: {
572
+ type: 'array',
573
+ description: 'summary 절 내용 [{lead, body, refs?}] — 각 문단은 근거로 삼은 **사례 번호**를 '
574
+ + '최소 하나 밝혀야 한다(예: 본문에 "§3.1" 이라 쓰거나 refs:["3.1"]). 절 번호가 아니라 '
575
+ + '사례 번호다. 근거가 없으면 서버가 발행을 거절한다.',
576
+ },
577
+ open_items: { type: 'array', description: 'asks 절 내용 [{title, body, refs?}]' },
578
+ footer: { type: 'array' },
579
+ assets: {
580
+ type: 'array',
581
+ description: '그림. **다 그려질 때까지 기다리지 말 것** — `brief` 에 장면 한두 문장만 '
582
+ + '적으면 서버가 그려서 나중에 채운다(문서는 그 자리를 잡은 채로 즉시 발행된다). '
583
+ + '이미 그린 그림이 있으면 `source` 에 원본 주소를 준다. '
584
+ + '**바이트를 직접 싣지 말 것** — `source` 를 주면 이 도구가 '
585
+ + '받아와 규격대로 굳혀 보낸다(hero·chapter 는 WebP q78 긴 변 1400px, eof·figure 는 무손실). '
586
+ + '크기와 형식은 인코딩 결과에서 읽으므로 선언하지 않는다. 전부 data: URI 로 문서에 박히며 '
587
+ + '(열람 CSP 가 외부 이미지를 막는다), 서버가 출처·매직바이트·예산(장당 80,000B·합계 400,000B)을 '
588
+ + '다시 검증한다.',
589
+ items: {
590
+ type: 'object',
591
+ properties: {
592
+ id: { type: 'string', description: '영소문자·숫자·하이픈 1~40자. 문서 안 참조 이름' },
593
+ role: {
594
+ type: 'string', enum: ['hero', 'chapter', 'figure', 'eof'],
595
+ description: '`eof` 는 **문서 끝 꼬리 카드**다. 안 줘도 된다 — 서버가 기본 카드를 '
596
+ + '끼운다(계약 9절). 이 문서만의 카드가 있을 때만 준다. 그때는 무손실(PNG 또는 '
597
+ + '무손실 WebP)이어야 하고 줄이지 않고 원본 크기로 실린다.',
598
+ },
599
+ provider: {
600
+ type: 'string', enum: ['higgsfield', 'deterministic', 'user-provided'],
601
+ description: 'hero·chapter 는 higgsfield 여야 한다. deterministic 은 role: figure 에 한한다. '
602
+ + 'user-provided 는 origin 이 필수다. sample-exception 은 발행 거절 값',
603
+ },
604
+ alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
605
+ origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
606
+ brief: {
607
+ type: 'string',
608
+ description: '**무엇을 그릴지 — 장면 한두 문장.** 이것만 주면 서버가 그린다(그리는 동안 '
609
+ + '그 자리는 「그리는 중」으로 나가고, 다 되면 문서가 스스로 갱신된다). '
610
+ + '스타일·색·선 굵기는 **적지 않는다** — 그건 서식이라 서버가 얹는다. '
611
+ + '장면은 그 장의 데이터에서 나와야 한다: title_lines(주장) · lead(대비시킬 것) · '
612
+ + 'topics(화면에 놓을 오브젝트) · 그 장의 cases(행동의 근거). '
613
+ + '⚠ 오브젝트는 **개수가 아니라 이름으로** 적는다 — 「원형 노드 다섯 개」라고 하면 '
614
+ + '모델이 빈칸을 스톡 아이콘으로 채운다(실측: 우산·돼지저금통·하트비트가 나왔다). '
615
+ + '장마다 시점·주체·동작을 일부러 갈라 준다(안 그러면 참조 때문에 전 장이 같은 포즈로 수렴한다).',
616
+ },
617
+ aspect: {
618
+ type: 'string', enum: ['16:9', '4:3', '3:4', '1:1', '3:2', '2:3', '9:16'],
619
+ description: '생략하면 역할이 정한다 — hero 16:9 · 그 밖 4:3.',
620
+ },
621
+ ref: {
622
+ type: 'boolean',
623
+ description: '거짓이면 **세트 참조 없이 단독으로** 그린다. 기본은 참 — hero 를 먼저 '
624
+ + '그려 그것을 참조로 나머지를 이어야 한 세트로 보인다(참조 없이 내면 장마다 '
625
+ + '인물이 달라진다). hero 자신에게는 뜻이 없다.',
626
+ },
627
+ source: {
628
+ type: 'string',
629
+ description: '이미 그린 그림의 원본 https 주소(허용 호스트 목록 안). **이 도구의 프로세스가** '
630
+ + '받아와 규격대로 굳혀 보낸다. 이미지 바이트를 도구 호출 인자로 실어 나르지 않는다. '
631
+ + '`brief` 와 함께 주면 `source` 가 이긴다(이미 있는 것을 다시 그리지 않는다).',
632
+ },
633
+ required: { type: 'boolean', description: '이 그림이 빠지면 문서가 성립하지 않는가' },
634
+ data: {
635
+ type: 'string',
636
+ description: '(구식) data:image/webp;base64,… — `source` 를 쓰면 이 칸은 무시된다. '
637
+ + '이미 발행한 문서를 고칠 때만 남겨 둔다.',
638
+ },
639
+ },
640
+ required: ['id', 'role', 'provider'],
641
+ },
642
+ },
643
+ cases: {
644
+ type: 'array',
645
+ description: '수정 건. 증상·원인·조치·확인 방법을 채우는 것이 이 서식의 뜻이지만,'
646
+ + ' 비워 두고 올린 뒤 다듬어도 된다(발행을 막지 않는다).',
647
+ items: {
648
+ type: 'object',
649
+ properties: {
650
+ group: { type: 'string' },
651
+ title: { type: 'string' },
652
+ grade: { type: 'string', description: 'grades 에 정의된 이름이어야 한다' },
653
+ symptom: { type: 'string' },
654
+ cause: { type: 'string' },
655
+ fix: { type: 'string' },
656
+ how_to_check: { type: 'string' },
657
+ remaining: { type: 'string', description: '고객 확인이 필요한 사항(선택)' },
658
+ source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
659
+ },
660
+ // group 만 필수다 — 없는 그룹에 속한 건은 어디에도 안 실려 조용히 사라진다.
661
+ required: ['group'],
662
+ },
663
+ },
664
+ },
665
+ // 문서가 서려면 있어야 하는 것만. 나머지는 비어 있어도 발행된다.
666
+ required: ['title_tag', 'sections'],
667
+ };
668
+
669
+ /* ── 세션 이식 ───────────────────────────────────────────────────────
670
+ *
671
+ * 이 기계의 대화(`~/.claude/projects/<슬러그>/<uuid>.jsonl`)를 검열해 서버에 두고,
672
+ * 다른 기계가 받아 `claude --resume <uuid>` 로 이어 가게 한다.
673
+ *
674
+ * Phase 0(2026-08-19) 실측이 이 코드의 전제다:
675
+ * · 원본과 다른 경로·다른 슬러그에 심어도 resume 가 열린다
676
+ * · `tool_result` 를 **가려도** 안 깨진다 → 그래서 지우지 않고 치환한다
677
+ * · 첨부·파일이력을 빼면 307KB → 16KB (−95%)
678
+ * · 슬러그는 **해석된 경로**로 만들어야 한다 — macOS 의 `/tmp` 는 `private/tmp`
679
+ * 심볼릭 링크라 리터럴 경로로 만들면 엉뚱한 디렉터리에 놓인다(그렇게 세 번 틀렸다)
680
+ */
681
+
682
+ /** 비밀 **모양** — `www/portal/lib/agent_session.php` 의 `agent_secret_shapes()` 와 **같아야 한다.**
683
+ * PHP 와 JS 는 코드를 공유할 수 없으므로 `mcp/smoke.mjs` 가 두 목록의 이름을 대조한다. */
684
+ const SECRET_SHAPES = [
685
+ ['lim-key', /\brpk_[A-Za-z0-9]{8,}/g],
686
+ ['lim-user', /\brpu_[A-Za-z0-9]{8,}/g],
687
+ ['lim-device', /\brpd_[A-Za-z0-9]{8,}/g],
688
+ ['sshpass', /\bsshpass\s+-p\s*\S+/g],
689
+ ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/g],
690
+ ['aws-key', /\bAKIA[0-9A-Z]{16}\b/g],
691
+ ['github-token', /\b(ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g],
692
+ ['openai-key', /\bsk-[A-Za-z0-9]{20,}/g],
693
+ ['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}/g],
694
+ ];
695
+ const MASK = '[가림: 서버에 올리며 지움]';
696
+ const DROP_TYPES = new Set(['file-history-snapshot', 'file-history-delta', 'attachment']);
697
+
698
+ function maskSecrets(text) {
699
+ let n = 0;
700
+ for (const [, re] of SECRET_SHAPES) {
701
+ text = text.replace(re, () => { n++; return MASK; });
702
+ }
703
+ return [text, n];
704
+ }
705
+
706
+ /** 트랜스크립트를 검열한다. 서버도 같은 일을 다시 한다 — 클라이언트는 낡을 수 있다. */
707
+ function scrubTranscript(raw) {
708
+ const out = [];
709
+ let redacted = 0, dropped = 0, no = 0;
710
+ for (const line of raw.split('\n')) {
711
+ no++;
712
+ if (!line.trim()) continue;
713
+ let r;
714
+ try { r = JSON.parse(line); } catch {
715
+ // 검열을 못 거친 줄은 안 보낸다. 조용히 흘리면 원문이 그대로 나간다.
716
+ throw new Error(`${no}번째 줄을 읽을 수 없습니다 — 검열을 못 거친 줄은 안 올립니다.`);
717
+ }
718
+ if (DROP_TYPES.has(r.type)) { dropped++; continue; }
719
+ if ('toolUseResult' in r) { r.toolUseResult = MASK; redacted++; }
720
+ if (typeof r.cwd === 'string') r.cwd = r.cwd.replace(/^\/(Users|home)\/[^/]+/, '~');
721
+ const c = r?.message?.content;
722
+ if (typeof c === 'string') {
723
+ const [t, n] = maskSecrets(c); r.message.content = t; redacted += n;
724
+ } else if (Array.isArray(c)) {
725
+ for (const b of c) {
726
+ if (!b || typeof b !== 'object') continue;
727
+ if (b.type === 'tool_result') { b.content = MASK; redacted++; } // 짝은 남긴다
728
+ else if (b.type === 'tool_use' && b.input !== undefined) {
729
+ const [t, n] = maskSecrets(JSON.stringify(b.input));
730
+ if (n) { redacted += n; try { b.input = JSON.parse(t); } catch { b.input = { 가림: true }; } }
731
+ } else if (b.type === 'text' && typeof b.text === 'string') {
732
+ const [t, n] = maskSecrets(b.text); b.text = t; redacted += n;
733
+ } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
734
+ const [t, n] = maskSecrets(b.thinking); b.thinking = t; redacted += n;
735
+ }
736
+ }
737
+ }
738
+ out.push(JSON.stringify(r));
739
+ }
740
+ if (!out.length) throw new Error('빈 트랜스크립트입니다.');
741
+ return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted };
742
+ }
743
+
744
+ /** cwd → 트랜스크립트 디렉터리. **해석된 경로**로 슬러그를 만든다(심볼릭 링크 함정). */
745
+ function transcriptDir(dir) {
746
+ let real = dir;
747
+ try { real = fs.realpathSync(dir); } catch { /* 없으면 준 값 그대로 */ }
748
+ return path.join(os.homedir(), '.claude', 'projects', real.replace(/[/_]/g, '-'));
749
+ }
750
+
751
+ /** uuid 로 이 기계의 트랜스크립트를 찾는다. 지금 프로젝트를 먼저 보고, 없으면 전체를 훑는다. */
752
+ function findTranscript(uuid) {
753
+ const here = path.join(transcriptDir(process.cwd()), `${uuid}.jsonl`);
754
+ if (fs.existsSync(here)) return here;
755
+ const root = path.join(os.homedir(), '.claude', 'projects');
756
+ for (const d of fs.readdirSync(root, { withFileTypes: true })) {
757
+ if (!d.isDirectory()) continue;
758
+ const f = path.join(root, d.name, `${uuid}.jsonl`);
759
+ if (fs.existsSync(f)) return f;
760
+ }
761
+ return null;
762
+ }
763
+
764
+ function gitHere() {
765
+ try {
766
+ const r = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--abbrev-ref', 'HEAD'],
767
+ { encoding: 'utf8' });
768
+ const b = (r.stdout || '').trim();
769
+ const c = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--short', 'HEAD'],
770
+ { encoding: 'utf8' });
771
+ return [b, (c.stdout || '').trim()].filter(Boolean).join('@');
772
+ } catch { return ''; }
773
+ }
774
+
775
+ /**
776
+ * 조각 하나의 상한. 서버 상한(8MB)보다 낮게 잡는다 — JSON 봉투와 이스케이프가
777
+ * 본문을 부풀리는데, 서버가 재는 것은 **디코드한 뒤**의 크기라 여유를 둔다.
778
+ */
779
+ const PUSH_PART_MAX = 6 * 1024 * 1024;
780
+
781
+ /**
782
+ * 글을 **줄 경계에서만** 조각낸다. 한 조각이 상한을 넘지 않게.
783
+ *
784
+ * ⚠ 한 줄이 상한보다 크면 **그 줄만 홀로** 한 조각이 된다 — 줄을 쪼개면 검열이 반쪽
785
+ * JSON 을 보게 되고, 그건 서버가 거절한다. 실측 최대 한 줄은 197KB 라 실제로는 안 걸린다.
786
+ *
787
+ * @returns {string[]} 각 조각은 줄바꿈으로 끝난다(마지막 조각 포함)
788
+ */
789
+ function splitAtLines(text, max) {
790
+ const out = [];
791
+ let cur = '';
792
+ let curBytes = 0;
793
+ for (const line of text.split('\n')) {
794
+ if (line === '') continue;
795
+ const piece = line + '\n';
796
+ /*
797
+ * ⚠ **바이트로 센다.** `piece.length` 는 UTF-16 단위라 한글이 3배로 어긋난다 —
798
+ * 3MB 로 나눴는데 8.7MB 가 가서 서버가 거절했다(2026-08-20 e2e 가 잡았다).
799
+ * 서버는 `strlen()`, 즉 바이트로 재므로 여기서도 바이트여야 한다.
800
+ */
801
+ const pieceBytes = Buffer.byteLength(piece, 'utf8');
802
+ if (cur !== '' && curBytes + pieceBytes > max) { out.push(cur); cur = ''; curBytes = 0; }
803
+ cur += piece;
804
+ curBytes += pieceBytes;
805
+ }
806
+ if (cur !== '') out.push(cur);
807
+ return out.length ? out : [text];
808
+ }
809
+
810
+ /**
811
+ * 이 기계의 대화 하나를 검열해서 올린다.
812
+ *
813
+ * **도구 손잡이와 자동 올리기가 같은 함수를 부른다.** 두 벌이 되면 사람이 부른 올리기와
814
+ * 심박이 부른 올리기가 다른 것을 보내게 되고, 그때 어느 쪽이 옳은지 알 방법이 없다.
815
+ *
816
+ * @return {{r: object, before: number}} 서버 응답과, 검열 전 파일 크기
817
+ */
818
+ async function pushSession(tenant, uuid, title, dryRun) {
819
+ const f = findTranscript(uuid);
820
+ if (!f) throw new Error(`이 기계에 그 세션이 없습니다: ${uuid}`);
821
+ const sc = scrubTranscript(fs.readFileSync(f, 'utf8'));
822
+ const before = fs.statSync(f).size;
823
+ /*
824
+ * ★ **제목을 안 정했으면 키를 아예 뺀다.** 예전에는 `title: title || ''` 로 빈 문자열을
825
+ * **명시해서** 보냈고 서버가 그것으로 기존 제목을 덮었다. 자동 올리기는 제목 없이 부르므로
826
+ * (`pushSession(테넌트, uuid, '')`), 사람이 붙인 이름이 **아무도 아무 것도 안 했는데** 지워졌다.
827
+ * 운영에서 이미 두 세션이 그렇게 잃었다(둘 다 updated_at 05:05:21, 같은 초).
828
+ * 없는 값을 「빈 값」으로 말하지 않는다 — 그 둘은 다른 뜻이다.
829
+ */
830
+ const body = {
831
+ tenant, uuid,
832
+ origin_cwd: process.cwd().replace(new RegExp(`^${os.homedir()}`), '~'),
833
+ origin_git: gitHere(), tools: { mcp: VERSION },
834
+ jsonl: sc.jsonl,
835
+ };
836
+ if (typeof title === 'string' && title.trim() !== '') body.title = title.trim();
837
+ if (dryRun) body.dry_run = true;
838
+
839
+ /*
840
+ * ── 긴 대화는 **조각으로** 보낸다 ─────────────────────────────────
841
+ *
842
+ * 서버 상한 8MB 는 메모리가 정한 값이다(운영 memory_limit=128M · 입력 40MB → 최고
843
+ * 122.9MB · 48MB 는 죽음, 2026-08-20 실측). 그런데 실제 세션이 검열 뒤에도 40.6MB 까지
844
+ * 나왔다(원본 132MB). 한 번에 보내면 서버가 넘어가거나 거절당한다.
845
+ *
846
+ * ⚠ **줄 경계에서만 나눈다.** 검열도 비밀 재검사도 줄 단위라, 한 줄이 두 조각에 걸치면
847
+ * 검열을 안 거친 반쪽이 올라간다. 서버도 같은 것을 확인하고 거절한다(두 겹).
848
+ */
849
+ const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
850
+ if (parts.length === 1 || dryRun) {
851
+ const r = await api('PUT', '/api/sessions', body);
852
+ if (!dryRun) indexNote(uuid, { tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
853
+ return { r, before };
854
+ }
855
+
856
+ const uploadId = crypto.randomBytes(12).toString('hex');
857
+ let last = null;
858
+ for (let i = 0; i < parts.length; i++) {
859
+ // 메타(제목·경로·도구)는 **조각마다** 싣는다. 작고, 마지막 조각에서만 쓰이지만
860
+ // 어느 조각이 마지막인지 서버가 아는 것과 부르는 쪽이 아는 것이 어긋날 이유가 없다.
861
+ last = await api('PUT', '/api/sessions', {
862
+ ...body, jsonl: parts[i],
863
+ upload_id: uploadId, part: i + 1, parts: parts.length,
864
+ });
865
+ process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} 올림 `
866
+ + `(${(parts[i].length / 1048576).toFixed(1)}MB)\n`);
867
+ }
868
+ indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms });
869
+ return { r: last, before };
870
+ }
871
+
872
+ /* ── 코로케이션 심박 ───────────────────────────────────────────────────
873
+ *
874
+ * 무엇을 푸는가: 한 대화를 **여러 기계가 동시에** 연다. 그런데 어느 기계가 가장 앞선
875
+ * 대화를 쥐고 있는지 아무도 모른다 — 이어받아 봤더니 어제 것이더라, 가 제일 흔한 사고다.
876
+ * 그래서 이 프로세스가 20초마다 자기 **머리(head)** 를 서버에 알린다.
877
+ *
878
+ * ── 왜 이 프로세스인가 (훅을 안 심는 이유) ────────────────────────────
879
+ * Claude Code 는 **창 하나에 MCP 서버 하나**를 띄우고, 그 프로세스 환경에
880
+ * `CLAUDE_CODE_SESSION_ID` 를 넣어 준다(2026-08-20 실측: PID 27461 env).
881
+ * 그러면 **이 프로세스가 살아 있음 = 이 기계가 이 세션을 열고 있음**이 그냥 성립한다.
882
+ * `~/.claude/settings.json` 에 훅을 심는 길도 있지만, 그건 남의 설정 파일을 고치는 일이고
883
+ * 지우면 조용히 안 뛴다. 설치물이 늘지 않는 쪽을 골랐다.
884
+ *
885
+ * ── 규격은 PHP 쪽이 정본이다 ─────────────────────────────────────────
886
+ * `www/portal/lib/agent_beat.php`. 코드를 공유할 수 없으므로 **같음을 시험이 붙든다**
887
+ * (`mcp/smoke.mjs` 가 저 파일에서 상수를 읽어 대조한다). 두 벌이 되면 한쪽만 고쳐지는 날이 온다.
888
+ *
889
+ * ⚠ **프레임 안은 바이트, 프레임 밖은 hex.** 경계에서 한쪽만 바꾸면 「같은 머리인데 영원히
890
+ * 다르다」가 되어 자동 올리기가 매 심박마다 도는 헛바퀴가 된다.
891
+ */
892
+ const BEAT = { MAGIC: 'LMB1', REPLY: 'LMBR', VER: 1, HEAD: 8, REC: 56, RREC: 32, MAX: 64 };
893
+ const BEAT_STATE = { OPEN: 0, WORKING: 1, CLOSED: 2 };
894
+ const BEAT_VERDICT = { SAME: 0, AHEAD: 1, BEHIND: 2, UNKNOWN: 3 };
895
+ const BEAT_WANT_PUSH = 0x01;
896
+
897
+ /** 심박 간격. 20초 = 서버가 죽었다고 보는 90초 안에 세 번 놓칠 여유. */
898
+ const BEAT_EVERY_MS = 20_000;
899
+ /** 안 바뀌어도 이 간격으로는 보낸다 — 「살아 있다」는 안 바뀌어도 알려야 한다. */
900
+ const BEAT_IDLE_MS = 60_000;
901
+ /** 자동 올리기의 최소 간격. 이게 없으면 긴 턴 하나가 심박마다 8MB 를 올린다. */
902
+ const AUTO_PUSH_MIN_MS = 90_000;
903
+ /** 이 창의 세션이 아니어도, 최근 이만큼 안에 움직인 사본은 「쥐고 있다」로 본다. */
904
+ const BEAT_RECENT_MS = 10 * 60_000;
905
+
906
+ function beatHex8(hex) {
907
+ const h = String(hex || '').replace(/[^0-9a-fA-F]/g, '').toLowerCase().padEnd(16, '0').slice(0, 16);
908
+ return Buffer.from(h, 'hex');
909
+ }
910
+ function beatHex8Str(buf) {
911
+ const h = buf.toString('hex');
912
+ return h === '0000000000000000' ? '' : h; // 「없다」와 「0이다」를 안 섞는다
913
+ }
914
+
915
+ function beatEncode(recs) {
916
+ if (!recs.length || recs.length > BEAT.MAX) throw new Error(`심박 레코드는 1~${BEAT.MAX}건입니다: ${recs.length}`);
917
+ const buf = Buffer.alloc(BEAT.HEAD + recs.length * BEAT.REC);
918
+ buf.write(BEAT.MAGIC, 0, 4, 'latin1');
919
+ buf.writeUInt8(BEAT.VER, 4);
920
+ buf.writeUInt8(recs.length, 5);
921
+ recs.forEach((r, i) => {
922
+ const o = BEAT.HEAD + i * BEAT.REC;
923
+ Buffer.from(r.uuid.replace(/-/g, ''), 'hex').copy(buf, o); // uuid 16
924
+ beatHex8(r.device).copy(buf, o + 16); // device 8
925
+ beatHex8(r.head_id).copy(buf, o + 24); // head_id 8
926
+ buf.writeBigUInt64BE(BigInt(Math.max(0, r.head_ms || 0)), o + 32); // head_ms 8
927
+ buf.writeUInt32BE(Math.max(0, r.lines || 0) >>> 0, o + 40); // lines 4
928
+ buf.writeUInt32BE(Math.max(0, r.tenant || 0) >>> 0, o + 44); // tenant 4
929
+ buf.writeUInt8(r.state ?? BEAT_STATE.OPEN, o + 48);
930
+ buf.writeUInt8(r.flags ?? 0, o + 49);
931
+ // 50..55 예비 — 0 인 채로 둔다(뒤에 칸을 붙일 자리).
932
+ });
933
+ return buf;
934
+ }
935
+
936
+ /** 응답 프레임 → 판정 배열. 모양이 틀리면 **예외가 아니라 null** — 심박은 조용히 실패한다. */
937
+ function beatReplyDecode(buf) {
938
+ if (!Buffer.isBuffer(buf) || buf.length < BEAT.HEAD) return null;
939
+ if (buf.toString('latin1', 0, 4) !== BEAT.REPLY || buf.readUInt8(4) !== BEAT.VER) return null;
940
+ const n = buf.readUInt8(5);
941
+ if (buf.length !== BEAT.HEAD + n * BEAT.RREC) return null;
942
+ const out = [];
943
+ for (let i = 0; i < n; i++) {
944
+ const o = BEAT.HEAD + i * BEAT.RREC;
945
+ const h = buf.toString('hex', o, o + 16);
946
+ out.push({
947
+ uuid: `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`,
948
+ lead: beatHex8Str(buf.subarray(o + 16, o + 24)),
949
+ lines: buf.readUInt32BE(o + 24),
950
+ verdict: buf.readUInt8(o + 28),
951
+ flags: buf.readUInt8(o + 29),
952
+ });
953
+ }
954
+ return out;
955
+ }
956
+
957
+ /** 이 기계의 고정 식별자. **이름이 아니라 지문**이 매 심박에 실린다. */
958
+ function deviceId() {
959
+ return crypto.createHash('sha256').update(`lim-beat/${deviceLabel()}`).digest('hex').slice(0, 16);
960
+ }
961
+
962
+ /* ── 이 기계가 아는 세션들 ──────────────────────────────────────────────
963
+ *
964
+ * 심박은 **서버에 올라온 세션에만** 보낸다(2026-08-20 결정). 그러려면 uuid 로 고객사 id 를
965
+ * 알아야 하는데, 프레임에는 이름이 아니라 id 가 실린다. 그 짝을 여기 적어 둔다 —
966
+ * `list_sessions`·`push_session`·`resume_session` 이 서버에서 받아 올 때마다 채운다.
967
+ *
968
+ * 자격 파일 옆에 둔다. 자격이 없는 기계는 심박도 안 뛰므로 수명이 같다.
969
+ */
970
+ const INDEX_FILE = path.join(path.dirname(CRED_FILE), 'sessions.json');
971
+
972
+ function indexRead() {
973
+ try { const j = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8')); return j && typeof j === 'object' ? j : {}; }
974
+ catch { return {}; }
975
+ }
976
+ function indexNote(uuid, entry) {
977
+ if (!uuid || !entry?.tenant_id) return;
978
+ try {
979
+ const idx = indexRead();
980
+ idx[uuid] = { ...(idx[uuid] || {}), ...entry, seen: new Date().toISOString() };
981
+ fs.mkdirSync(path.dirname(INDEX_FILE), { recursive: true, mode: 0o700 });
982
+ const fd = fs.openSync(INDEX_FILE, 'w', 0o600);
983
+ try { fs.writeFileSync(fd, JSON.stringify(idx, null, 2) + '\n'); } finally { fs.closeSync(fd); }
984
+ } catch { /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
985
+ }
986
+
987
+ /**
988
+ * 사본의 **머리** — 마지막으로 남은 발화의 id·시각, 그리고 줄 수.
989
+ *
990
+ * ⚠ **꼬리만 읽는다.** 실측 트랜스크립트가 6.9MB 라(2026-08-20, 이 기계) 20초마다 전체를
991
+ * json 으로 펴면 그 자체가 부하다. 마지막 발화는 파일 끝에 있으므로 뒤 256KB 면 족하고,
992
+ * 줄 수는 **늘어난 부분만** 세어 더한다. 파일이 줄었으면(다른 대화로 갈렸다) 다시 센다.
993
+ *
994
+ * PHP 쪽 `agent_session_head()` 와 **같은 값**을 내야 한다 — user·assistant 만, id 앞 16hex.
995
+ */
996
+ const headCache = new Map(); // file → { size, mtimeMs, head }
997
+ function transcriptHead(file) {
998
+ let st;
999
+ try { st = fs.statSync(file); } catch { return null; }
1000
+ const was = headCache.get(file);
1001
+ if (was && was.size === st.size && was.mtimeMs === st.mtimeMs) return was.head;
1002
+
1003
+ const fd = fs.openSync(file, 'r');
1004
+ try {
1005
+ const TAIL = 256 * 1024;
1006
+ const from = Math.max(0, st.size - TAIL);
1007
+ const tail = Buffer.alloc(st.size - from);
1008
+ fs.readSync(fd, tail, 0, tail.length, from);
1009
+ const text = tail.toString('utf8');
1010
+ // 앞이 잘린 줄은 버린다 — 반쪽 JSON 은 파싱이 안 되지만, 버리는 것을 **의도로** 적어 둔다.
1011
+ const lines = text.split('\n');
1012
+ if (from > 0) lines.shift();
1013
+
1014
+ let head = { id: '', ms: 0 };
1015
+ for (let i = lines.length - 1; i >= 0; i--) {
1016
+ const s = lines[i].trim();
1017
+ if (!s) continue;
1018
+ let r; try { r = JSON.parse(s); } catch { continue; }
1019
+ if (r?.type !== 'user' && r?.type !== 'assistant') continue;
1020
+ const hex = String(r.uuid || '').replace(/-/g, '').toLowerCase();
1021
+ if (!/^[0-9a-f]{32}$/.test(hex)) continue;
1022
+ head = { id: hex.slice(0, 16), ms: Date.parse(r.timestamp || '') || 0 };
1023
+ break;
1024
+ }
1025
+
1026
+ // 줄 수 — 늘어난 만큼만 센다.
1027
+ let lineCount;
1028
+ if (was && st.size >= was.size) {
1029
+ const grow = Buffer.alloc(st.size - was.size);
1030
+ if (grow.length) fs.readSync(fd, grow, 0, grow.length, was.size);
1031
+ lineCount = was.head.lines + countNl(grow);
1032
+ } else {
1033
+ const all = Buffer.alloc(st.size);
1034
+ fs.readSync(fd, all, 0, all.length, 0);
1035
+ lineCount = countNl(all);
1036
+ }
1037
+ head.lines = lineCount;
1038
+ headCache.set(file, { size: st.size, mtimeMs: st.mtimeMs, head });
1039
+ return head;
1040
+ } catch { return null; } finally { fs.closeSync(fd); }
1041
+ }
1042
+ function countNl(buf) {
1043
+ let n = 0;
1044
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) n++;
1045
+ return n;
1046
+ }
1047
+
1048
+ /* 트랜스크립트 자리를 기억한다.
1049
+ *
1050
+ * `findTranscript` 는 지금 폴더에 없으면 **`~/.claude/projects` 전부**를 훑는다(이 기계 43개).
1051
+ * 심박은 20초마다 도는데, 색인에 있지만 이 기계엔 없는 세션 하나가 매번 그 훑기를 시킨다.
1052
+ * 자리는 잘 안 바뀌므로 찾은 것도 못 찾은 것도 기억하되, 못 찾은 것은 5분 뒤 다시 본다
1053
+ * (다른 창이 방금 받아 놓았을 수 있다). */
1054
+ const pathCache = new Map(); // uuid → { file: string|null, at: number }
1055
+ const PATH_MISS_TTL = 5 * 60_000;
1056
+ function transcriptOf(uuid) {
1057
+ const c = pathCache.get(uuid);
1058
+ if (c && (c.file ? fs.existsSync(c.file) : Date.now() - c.at < PATH_MISS_TTL)) return c.file;
1059
+ const f = findTranscript(uuid);
1060
+ pathCache.set(uuid, { file: f, at: Date.now() });
1061
+ return f;
1062
+ }
1063
+
1064
+ /** 지금 심박을 보낼 세션들. 이 창의 세션 + 최근에 움직인 사본. */
1065
+ function beatTargets() {
1066
+ const idx = indexRead();
1067
+ const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
1068
+ const now = Date.now();
1069
+ const out = [];
1070
+ for (const [uuid, e] of Object.entries(idx)) {
1071
+ if (!e?.tenant_id) continue;
1072
+ const f = transcriptOf(uuid);
1073
+ if (!f) continue; // 이 기계에 사본이 없으면 「쥐고 있다」가 아니다
1074
+ let mtime = 0;
1075
+ try { mtime = fs.statSync(f).mtimeMs; } catch { continue; }
1076
+ const isMine = uuid === mine;
1077
+ if (!isMine && now - mtime > BEAT_RECENT_MS) continue;
1078
+ const head = transcriptHead(f);
1079
+ if (!head) continue;
1080
+ out.push({
1081
+ uuid, device: deviceId(), head_id: head.id, head_ms: head.ms,
1082
+ lines: head.lines, tenant: e.tenant_id, flags: 0,
1083
+ // 방금 움직였으면 「말하는 중」이다 — 화면의 맥박이 여기서 뛴다.
1084
+ state: now - mtime < BEAT_EVERY_MS ? BEAT_STATE.WORKING : BEAT_STATE.OPEN,
1085
+ _tenant_name: e.tenant,
1086
+ });
1087
+ if (out.length >= BEAT.MAX) break;
1088
+ }
1089
+ return out;
1090
+ }
1091
+
1092
+ /* 이미 말한 고장은 다시 안 말한다 — 20초마다 같은 줄을 뱉으면 그건 신호가 아니라 소음이다. */
1093
+ const beatSaid = new Set();
1094
+
1095
+ /**
1096
+ * 심박 한 번.
1097
+ *
1098
+ * ── 실패를 두 갈래로 나눈다 ─────────────────────────────────────────
1099
+ *
1100
+ * 처음에는 **전부 조용히** 삼켰다(`catch { return null }`). 심박이 안 뛰는 것이 발행을 막을
1101
+ * 이유는 아니니까. 그런데 그 침묵이 실제 고장을 통째로 가렸다 — 기기 이름이 한글인 기계는
1102
+ * HTTP 머리에 그 값을 넣는 순간 `fetch` 가 **TypeError 를 던지는데**, 그게 이 catch 로 들어가
1103
+ * **심박이 영영 안 뛰는데 아무도 모르는** 상태가 됐다(2026-08-20 e2e 에서 잡았다).
1104
+ * 인코딩은 고쳤지만, **고친 것은 그 한 건이고 침묵은 그대로였다.** 다음 번 다른 고장도
1105
+ * 똑같이 안 보인다 — 그래서 여기를 고친다.
1106
+ *
1107
+ * · **망 사정** — 끊김·시간초과·5xx. 조용하다. 이건 고장이 아니라 날씨다.
1108
+ * · **그 밖** — TypeError·프레임 조립 실패 같은 것. **우리 잘못**이므로 한 번 말한다.
1109
+ *
1110
+ * 말하는 자리는 stderr 다(stdout 은 프로토콜 전용 — 한 글자만 써도 클라이언트가 깨진다).
1111
+ * 같은 고장은 한 번만, 그리고 **다시 붙으면 붙었다고도 말한다** — 「아직도 안 되나」를
1112
+ * 사람이 짐작하지 않게.
1113
+ */
1114
+ async function beatOnce(recs, { closing = false } = {}) {
1115
+ const cred = credential();
1116
+ /*
1117
+ * ★ **고객사 키로는 심박을 안 보낸다 — 문은 여기 하나다.**
1118
+ *
1119
+ * 심박은 「어느 기계가 어느 세션을 열고 있나」를 말하는 것이고, 세션은 내부 대화라 서버가
1120
+ * 사람 토큰만 받는다(api.php 의 「세션은 사람 토큰으로만 다룹니다」 → 403). 자격 종류를 안 보고
1121
+ * 두드리면 키를 쓰는 사람이 **정상인데 고장처럼 보이는 경고**를 계속 본다.
1122
+ *
1123
+ * ⚠ 처음에는 `startBeat()` 에만 이 판정을 뒀다. 그러면 **타이머는 안 돌지만 종료 인사
1124
+ * (`stopBeat` → closing)는 그대로 나간다** — 창을 닫을 때마다 403 이 한 번씩. 실측으로
1125
+ * 잡았다(가짜 서버로 요청을 세었다: 새판·키 = 도는 중 0건 **닫힐 때 1건**).
1126
+ * 그래서 판정을 **모든 심박이 지나는 이 자리**로 내렸다. 문이 둘이면 하나는 잠긴다.
1127
+ */
1128
+ if (!cred || cred.kind !== 'user' || !BASE || !hostAllowed(BASE) || !recs.length) return null;
1129
+ const body = beatEncode(closing ? recs.map((r) => ({ ...r, state: BEAT_STATE.CLOSED })) : recs);
1130
+ const ac = new AbortController();
1131
+ const t = setTimeout(() => ac.abort(), 5000);
1132
+ try {
1133
+ const res = await fetch(`${BASE}/api/sessions/beat`, {
1134
+ method: 'POST',
1135
+ headers: {
1136
+ Authorization: `Bearer ${cred.token}`,
1137
+ 'Content-Type': 'application/x-lim-beat',
1138
+ /*
1139
+ * 사람이 읽을 이름은 **머리에 한 번**만. 프레임에는 지문만 흐른다.
1140
+ *
1141
+ * ⚠ **퍼센트 인코딩해서 보낸다.** HTTP 머리값은 ByteString 이라 0xFF 를 넘는 글자를
1142
+ * 넣으면 `fetch` 가 **던진다** — 그리고 이 함수는 조용히 실패하도록 짜여 있어서,
1143
+ * 기계 이름이 한글인 사람은 **심박이 영영 안 뛰는데 아무 말도 안 나온다.**
1144
+ * (2026-08-20 e2e 에서 잡았다: 이름이 `e2e@맥북` 이면 index 4 에서 터진다.)
1145
+ * 서버가 `rawurldecode` 로 되돌린다.
1146
+ */
1147
+ 'X-Lim-Device': encodeURIComponent(deviceLabel()),
1148
+ },
1149
+ body,
1150
+ signal: ac.signal,
1151
+ });
1152
+ if (!res.ok) {
1153
+ // 4xx 는 **우리가 잘못 보낸 것**이다(자격·프레임 모양). 5xx 와 달리 기다린다고 안 낫는다.
1154
+ if (res.status >= 400 && res.status < 500) beatSay(`beat-http-${res.status}`,
1155
+ `심박을 서버가 거절했다(HTTP ${res.status}). 자격이나 프레임을 확인할 것.`);
1156
+ return null;
1157
+ }
1158
+ const reply = beatReplyDecode(Buffer.from(await res.arrayBuffer()));
1159
+ if (reply === null) {
1160
+ beatSay('beat-reply', '심박 응답을 못 읽었다 — 서버와 프레임 판이 어긋났을 수 있다.');
1161
+ return null;
1162
+ }
1163
+ beatWell();
1164
+ return reply;
1165
+ } catch (e) {
1166
+ /*
1167
+ * 망 사정은 조용히 넘긴다. 그 밖은 **우리 잘못**이라 한 번 말한다 —
1168
+ * 이름이 한글이라 머리에 못 싣는 것 같은 고장이 여기로 들어온다.
1169
+ */
1170
+ const weather = e?.name === 'AbortError'
1171
+ || /fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|network/i.test(e?.message || '');
1172
+ if (!weather) beatSay(`beat-${e?.name || 'err'}`, `심박을 보내지 못했다 — ${e?.message}`);
1173
+ return null;
1174
+ } finally { clearTimeout(t); }
1175
+ }
1176
+
1177
+ /** 같은 고장은 한 번만. stdout 은 프로토콜 전용이라 stderr 로만 쓴다. */
1178
+ function beatSay(key, msg) {
1179
+ if (beatSaid.has(key)) return;
1180
+ beatSaid.add(key);
1181
+ try { process.stderr.write(`genetics: ${msg}\n`); } catch { /* 그것마저 막히면 넘어간다 */ }
1182
+ }
1183
+
1184
+ /** 다시 붙었으면 붙었다고 말한다 — 안 말하면 사람이 「아직도 안 되나」를 짐작해야 한다. */
1185
+ function beatWell() {
1186
+ if (!beatSaid.size) return;
1187
+ beatSaid.clear();
1188
+ try { process.stderr.write('genetics: 심박이 다시 붙었다.\n'); } catch { /* 넘어간다 */ }
1189
+ }
1190
+
1191
+ /* 자동 올리기 — **서버 사본이 낡았을 때만.** 사용자 결정(2026-08-20)이라 기본이 켜짐이고,
1192
+ * 끄는 길을 함께 둔다(`GENETICS_NO_AUTO_PUSH=1`). 이 값은 「내부 대화가 서버에 쌓인다」를
1193
+ * 뜻하므로, 끄는 길이 없으면 결정이 아니라 함정이 된다. */
1194
+ const AUTO_PUSH = !cfg.GENETICS_NO_AUTO_PUSH;
1195
+ const autoPushAt = new Map();
1196
+
1197
+ async function autoPushIfAhead(reply, recs) {
1198
+ if (!AUTO_PUSH || !reply) return;
1199
+ const now = Date.now();
1200
+ for (const v of reply) {
1201
+ if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) continue;
1202
+ const rec = recs.find((r) => r.uuid === v.uuid);
1203
+ if (!rec?._tenant_name) continue;
1204
+ if (now - (autoPushAt.get(v.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
1205
+ autoPushAt.set(v.uuid, now);
1206
+ try {
1207
+ const { r } = await pushSession(rec._tenant_name, v.uuid, '');
1208
+ process.stderr.write(`genetics: ${v.uuid.slice(0, 8)} 를 자동으로 올렸다 — ${r.lines}줄(서버가 낡아 있었다).\n`);
1209
+ } catch (e) {
1210
+ // 실패는 알리되 멈추지 않는다. 다음 심박이 다시 시도한다.
1211
+ process.stderr.write(`genetics: 자동 올리기 실패(${v.uuid.slice(0, 8)}) — ${e.message}\n`);
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ let beatTimer = null;
1217
+ let beatLastSent = 0;
1218
+ let beatLastSig = '';
1219
+ let beatRunning = false;
1220
+
1221
+ async function beatTick() {
1222
+ /*
1223
+ * **겹쳐 돌지 않는다.** 자동 올리기가 붙어 있어서 한 번의 tick 이 몇 초씩 걸릴 수 있는데
1224
+ * (8MB 검열 + 업로드), 그동안 20초 타이머가 또 들어오면 **같은 세션을 두 번 올린다.**
1225
+ * 시각 기반 제한(AUTO_PUSH_MIN_MS)만으로는 이미 시작한 것을 못 막는다.
1226
+ */
1227
+ if (beatRunning) return;
1228
+ beatRunning = true;
1229
+ try {
1230
+ const recs = beatTargets();
1231
+ if (!recs.length) return;
1232
+ // **안 바뀌었으면 안 보낸다.** 이 설계가 실제로 버는 것은 바이트가 아니라 이것이다.
1233
+ const sig = recs.map((r) => `${r.uuid}:${r.head_id}:${r.lines}:${r.state}`).join('|');
1234
+ if (sig === beatLastSig && Date.now() - beatLastSent < BEAT_IDLE_MS) return;
1235
+ beatLastSig = sig;
1236
+ beatLastSent = Date.now();
1237
+ const reply = await beatOnce(recs);
1238
+ await autoPushIfAhead(reply, recs);
1239
+ } finally {
1240
+ beatRunning = false;
1241
+ }
1242
+ }
1243
+
1244
+ function startBeat() {
1245
+ if (cfg.GENETICS_NO_BEAT) return; // 심박을 원치 않는 사람의 탈출구
1246
+ const cred = credential();
1247
+ if (!BASE || !cred) return; // 자격이 없으면 **꺼진 것**이지 고장이 아니다
1248
+ // 타이머를 아예 안 거는 것은 **아낌**이다. 진짜 관문은 beatOnce 안에 있다(종료 인사도 지난다).
1249
+ if (cred.kind !== 'user') return;
1250
+ beatTick().catch(() => {});
1251
+ beatTimer = setInterval(() => { beatTick().catch(() => {}); }, BEAT_EVERY_MS);
1252
+ // **프로세스를 붙잡지 않는다.** 이 타이머 때문에 서버가 안 끝나면 창을 닫아도 노드가 남는다.
1253
+ beatTimer.unref?.();
1254
+ }
1255
+
1256
+ /** 창이 닫힌다 — 마지막으로 「닫힘」을 알린다. 못 알려도 90초 뒤 서버가 스스로 지운다. */
1257
+ async function stopBeat() {
1258
+ if (beatTimer) { clearInterval(beatTimer); beatTimer = null; }
1259
+ try { await beatOnce(beatTargets(), { closing: true }); } catch { /* 최선만 다한다 */ }
1260
+ }
1261
+
1262
+ const TOOLS = [
1263
+ {
1264
+ name: 'login',
1265
+ description:
1266
+ 'SSO 로 로그인해 **이 기계에 내 자격을 심는다.** 웹에서 API 키를 발급받지 않았어도 이것만으로 쓸 수 있다. '
1267
+ + '브라우저로 주소를 열고 코드를 넣어 승인하면 끝이고, 그 뒤로는 다시 할 필요가 없다(90일). '
1268
+ + '**사람이 브라우저에서 승인해야 하므로 이 도구는 두 번 부른다** — 처음에는 코드를 받고, '
1269
+ + '사용자가 승인했다고 말하면 `finish: true` 로 한 번 더 불러 자격을 받는다.',
1270
+ inputSchema: {
1271
+ type: 'object',
1272
+ properties: {
1273
+ finish: { type: 'boolean', description: '승인을 마쳤으면 true. 처음 부를 때는 넣지 않는다' },
1274
+ device_code: { type: 'string', description: 'finish 일 때, 처음 받은 device_code' },
1275
+ },
1276
+ },
1277
+ },
1278
+ {
1279
+ name: 'whoami',
1280
+ description: '지금 무엇으로 붙어 있는지 — 고객사 키인지 사람 로그인인지, 사람이면 누구인지.',
1281
+ inputSchema: { type: 'object', properties: {} },
1282
+ },
1283
+ {
1284
+ name: 'logout',
1285
+ description: '이 기계에 심긴 로그인 자격을 지운다. 서버 쪽 토큰은 관리 화면에서 폐기한다.',
1286
+ inputSchema: { type: 'object', properties: {} },
1287
+ },
1288
+ {
1289
+ name: 'list_reports',
1290
+ description:
1291
+ '이 키가 담당하는 고객사의 문서 목록. 각 줄의 대괄호가 **문서 상태**이고, ' +
1292
+ '무엇으로 만들어졌는지·API 로 읽고 쓸 수 있는지를 서버가 판정해 준다. ' +
1293
+ '편집기가 정본인 문서는 API 로 못 고친다 — 사람이 편집 화면에서 고쳐야 한다. ' +
1294
+ '응답 끝의 다음 page 안내를 따라가면 100건 뒤의 문서도 볼 수 있다.',
1295
+ inputSchema: {
1296
+ type: 'object',
1297
+ properties: {
1298
+ page: {
1299
+ type: 'integer', minimum: 1, maximum: 1000,
1300
+ description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
1301
+ },
1302
+ limit: {
1303
+ type: 'integer', minimum: 1, maximum: 100,
1304
+ description: '한 번에 받을 문서 수(최대 100, 기본 100).',
1305
+ },
1306
+ },
1307
+ },
1308
+ },
1309
+ {
1310
+ name: 'get_report',
1311
+ description:
1312
+ '발행에 쓰인 데이터를 그대로 받아온다. 이걸 고쳐 update_report 로 되돌려 보내면 수정 발행이 된다 — ' +
1313
+ '**단 편집기가 정본인 문서는 읽을 수는 있어도 되돌려 보낼 수 없다.** ' +
1314
+ '목록의 can_write 가 그 답이고, 거절될 때는 응답에 편집 화면 주소가 실려 온다.',
1315
+ inputSchema: {
1316
+ type: 'object',
1317
+ properties: {
1318
+ id: { type: 'integer', description: '보고서 id' },
1319
+ outline: {
1320
+ type: 'boolean',
1321
+ description: '참이면 데이터 대신 **렌더 결과의 구조**를 준다 — 블록 차례·챕터 좌우'
1322
+ + '(data-side)·빈 블록. 낸 것이 제대로 섰는지 확인할 때 쓴다(발행물 자체는 1MB 대라 주지 않는다).',
1323
+ },
1324
+ },
1325
+ required: ['id'],
1326
+ },
1327
+ },
1328
+ {
1329
+ name: 'publish_report',
1330
+ description:
1331
+ '새 보고서를 만든다. 고객사는 키가 정하므로 지정하지 않는다. ' +
1332
+ 'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
1333
+ '**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
1334
+ 'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
1335
+ '쓰기 전에 결과만 보고 싶으면 dry_run: true — 렌더까지 돌려 경고를 주고 아무것도 저장하지 않는다.',
1336
+ inputSchema: {
1337
+ type: 'object',
1338
+ properties: {
1339
+ tenant: { type: 'string', description: '발행 대상 고객사 이름. 키가 가리키는 고객사와 다르면 거부된다(오발행 방지).' },
1340
+ slug: { type: 'string', description: '파일 이름이 될 식별자. 영문 소문자·숫자·하이픈 (예: fixes-20260812)' },
1341
+ published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
1342
+ publish: {
1343
+ type: 'boolean',
1344
+ description: '고객에게 바로 열지 여부. **생략하면 false(초안)** — 확인 전에 고객이 먼저 보는 일을 막는다.',
1345
+ },
1346
+ dry_run: {
1347
+ type: 'boolean',
1348
+ description: '참이면 아무것도 저장하지 않고 렌더 결과와 경고만 돌려준다.',
1349
+ },
1350
+ data: REPORT_DATA,
1351
+ },
1352
+ required: ['tenant', 'slug', 'data'],
1353
+ },
1354
+ },
1355
+ {
1356
+ name: 'release_report',
1357
+ description:
1358
+ '초안을 고객에게 연다. 이 순간부터 고객 목록과 열람에 뜬다. ' +
1359
+ '이미 열려 있으면 아무 일도 하지 않고 그렇다고 답한다(다시 불러도 안전하다).',
1360
+ inputSchema: {
1361
+ type: 'object',
1362
+ properties: {
1363
+ tenant: { type: 'string', description: '대상 고객사 이름. 키와 다르면 거부된다.' },
1364
+ id: { type: 'integer' },
1365
+ published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
1366
+ },
1367
+ required: ['tenant', 'id'],
1368
+ },
1369
+ },
1370
+ {
1371
+ name: 'unpublish_report',
1372
+ description:
1373
+ '고객에게서 내린다 — **지우지 않는다.** 문서와 열람 이력은 그대로 남고 목록·열람에서만 빠진다. ' +
1374
+ '잘못 낸 문서를 물릴 때 쓴다. 이미 내려가 있으면 아무 일도 하지 않는다(다시 불러도 안전하다).',
1375
+ inputSchema: {
1376
+ type: 'object',
1377
+ properties: {
1378
+ tenant: { type: 'string', description: '대상 고객사 이름. 키와 다르면 거부된다.' },
1379
+ id: { type: 'integer' },
1380
+ reason: { type: 'string', description: '왜 내리는지. 서버 로그에 남아 다음 사람이 이유를 안다.' },
1381
+ },
1382
+ required: ['tenant', 'id'],
1383
+ },
1384
+ },
1385
+ {
1386
+ name: 'report_assets',
1387
+ description:
1388
+ '그림이 어디까지 왔는지 본다. `brief` 로 발행하면 문서는 즉시 나가고 서버가 그림을 그리는데, '
1389
+ + '**이 도구가 그 진행을 묻는 유일한 자리**다(발행물을 통째로 다시 받지 않는다).\n'
1390
+ + '한 장에 60~120초쯤 걸리고 순서가 있다 — hero 가 먼저 나와 관문을 통과해야 나머지가 시작된다. '
1391
+ + '되풀이해서 두드리지 말 것: 7장짜리면 처음 물어보기까지 5분쯤 두는 편이 낫다.',
1392
+ inputSchema: {
1393
+ type: 'object',
1394
+ properties: { id: { type: 'integer', description: '보고서 id' } },
1395
+ required: ['id'],
1396
+ },
1397
+ },
1398
+ {
1399
+ name: 'update_report',
1400
+ description:
1401
+ '기존 보고서의 내용을 교체한다. 보고서 id·주소가 그대로라 **이미 고객에게 나간 열람 링크가 새 내용을 가리킨다**. ' +
1402
+ '사람이 편집기에서 만진 문서는 서버가 거절한다 — 여기서 덮으면 다음 편집기 발행 때 그 변경이 조용히 사라지기 때문이다. ' +
1403
+ '그때는 응답에 편집 주소가 실려 오니 사람에게 그 주소를 알려 주면 된다.',
1404
+ inputSchema: {
1405
+ type: 'object',
1406
+ properties: {
1407
+ tenant: { type: 'string', description: '대상 고객사 이름. 키와 다르면 거부된다(오발행 방지).' },
1408
+ id: { type: 'integer' },
1409
+ dry_run: {
1410
+ type: 'boolean',
1411
+ description: '참이면 아무것도 바꾸지 않고 렌더 결과와 경고만 돌려준다. '
1412
+ + '이 문서는 이미 고객에게 나간 링크를 갖고 있으니 덮기 전에 한 번 볼 수 있다.',
1413
+ },
1414
+ data: REPORT_DATA,
1415
+ },
1416
+ required: ['tenant', 'id', 'data'],
1417
+ },
1418
+ },
1419
+ {
1420
+ name: 'remember_case',
1421
+ description:
1422
+ '**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그 고객사 지식의 `cases/` 에 적히고 '
1423
+ + '다음에 누가(어느 기계에서든) `search_cases` 로 찾아 쓴다. 같은 제목이면 **덮어쓴다** — '
1424
+ + '그래서 「갱신」·「업데이트」도 이 도구다.\n'
1425
+ + '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
1426
+ + ' "기억해줘" · "메모해둬" · "남겨줘" · "정리해둬" · "업데이트해줘" · "갱신해줘" · '
1427
+ + '"동기화해줘" · "올려둬" · "다음에 또 겪을 테니 적어두자" · "이거 우리 지식에 있어야 해"\n'
1428
+ + '그리고 사람이 안 시켜도 부를 때: 버그·장애·설정 함정을 **고친 직후**, 그 원인이 재발할 종류이면. '
1429
+ + '로컬 메모에만 적으면 그 기계에서만 남는다 — 팀이 쓸 것이면 여기로.\n'
1430
+ + '헷갈리는 자리: **대화 전체**를 넘기는 것이면 push_session, **고객에게 낼 문서**면 publish_report, '
1431
+ + '**알아낸 것 하나**면 이 도구다.\n'
1432
+ + '무엇을 적는가: 증상은 **겪은 현상 그대로**(다음 사람은 원인을 모르는 채 이 낱말로 찾는다), '
1433
+ + '처방은 다시 하면 되는 동작, 근거는 실측치·커밋·로그. 원인과 근거는 비워도 되지만 '
1434
+ + '비면 그렇게 적힌다 — 없는 확신을 지어내지 말 것.',
1435
+ inputSchema: {
1436
+ type: 'object',
1437
+ properties: {
1438
+ tenant: { type: 'string', description: '대상 고객사 이름.' },
1439
+ title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
1440
+ symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
1441
+ cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
1442
+ fix: { type: 'string', description: '처방. 다시 만나면 그대로 하면 되는 동작.' },
1443
+ evidence: { type: 'string', description: '근거 — 실측치·커밋 해시·로그 한 줄. 비면 "안 적혔다"로 남는다.' },
1444
+ tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8).' },
1445
+ },
1446
+ required: ['tenant', 'title', 'symptom', 'fix'],
1447
+ },
1448
+ },
1449
+ {
1450
+ name: 'search_cases',
1451
+ description:
1452
+ '**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그 고객사 지식(사례·개념·대상 전부)를 '
1453
+ + '증상 문장으로 훑어 점수순으로 돌려준다.\n'
1454
+ + '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
1455
+ + ' "전에도 이런 적 있었나" · "우리 이거 겪지 않았나" · "지식에 있나 찾아봐" · '
1456
+ + '"기록 확인해줘" · "예전에 어떻게 했더라"\n'
1457
+ + '그리고 사람이 안 시켜도 부를 때: 버그·장애를 **고치기 전에 먼저**, 원인을 모를 때, '
1458
+ + '설정이 이상할 때, 그리고 remember_case 로 적기 전에(같은 사례가 있으면 새로 만들지 말고 그것을 갱신한다). '
1459
+ + '팀이 이미 값을 치른 답이 있으면 다시 치르지 않는다.\n'
1460
+ + '어떻게 묻는가: 원인 추정이 아니라 **겪은 현상**으로. 예: "담당에서 뺐는데 clone 이 계속 된다".',
1461
+ inputSchema: {
1462
+ type: 'object',
1463
+ properties: {
1464
+ tenant: { type: 'string', description: '대상 고객사 이름.' },
1465
+ q: { type: 'string', description: '증상 문장. 원인이 아니라 현상으로.' },
1466
+ limit: { type: 'integer', minimum: 1, maximum: 20, description: '몇 건까지(기본 5).' },
1467
+ fast: { type: 'boolean', description: '의미 확장을 건너뛴다(빠르지만 덜 찾는다). 기본은 확장 켬 — '
1468
+ + '어휘가 안 겹치는 질문을 두뇌가 넓혀 다시 찾는다(처음 몇 초, 같은 질문은 캐시).' },
1469
+ },
1470
+ required: ['tenant', 'q'],
1471
+ },
1472
+ },
1473
+ {
1474
+ name: 'list_sessions',
1475
+ description:
1476
+ '이 고객사에 올라와 있는 **작업 세션 대화** 목록. 담당자가 부재일 때 무엇을 이어받을 수 있는지 본다. '
1477
+ + '응답 끝의 다음 page 안내를 따라가면 100건 뒤의 세션도 볼 수 있다.',
1478
+ inputSchema: {
1479
+ type: 'object',
1480
+ properties: {
1481
+ tenant: { type: 'string', description: '대상 고객사 이름.' },
1482
+ page: {
1483
+ type: 'integer', minimum: 1, maximum: 1000,
1484
+ description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
1485
+ },
1486
+ limit: {
1487
+ type: 'integer', minimum: 1, maximum: 100,
1488
+ description: '한 번에 받을 세션 수(최대 100, 기본 100).',
1489
+ },
1490
+ },
1491
+ required: ['tenant'],
1492
+ },
1493
+ },
1494
+ {
1495
+ name: 'push_session',
1496
+ description:
1497
+ '이 기계의 대화 하나를 **검열해서** 서버에 올린다. 다른 기계가 pull_session 으로 받아 ' +
1498
+ '`claude --resume <uuid>` 로 이어 갈 수 있다.\n' +
1499
+ '올라가지 않는 것: 도구 실행 결과·첨부·파일 이력·집 경로·비밀 모양(토큰·비밀번호·개인키). ' +
1500
+ '**도구 호출의 짝과 인자는 남는다** — 그게 없으면 대화가 안 열린다.\n' +
1501
+ '쓰기 전에 무엇이 빠지는지만 보고 싶으면 dry_run: true.',
1502
+ inputSchema: {
1503
+ type: 'object',
1504
+ properties: {
1505
+ tenant: { type: 'string', description: '대상 고객사 이름.' },
1506
+ uuid: { type: 'string', description: '세션 id. `--resume` 에 쓰는 그 값.' },
1507
+ title: { type: 'string', description: '목록에서 알아볼 한 줄. 생략하면 빈 값.' },
1508
+ dry_run: { type: 'boolean', description: '참이면 아무것도 저장하지 않고 검열 결과만 돌려준다.' },
1509
+ },
1510
+ required: ['tenant', 'uuid'],
1511
+ },
1512
+ },
1513
+ {
1514
+ /*
1515
+ * **목록에서 pull_session 보다 앞에 선다.** 이어받기의 기본 경로가 이쪽이고, 뒤에 두면
1516
+ * 에이전트가 먼저 눈에 띄는 것을 집는다 — 그러면 파일만 놓고 「창을 새로 여세요」로 끝난다.
1517
+ */
1518
+ name: 'resume_session',
1519
+ description:
1520
+ '서버에 있는 대화를 **지금 이 창이 이어받는다.** 포털의 「이어받기」 버튼이 복사해 주는 문구가 이 도구를 부른다.\n' +
1521
+ '한 번에 세 가지를 한다 — ① 그 대화의 요지를 받아 **여기서 바로 읽는다**(창을 새로 열 필요가 없다), ' +
1522
+ '② 원본 jsonl 을 이 기계의 제자리에 놓는다(나중에 `claude --resume <uuid>` 로 원본을 열 수 있다), ' +
1523
+ '③ 이 기계가 그 대화를 쥐었다고 서버에 알린다(다른 기계가 「누가 이어가는지」를 본다).\n' +
1524
+ '**받기 전에 누가 앞서는지 말해 준다** — 다른 기계가 서버 사본보다 앞서 있으면 그 사실을 먼저 알린다.\n' +
1525
+ '받은 요지를 읽었으면 사용자에게 「어디까지 되어 있고 다음이 무엇인지」를 짧게 정리해 주고 이어서 일한다.',
1526
+ inputSchema: {
1527
+ type: 'object',
1528
+ properties: {
1529
+ tenant: { type: 'string', description: '대상 고객사 이름.' },
1530
+ uuid: { type: 'string', description: '이어받을 세션 id.' },
1531
+ into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
1532
+ + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
1533
+ chars: { type: 'integer', description: '요지의 글자 상한(생략하면 40,000자). 대화가 길면 '
1534
+ + '앞 30%·뒤 70% 를 싣고 가운데를 접는다 — 접었다는 사실은 글 안에 적힌다.' },
1535
+ },
1536
+ required: ['tenant', 'uuid'],
1537
+ },
1538
+ },
1539
+ {
1540
+ name: 'pull_session',
1541
+ description:
1542
+ '서버에 있는 대화를 **파일로만** 이 기계에 받는다. 이 창은 그 내용을 읽지 않는다 — ' +
1543
+ '읽으면서 이어받으려면 `resume_session` 을 쓴다.\n' +
1544
+ '받은 뒤 `claude --resume <uuid>` 로 원본 그대로 이어서 말할 수 있다.\n' +
1545
+ '**이미 그 파일이 있으면 덮지 않는다** — 덮으면 이 기계에서 하던 대화가 사라진다.',
1546
+ inputSchema: {
1547
+ type: 'object',
1548
+ properties: {
1549
+ tenant: { type: 'string', description: '대상 고객사 이름.' },
1550
+ uuid: { type: 'string', description: '받을 세션 id.' },
1551
+ into: { type: 'string', description: '어느 디렉터리에서 이어 갈 것인가(생략하면 지금 폴더). '
1552
+ + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 이어 갈 자리다.' },
1553
+ },
1554
+ required: ['tenant', 'uuid'],
1555
+ },
1556
+ },
1557
+ ];
1558
+
1559
+ /* 서버가 준 경고를 그대로 싣는다.
1560
+ * 2026-08-18 까지 이 값은 **한 번도 오지 않았다** — 서버가 `error_log` 로만 흘렸고
1561
+ * `api.php` 에 'warnings' 는 0건이었다. 그런데 도구는 이 코드를 갖고 있었으니,
1562
+ * 에이전트는 「경고 없음」으로 읽었고 확인할 방법이 없었다. 전달 경로를 만든 뒤에도
1563
+ * 찍는 자리가 흩어져 있으면 또 한 곳이 빠지므로 여기 하나로 모은다. */
1564
+ function warnText(r) {
1565
+ return r?.warnings?.length
1566
+ ? `\n\n경고 ${r.warnings.length}건:\n· ${r.warnings.join('\n· ')}`
1567
+ : '';
1568
+ }
1569
+
1570
+ /* 그림이 아직 오는 중이라는 사실을 **발행 응답에 붙인다.**
1571
+ * 안 붙이면 에이전트는 그림 없는 문서를 받아 들고 「왜 빠졌지」에서 멈추고,
1572
+ * 대개 그 자리에서 다시 발행한다 — 그러면 방금 시작한 생성이 통째로 버려진다. */
1573
+ function pendingText(r) {
1574
+ const n = Number(r?.assets_pending || 0);
1575
+ return n > 0
1576
+ ? `\n\n그림 ${n}장은 **서버가 그리는 중**입니다. 지금 할 일은 없습니다 — 다 되면 같은 주소의 `
1577
+ + `문서가 스스로 갱신됩니다(한 장에 60~120초). 확인하려면 report_assets(id: ${r.id}).`
1578
+ : '';
1579
+ }
1580
+
1581
+ /** 승인 화면이 「어느 기계인가」를 보여 줄 수 있게. 사람이 알아보는 이름이면 충분하다. */
1582
+ function deviceLabel() {
1583
+ try { return `${os.userInfo().username}@${os.hostname()}`; } catch { return os.hostname(); }
1584
+ }
1585
+
1586
+ async function callTool(name, args = {}) {
1587
+ switch (name) {
1588
+ case 'login': {
1589
+ /*
1590
+ * **한 번에 끝내지 않는다.** 사람이 브라우저에서 승인해야 하므로 여기서 되묻고
1591
+ * 있으면 그동안 대화가 멈춘다. 코드를 주고 물러났다가, 사용자가 승인했다고 하면
1592
+ * 그때 받아 온다 — 기다림을 도구가 아니라 **사람이 쥔다**.
1593
+ */
1594
+ if (!args.finish) {
1595
+ const { data } = await apiOpen('POST', '/api/device/start', { device_label: deviceLabel() });
1596
+ if (!data.ok) throw new Error(data.error || '기기 코드를 받지 못했습니다.');
1597
+ pendingWrite({ device_code: data.device_code, at: Date.now() });
1598
+ /*
1599
+ * **사람이 읽는 글이다.** 그래서 ⑴ 주어가 사람이고 ⑵ 도구를 어떻게 부르는지는
1600
+ * 안 적으며(그건 이 도구의 설명에 있다 — 읽는 쪽이 다르다) ⑶ 기계 코드를 안 싣는다.
1601
+ */
1602
+ return `브라우저에서 승인해 주세요.\n\n`
1603
+ + ` 1. ${data.verification_uri} 를 엽니다\n`
1604
+ + ` 2. 이 코드를 넣습니다: ${data.user_code}\n\n`
1605
+ + `승인 화면에 «${deviceLabel()}» 이 뜹니다. **이 기계가 맞는지 보고 승인하세요.**\n`
1606
+ + `${Math.round(data.expires_in / 60)}분 뒤 만료됩니다. 승인하셨으면 알려 주세요 — 이어서 마무리합니다.`;
1607
+ }
1608
+ // 진행 중인 로그인을 로컬에서 읽는다. 인자로 줘도 받는다(옛 판과 물린다).
1609
+ const dc = String(args.device_code || pendingRead()?.device_code || '');
1610
+ if (!dc) throw new Error('진행 중인 로그인이 없습니다. `login` 을 먼저 부르세요.');
1611
+ const { status, data } = await apiOpen('POST', '/api/device/token', {
1612
+ device_code: dc, device_label: deviceLabel(),
1613
+ });
1614
+ if (status === 428) return '아직 승인 전입니다. 브라우저에서 승인한 뒤 다시 불러 주세요.';
1615
+ if (status === 429) return '너무 자주 물었습니다. 몇 초 뒤에 다시 불러 주세요.';
1616
+ if (!data.ok) throw new Error(data.error || `로그인에 실패했습니다(HTTP ${status}).`);
1617
+ pendingClear();
1618
+ credWrite({
1619
+ token: data.token,
1620
+ user: data.user,
1621
+ expires_at: data.expires_at,
1622
+ device_label: deviceLabel(),
1623
+ base: BASE,
1624
+ });
1625
+ const warn = KEY
1626
+ ? `\n\n⚠ 이 기계에는 고객사 키도 있습니다. **발행은 키로 나갑니다**(좁은 쪽이 기본). `
1627
+ + `로그인 자격으로 쓰려면 ${ENV_FILE} 의 GENETICS_API_KEY 를 지우세요.`
1628
+ : '';
1629
+ return `로그인했습니다 — ${data.user.name}(${data.user.email}) · ${data.user.role}\n`
1630
+ + `자격은 ${CRED_FILE} 에 넣었습니다(본인만 읽기). 만료 ${data.expires_at}.${warn}`;
1631
+ }
1632
+
1633
+ case 'whoami': {
1634
+ const c = credential();
1635
+ if (!c) return `자격이 없습니다. \`login\` 을 부르거나 ${ENV_FILE} 에 키를 넣으세요.`;
1636
+ if (c.kind === 'key') {
1637
+ const other = credRead();
1638
+ const also = other ? `\n(로그인 자격도 있습니다: ${other.user?.email} — 키가 있어 그쪽이 안 쓰입니다.)` : '';
1639
+ return `고객사 키로 붙어 있습니다. 키가 곧 고객사이므로 그 고객사에만 발행됩니다.${also}`;
1640
+ }
1641
+ const me = await api('GET', '/api/me');
1642
+ return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
1643
+ + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}`;
1644
+ }
1645
+
1646
+ case 'logout': {
1647
+ if (!credRead()) return '이 기계에는 심긴 로그인 자격이 없습니다.';
1648
+ fs.rmSync(CRED_FILE, { force: true });
1649
+ return `자격을 지웠습니다(${CRED_FILE}).\n`
1650
+ + '**서버 쪽 토큰은 아직 살아 있습니다** — 기계를 잃어버린 경우라면 관리 화면에서 폐기하세요.';
1651
+ }
1652
+
1653
+ case 'list_reports': {
1654
+ const query = new URLSearchParams();
1655
+ if (args.page !== undefined) query.set('page', String(args.page));
1656
+ if (args.limit !== undefined) query.set('limit', String(args.limit));
1657
+ const encodedQuery = query.toString();
1658
+ const suffix = encodedQuery ? `?${encodedQuery}` : '';
1659
+ const r = await api('GET', `/api/reports${suffix}`);
1660
+ const reports = Array.isArray(r.reports) ? r.reports : [];
1661
+ // **여기서 문장을 지어내지 않는다.** 전에는 `editable=false` 를 전부 "옛 파일" 이라고
1662
+ // 옮겼는데, 그 값이 뜻하는 것은 셋이었다(옛 업로드본·편집기 문서·초안).
1663
+ // 상태를 아는 것은 서버뿐이므로 서버가 준 말을 그대로 싣는다.
1664
+ const lines = reports.map((x) => {
1665
+ // 상태를 모르는 옛 서버와도 물린다 — 그때는 말할 수 있는 것만 말하고,
1666
+ // 말할 것이 없으면 **빈 대괄호를 찍지 않는다**(`[]` 는 아무 뜻도 없다).
1667
+ const bits = [];
1668
+ if (x.state_label) bits.push(x.state_label);
1669
+ else if (x.editable === false) bits.push('데이터 없음');
1670
+ if (x.can_write === false) bits.push('API 로 수정 불가');
1671
+ const tag = bits.length ? ` [${bits.join(' · ')}]` : '';
1672
+ // slug 를 싣는다. 중복 slug 는 409 로 잘 막히지만 **미리 알 수가 없었다** —
1673
+ // 목록에 이름표가 없어 새 이름을 고를 때 참고할 것이 없었다(2026-08-18 검토).
1674
+ const sl = x.slug ? ` · ${x.slug}` : '';
1675
+ /*
1676
+ * **사람 로그인일 때만 줄마다 고객사를 붙인다.** 키는 고객사가 하나라 머리에 한 번
1677
+ * 적으면 되지만, 사람은 담당이 여럿이라 섞여 온다 — 줄에 없으면 목록만 보고 고를 수 없다.
1678
+ * 키일 때도 붙이면 같은 이름이 수백 줄 반복돼 오히려 읽기 어렵다.
1679
+ */
1680
+ const tn = (r.tenant === null && x.tenant_name) ? `${x.tenant_name} · ` : '';
1681
+ return `#${x.id} ${tn}${x.title} (${x.published_at || '미발행'})${sl}${tag}\n ${x.url}`;
1682
+ });
1683
+ // 서버가 `tenant: null` 을 주면 「고객사 하나에 매인 자격이 아니다」는 뜻이다.
1684
+ const head = r.tenant === null ? '담당 고객사 전체' : `고객사: ${r.tenant}`;
1685
+ /*
1686
+ * pagination이 없는 옛 서버와도 물린다. 새 서버면 **전체 건수와 현재 범위**를 말하고,
1687
+ * next를 그대로 다음 도구 호출로 보여 준다 — 첫 100건만 보인 채 끝나는 막다른 길을
1688
+ * 만들지 않는다. hard cap은 next를 지어내지 않고 왜 멈췄는지 밝힌다.
1689
+ */
1690
+ const p = r.pagination && typeof r.pagination === 'object' ? r.pagination : null;
1691
+ let count = `문서 ${reports.length}건`;
1692
+ if (p && Number.isInteger(p.total) && p.range && Number.isInteger(p.range.first)
1693
+ && Number.isInteger(p.range.last)) {
1694
+ count = p.total === 0
1695
+ ? '문서 0건'
1696
+ : `문서 ${p.total}건 중 ${p.range.first}–${p.range.last}`;
1697
+ }
1698
+ const follow = [];
1699
+ if (p && Number.isInteger(p.next)) {
1700
+ follow.push(`다음 목록: list_reports(page: ${p.next}, limit: ${p.limit || 100})`);
1701
+ } else if (p?.truncated && p?.has_more) {
1702
+ follow.push(`⚠ 순차 목록은 ${p.page_max || 1000} page 상한에서 멈췄습니다. `
1703
+ + '더 깊은 문서는 id를 알고 있을 때 get_report(id)로 직접 여세요.');
1704
+ }
1705
+ return `${head} · ${count}\n` + (lines.join('\n') || '(없음)')
1706
+ + (follow.length ? `\n\n${follow.join('\n')}` : '');
1707
+ }
1708
+ case 'get_report': {
1709
+ const q = args.outline ? '?outline=1' : '';
1710
+ const r = await api('GET', `/api/reports/${Number(args.id)}${q}`);
1711
+ if (!args.outline) return JSON.stringify(r.data, null, 2);
1712
+ // 구조만 본다 — 데이터는 이미 다른 호출로 받을 수 있고, 둘을 같이 실으면
1713
+ // 문맥이 두 배가 된다.
1714
+ const o = r.outline || {};
1715
+ const c = o.counts || {};
1716
+ const lines = (o.blocks || []).map((b) =>
1717
+ ` ${b.tag}.${b.class}${b.side ? ` [${b.side}]` : ''}${b.text ? ` — ${b.text}` : ''}`);
1718
+ return `발행물 구조 — 절 ${c.sections ?? '?'} · 건 ${c.cases ?? '?'} · 장 ${c.chapters ?? '?'}`
1719
+ + ` · 그림 ${c.images ?? '?'} · 좌우 ${c.side_l ?? 0}/${c.side_r ?? 0}`
1720
+ + ` · 도해 ${c.logic ?? 0} · 원칙카드 ${c.principles ?? 0}\n`
1721
+ + lines.join('\n')
1722
+ + (o.empty?.length ? `\n\n⚠ 빈 블록 ${o.empty.length}건: ${o.empty.join(', ')}` : '\n\n빈 블록 없음');
1723
+ }
1724
+ case 'publish_report': {
1725
+ const data = await materializeAssets(args.data);
1726
+ // **기본은 초안이다.** 서버 기본값은 참(다른 호출자의 동작을 말없이 바꾸지 않는다)이라
1727
+ // 여기서 뒤집는다 — 확인 전에 고객이 먼저 보는 일을 막는 것은 도구의 몫이다.
1728
+ const publish = args.publish === true;
1729
+ const body = { ...data, tenant: args.tenant, slug: args.slug, publish };
1730
+ if (args.published_at) body.published_at = args.published_at;
1731
+ if (args.dry_run) body.dry_run = true;
1732
+ const r = await api('POST', '/api/reports', body);
1733
+ if (r.dry_run) {
1734
+ return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
1735
+ + `${r.tenant} · ${r.title} (건 ${r.cases}) · 저장하면 ${r.would_publish ? '바로 공개' : '초안'}`
1736
+ + warnText(r);
1737
+ }
1738
+ // **무엇이 일어났는지 말한다.** 「발행 완료」 한 마디로는 고객이 지금 볼 수 있는지
1739
+ // 없는지가 안 보인다 — 그 둘은 다음에 할 일이 정반대다.
1740
+ return (publish
1741
+ ? `공개 발행 — ${r.tenant} · ${r.title} (건 ${r.cases})\n**고객이 지금 볼 수 있습니다.**`
1742
+ : `초안 저장 — ${r.tenant} · ${r.title} (건 ${r.cases})\n`
1743
+ + `**아직 고객에게 보이지 않습니다.** 확인한 뒤 release_report(id: ${r.id}) 로 열어 주세요.`)
1744
+ + `\n열람 주소: ${r.url}` + pendingText(r) + warnText(r);
1745
+ }
1746
+ case 'release_report': {
1747
+ const body = { tenant: args.tenant };
1748
+ if (args.published_at) body.published_at = args.published_at;
1749
+ const r = await api('POST', `/api/reports/${Number(args.id)}/publish`, body);
1750
+ return (r.changed ? '열었습니다 — 고객이 지금부터 볼 수 있습니다.' : '이미 열려 있습니다.')
1751
+ + `\n${r.url}`;
1752
+ }
1753
+ case 'unpublish_report': {
1754
+ const body = { tenant: args.tenant };
1755
+ if (args.reason) body.reason = args.reason;
1756
+ const r = await api('POST', `/api/reports/${Number(args.id)}/unpublish`, body);
1757
+ return (r.changed
1758
+ ? `내렸습니다 — 고객 목록과 열람에서 빠졌습니다(문서와 이력은 그대로).`
1759
+ : '이미 내려가 있습니다.') + `\n다시 열려면 release_report(id: ${Number(args.id)}).`;
1760
+ }
1761
+ case 'report_assets': {
1762
+ const r = await api('GET', `/api/reports/${Number(args.id)}/assets`);
1763
+ if (!r.total) return '이 문서에는 서버가 채울 그림이 없습니다.';
1764
+ const mark = { done: '도착', failed: '실패', queued: '그리는 중', waiting: '차례 기다림' };
1765
+ const lines = (r.slots || []).map((s) =>
1766
+ ` ${(mark[s.state] || s.state).padEnd(6)} ${s.role.padEnd(8)} ${s.id}`
1767
+ + (s.note ? ` — ${s.note}` : ''));
1768
+ return `${r.note}\n${lines.join('\n')}`;
1769
+ }
1770
+ case 'update_report': {
1771
+ const data = await materializeAssets(args.data);
1772
+ const payload = { ...data, tenant: args.tenant };
1773
+ if (args.dry_run) payload.dry_run = true;
1774
+ const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload);
1775
+ if (r.dry_run) {
1776
+ return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
1777
+ }
1778
+ // 상태를 함께 알린다 — 이 문서를 다음에도 API 로 고칠 수 있는지가 여기서 갈린다.
1779
+ const st = r.state ? ` [${r.state}]` : '';
1780
+ return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}`
1781
+ + pendingText(r) + warnText(r);
1782
+ }
1783
+ case 'remember_case': {
1784
+ const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
1785
+ title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
1786
+ evidence: args.evidence || '', tags: args.tags || [],
1787
+ });
1788
+ return `${r.note} — ${r.tenant} · ${r.path}\n`
1789
+ + '다음에 같은 증상을 만나면 search_cases 로 이 장을 먼저 찾는다.';
1790
+ }
1791
+ case 'search_cases': {
1792
+ /*
1793
+ * 에이전트는 기다릴 수 있다 — 그래서 **기본이 의미 확장 켬**이다(expand=1).
1794
+ * 사람 화면은 반대로 기본이 꺼짐이다: 운영 실측으로 어휘 검색은 3ms 인데 두뇌 확장은
1795
+ * 처음 6.6~8.2초(캐시 뒤 0.02초)라, 사람이 보는 검색창에 8초를 기본으로 둘 수 없다.
1796
+ * 골드셋에서 확장은 Recall@5 0.90 → 0.97(약할 때만)이고 소음은 안 늘었다.
1797
+ */
1798
+ const qs = '?tenant=' + encodeURIComponent(args.tenant) + '&q=' + encodeURIComponent(args.q)
1799
+ + (args.limit ? '&limit=' + args.limit : '') + (args.fast ? '' : '&expand=1');
1800
+ const r = await api('GET', '/api/wiki/search' + qs);
1801
+ if (!r.hits.length) {
1802
+ return `«${args.q}» 로 찾은 사례가 없다(${r.scanned}장 훑음). 전에 겪은 적이 없거나 아직 안 남긴 것이다 — `
1803
+ + '이번에 고치면 remember_case 로 남길 것.';
1804
+ }
1805
+ return `«${args.q}» — ${r.hits.length}건(${r.scanned}장 중${r.expanded ? ', 의미 확장으로 다시 찾음' : ''})\n`
1806
+ + r.hits.map((h) => `· ${h.path} [${h.score}점]\n ${h.excerpt}`).join('\n')
1807
+ + '\n전문은 get 이 아니라 포털 지식 화면에서 본다(경로 그대로).';
1808
+ }
1809
+ case 'list_sessions': {
1810
+ const query = new URLSearchParams({ tenant: String(args.tenant) });
1811
+ if (args.page !== undefined) query.set('page', String(args.page));
1812
+ if (args.limit !== undefined) query.set('limit', String(args.limit));
1813
+ const r = await api('GET', `/api/sessions?${query.toString()}`);
1814
+ const sessions = Array.isArray(r.sessions) ? r.sessions : [];
1815
+ if (!sessions.length) return `${r.tenant} — 올라온 세션이 없습니다.`;
1816
+ const rows = sessions.map((s) => {
1817
+ const mark = s.deleted ? ' (가려짐)' : '';
1818
+ return ` ${s.uuid.slice(0, 8)} ${s.updated} ${s.lines}줄 ${(s.bytes / 1024).toFixed(0)}KB`
1819
+ + ` ${s.title || '(제목 없음)'}${mark}`;
1820
+ });
1821
+ // **본 것을 적어 둔다.** 이 짝(uuid → 고객사 id)이 없으면 심박을 보낼 수가 없다 —
1822
+ // 프레임에는 고객사 이름이 아니라 id 가 실린다.
1823
+ for (const s of sessions) {
1824
+ if (!s.deleted) indexNote(s.uuid, { tenant: r.tenant, tenant_id: s.tenant_id, head_id: s.head_id, head_ms: s.head_ms });
1825
+ }
1826
+ const p = r.pagination && typeof r.pagination === 'object' ? r.pagination : null;
1827
+ let count = `세션 ${sessions.length}건`;
1828
+ if (p && Number.isInteger(p.total) && p.range && Number.isInteger(p.range.first)
1829
+ && Number.isInteger(p.range.last)) {
1830
+ count = `세션 ${p.total}건 중 ${p.range.first}–${p.range.last}`;
1831
+ }
1832
+ const follow = [];
1833
+ if (p && Number.isInteger(p.next)) {
1834
+ follow.push(`다음 목록: list_sessions(tenant: ${JSON.stringify(args.tenant)}, `
1835
+ + `page: ${p.next}, limit: ${p.limit || 100})`);
1836
+ } else if (p?.truncated && p?.has_more) {
1837
+ follow.push(`⚠ 순차 목록은 ${p.page_max || 1000} page 상한에서 멈췄습니다. `
1838
+ + 'uuid를 알고 있는 세션은 resume_session 또는 pull_session으로 직접 여세요.');
1839
+ }
1840
+ return `${r.tenant} — ${count}\n${rows.join('\n')}\n\n`
1841
+ + '이어받으려면 `resume_session` — 지금 이 창이 그 대화를 읽고 이어서 일합니다.\n'
1842
+ + '원본 그대로 열려면 `pull_session` 으로 받은 뒤 그 폴더에서 `claude --resume <uuid>`.'
1843
+ + (follow.length ? `\n\n${follow.join('\n')}` : '');
1844
+ }
1845
+ case 'push_session': {
1846
+ const { r, before } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run);
1847
+ const cut = `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
1848
+ if (r.dry_run) {
1849
+ return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
1850
+ + `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}`;
1851
+ }
1852
+ return `${r.replaced ? '갈아 끼웠습니다' : '올렸습니다'} — ${r.tenant} · ${args.uuid.slice(0, 8)}\n`
1853
+ + `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}\n`
1854
+ + '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`';
1855
+ }
1856
+ case 'resume_session': {
1857
+ /*
1858
+ * 이어받기의 **본 경로.** 포털의 버튼이 복사해 주는 문구가 여기로 온다.
1859
+ *
1860
+ * 왜 파일만 놓지 않는가: 파일은 이 창이 못 읽는다(한 줄이 197KB 까지 간다).
1861
+ * 그래서 서버가 구운 요지를 **응답 본문에 실어** 지금 창의 맥락에 그대로 들어가게 한다 —
1862
+ * 붙여넣기+엔터 한 번으로 끝나야 「이어받기」이지, 창을 새로 열어야 하면 절차서다.
1863
+ */
1864
+ const chars = Math.max(2000, Math.min(80000, args.chars || 40000));
1865
+ const r = await api('GET',
1866
+ `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}`
1867
+ + `&digest=1&digest_chars=${chars}`);
1868
+ indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
1869
+
1870
+ // 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
1871
+ const into = args.into || process.cwd();
1872
+ const dir = transcriptDir(into);
1873
+ const target = path.join(dir, `${args.uuid}.jsonl`);
1874
+ let placed = target;
1875
+ if (fs.existsSync(target)) {
1876
+ placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
1877
+ } else {
1878
+ fs.mkdirSync(dir, { recursive: true });
1879
+ fs.writeFileSync(target, r.jsonl, { mode: 0o600 });
1880
+ }
1881
+
1882
+ /*
1883
+ * **받았다고 알린다.** 이제 이 기계도 그 대화를 쥐고 있다. 심박 주기를 기다리면
1884
+ * 최대 20초 동안 다른 사람 화면에는 이 기계가 안 보인다 — 이어받은 직후가 바로
1885
+ * 「누가 잡았나」를 알아야 하는 순간이라 여기서 한 번 즉시 뛴다.
1886
+ */
1887
+ beatTick().catch(() => {});
1888
+
1889
+ // **받기 전에 알아야 할 것을 먼저 쓴다.** 요지 뒤에 붙이면 긴 글에 묻힌다.
1890
+ const others = (r.devices || []).filter((d) => d.device !== deviceId());
1891
+ const ahead = others.filter((d) => d.verdict === BEAT_VERDICT.AHEAD);
1892
+ let warn = '';
1893
+ if (ahead.length) {
1894
+ warn = `\n⚠ **${ahead.map((d) => d.label).join(', ')} 가 서버 사본보다 앞서 있습니다.**\n`
1895
+ + '아래 요지는 그 기계가 아직 안 올린 부분을 뺀 것입니다. '
1896
+ + '그 기계의 심박이 잡히면 곧 자동으로 올라오니, 급하지 않으면 잠시 뒤 다시 받으세요.\n';
1897
+ } else if (others.length) {
1898
+ warn = `\n· 지금 ${others.map((d) => d.label).join(', ')} 도 이 대화를 열고 있습니다(서버 사본과 같은 자리).\n`;
1899
+ }
1900
+ const trimmed = r.digest_trimmed
1901
+ ? `요지 ${r.digest_chars.toLocaleString()}자(전체 ${r.digest_full_chars.toLocaleString()}자에서 가운데를 접음)`
1902
+ : `요지 ${(r.digest_chars || 0).toLocaleString()}자(전문)`;
1903
+
1904
+ return `이어받았습니다 — ${r.tenant} · ${args.uuid.slice(0, 8)} · ${r.turns || 0}턴 · ${trimmed}\n`
1905
+ + `원본을 놓은 자리: ${placed}\n`
1906
+ + `원본 그대로 열려면: cd ${into} && claude --resume ${args.uuid}\n`
1907
+ + (r.redacted ? `※ 가린 자리 ${r.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.\n` : '')
1908
+ + warn
1909
+ + '\n다음에 할 일: 아래 대화를 읽고, 사용자에게 「어디까지 되어 있고 다음이 무엇인지」를 '
1910
+ + '짧게 정리해 준 뒤 이어서 일하세요.\n'
1911
+ + `\n──────── 이어받은 대화 ────────\n\n${r.digest || '(요지가 비어 있습니다)'}`;
1912
+ }
1913
+ case 'pull_session': {
1914
+ const r = await api('GET',
1915
+ `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}`);
1916
+ const into = args.into || process.cwd();
1917
+ const dir = transcriptDir(into);
1918
+ const target = path.join(dir, `${args.uuid}.jsonl`);
1919
+ // **덮지 않는다.** 같은 uuid 가 이 기계에도 있으면 여기서 하던 대화가 사라진다.
1920
+ if (fs.existsSync(target)) {
1921
+ throw new Error(`이미 있습니다: ${target}\n덮지 않았습니다 — 이 기계에서 하던 대화가 사라집니다.`);
1922
+ }
1923
+ fs.mkdirSync(dir, { recursive: true });
1924
+ fs.writeFileSync(target, r.jsonl, { mode: 0o600 });
1925
+ indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
1926
+ return `받았습니다 — ${args.uuid.slice(0, 8)} · ${r.lines}줄 · ${(r.bytes / 1024).toFixed(0)}KB\n`
1927
+ + `놓은 자리: ${target}\n`
1928
+ + `이어서 말하려면: cd ${into} && claude --resume ${args.uuid}\n`
1929
+ + (r.redacted ? `※ 가린 자리 ${r.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
1930
+ }
1931
+ default:
1932
+ throw new Error(`알 수 없는 도구: ${name}`);
1933
+ }
1934
+ }
1935
+
1936
+ // ── JSON-RPC over stdio ─────────────────────────────────────────────
1937
+
1938
+ // 상대가 먼저 끊으면 **조용히 나간다.** 답을 쓰는 도중 파이프가 닫히면 node 가
1939
+ // EPIPE 를 처리되지 않은 오류로 올려 스택을 토하는데, 그건 고장이 아니라 종료다.
1940
+ // (검증하다 실제로 봤다 — 읽는 쪽을 먼저 죽였더니 서버가 스택을 뱉었다.)
1941
+ process.stdout.on('error', (e) => {
1942
+ if (e && e.code === 'EPIPE') process.exit(0);
1943
+ throw e;
1944
+ });
1945
+
1946
+ function send(msg) {
1947
+ process.stdout.write(JSON.stringify(msg) + '\n');
1948
+ }
1949
+
1950
+ let buf = '';
1951
+
1952
+ async function pump(flush = false) {
1953
+ for (;;) {
1954
+ const i = buf.indexOf('\n');
1955
+ let line;
1956
+ if (i >= 0) {
1957
+ line = buf.slice(0, i).trim();
1958
+ buf = buf.slice(i + 1);
1959
+ } else if (flush && buf.trim()) {
1960
+ // 마지막 줄에 개행이 없어도 처리한다 — 안 하면 조용히 아무 답이 없다.
1961
+ line = buf.trim();
1962
+ buf = '';
1963
+ } else {
1964
+ return;
1965
+ }
1966
+ if (!line) continue;
1967
+ await handle(line);
1968
+ }
1969
+ }
1970
+
1971
+ async function handle(line) {
1972
+ {
1973
+
1974
+ let req;
1975
+ try { req = JSON.parse(line); } catch { return; }
1976
+
1977
+ // 알림(id 없음)에는 답하지 않는다 — 답하면 클라이언트가 프로토콜 위반으로 본다.
1978
+ const isNotification = req.id === undefined || req.id === null;
1979
+
1980
+ try {
1981
+ let result;
1982
+ if (req.method === 'initialize') {
1983
+ result = {
1984
+ protocolVersion: req.params?.protocolVersion || '2024-11-05',
1985
+ capabilities: { tools: {} },
1986
+ /*
1987
+ * 자기소개 이름. **도구 이름 접두사와 다른 물건이다** — 그 접두사는 클라이언트 설정
1988
+ * 키(`claude mcp add --scope user <키>`)에서 나오고, 패키지는 그 값을 못 바꾼다.
1989
+ * 개명(2026-08-20) 때 스킬이 그 접두사를 박고 있어 키를 못 옮겼다. 스킬은 패키지에
1990
+ * 실려 자동으로 덮어써지는데 설정 키는 안 바뀌므로, 둘을 같이 바꾸면 기존 기계에
1991
+ * 「없는 도구를 부르는 스킬」이 배급된다. 지금은 스킬에서 접두사를 뺐으니 키도
1992
+ * 옮길 수 있다 — 그 판이 퍼진 뒤에.
1993
+ */
1994
+ serverInfo: { name: 'genetics', version: VERSION },
1995
+ /*
1996
+ * MCP 명세의 선택 필드다(initialize 결과 — "Instructions describing how to use the server").
1997
+ * 클라이언트에게 **강제되지 않는다**: Claude 계열은 시스템 프롬프트에 싣지만 다른
1998
+ * 클라이언트는 문서로 확인되지 않았다(2026-08-20 조사). 그래서 발동 조건 자체는
1999
+ * 각 도구 description 에 적고(그건 모든 클라이언트가 모델에게 준다), 여기에는
2000
+ * **도구 사이의 관계**만 적는다 — 중복하면 둘이 갈라진다.
2001
+ */
2002
+ instructions: [
2003
+ 'Lim 문서 포털 — 고객사 문서 발행, 작업 세션 이식, 사례 기억을 다룬다.',
2004
+ '',
2005
+ '무엇을 언제 쓰는가(관계만; 각 도구의 발동 조건은 그 도구 설명에 있다):',
2006
+ '· 고객에게 나갈 문서를 만들면 publish_report → release_report. 이미 나간 문서를 고치면 update_report.',
2007
+ '· 이 대화를 다른 기계가 이어받아야 하면 push_session, 이어받을 때는 resume_session.',
2008
+ '· **문제를 고쳤으면 remember_case**, **고치기 전에는 search_cases** — 이 둘이 팀의 기억이다.',
2009
+ ' 로컬 메모는 그 기계에서만 남는다. 다른 사람·다른 기계가 쓸 것이면 여기로 남긴다.',
2010
+ '',
2011
+ '「남겨줘 / 기억해줘 / 업데이트 / 갱신 / 동기화 / 올려둬」는 **무엇을 남기느냐로 갈린다**:',
2012
+ '· 알아낸 것 하나(증상과 처방) → remember_case (같은 제목이면 덮어쓰므로 갱신도 이것)',
2013
+ '· 지금 이 대화 전체 → push_session (다른 기계가 이어받게)',
2014
+ '· 고객에게 나갈 문서 → publish_report / update_report',
2015
+ '무엇인지 모호하면 묻기 전에 search_cases 로 먼저 뒤져 본다 — 이미 있으면 갱신이 답이다.',
2016
+ '',
2017
+ '자격은 고객사 키(rpk_) 또는 사람 로그인(login) 하나면 된다. 고객사는 tenant 이름으로 고른다 —',
2018
+ '담당이 아닌 고객사는 존재도 보이지 않으므로, 404 는 "없다"가 아니라 "당신 것이 아니다"일 수 있다.',
2019
+ ].join('\n'),
2020
+ };
2021
+ } else if (req.method === 'tools/list') {
2022
+ result = { tools: TOOLS };
2023
+ } else if (req.method === 'tools/call') {
2024
+ const text = await callTool(req.params?.name, req.params?.arguments || {});
2025
+ result = { content: [{ type: 'text', text }] };
2026
+ } else if (isNotification) {
2027
+ return;
2028
+ } else {
2029
+ send({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: `지원하지 않는 메서드: ${req.method}` } });
2030
+ return;
2031
+ }
2032
+ if (!isNotification) send({ jsonrpc: '2.0', id: req.id, result });
2033
+ } catch (e) {
2034
+ if (isNotification) return;
2035
+ // 도구 실행 실패는 프로토콜 오류가 아니라 **결과**로 돌려준다 —
2036
+ // 그래야 에이전트가 메시지를 읽고 고쳐서 다시 시도한다.
2037
+ if (req.method === 'tools/call') {
2038
+ send({ jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: `실패: ${e.message}` }], isError: true } });
2039
+ } else {
2040
+ send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: String(e.message || e) } });
2041
+ }
2042
+ }
2043
+ }
2044
+ }
2045
+
2046
+ /*
2047
+ * 직접 실행될 때만 stdin 을 문다. 시험이 이 파일을 import 해서 순수 함수(주소 검사·
2048
+ * 크기 파서·인코더)를 부를 수 있어야 하는데, 무조건 물면 그 프로세스가 안 끝난다.
2049
+ */
2050
+ const IS_MAIN = (() => {
2051
+ // argv[1] 이 파일이 아닐 수 있다(`node -` 로 stdin 실행 등) — 그때는 서버가 아니다.
2052
+ try {
2053
+ return !!process.argv[1]
2054
+ && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
2055
+ } catch { return false; }
2056
+ })();
2057
+ /*
2058
+ * **뜰 때 스킬을 맞춰 둔다.**
2059
+ *
2060
+ * MCP 서버를 까는 표준은 `claude mcp add` 한 줄이다. 거기에 우리만의 설치 플래그를 하나 더
2061
+ * 얹으면 **아무도 안 친다** — 그러면 도구는 뜨는데 방법(스킬)이 없다. 반경이 어긋나는 그
2062
+ * 오래된 실패(2024-11 이후 install.sh 머리말 참조)를 사람의 성실함에 기대서 막을 수는 없다.
2063
+ * 그래서 사람에게 시키지 않고 서버가 스스로 맞춘다.
2064
+ *
2065
+ * 규칙 셋:
2066
+ * · **판이 같으면 아무것도 안 한다.** 세션마다 파일을 덮으면 디스크만 긁는다.
2067
+ * · **심볼릭 링크면 손대지 않는다.** 개발 기계가 레포를 걸어 둔 자리다 — 덮으면 레포가 끊긴다.
2068
+ * · **실패해도 서버는 산다.** 스킬을 못 펴는 것은 발행을 못 할 이유가 아니다.
2069
+ */
2070
+ function ensureSkillOnBoot() {
2071
+ if (process.env.GENETICS_NO_SKILL) return; // 자동 배치를 원치 않는 사람의 탈출구
2072
+ try {
2073
+ const src = path.join(HERE, 'skills');
2074
+ if (!fs.existsSync(src)) return; // 스킬이 안 든 판
2075
+ const dst = path.join(os.homedir(), '.claude/skills');
2076
+ /*
2077
+ * ★ **옛 자리를 치운다 — 우리가 깐 것만.** 2.0.0 에서 스킬 이름이 바뀌었다
2078
+ * (lim-report-generate → genetics-report). 안 치우면 같은 일을 하는 스킬이 두 벌 남아
2079
+ * 모델이 낡은 쪽을 고를 수 있다. 판 도장(.installed-version)이 있는 것만 지운다 —
2080
+ * 그건 **우리가 깐 표시**다. 심볼릭 링크(사람이 레포에 걸어 둔 개발 자리)와 도장이 없는
2081
+ * 디렉터리는 남의 것이니 손대지 않는다.
2082
+ */
2083
+ for (const old of ['lim-report-generate']) { // legacy-ok — 지우려면 이름을 알아야 한다
2084
+ const dir = path.join(dst, old);
2085
+ try {
2086
+ if (fs.lstatSync(dir).isSymbolicLink()) continue; // 사람이 건 링크 — 남의 것
2087
+ if (!fs.existsSync(path.join(dir, '.installed-version'))) continue; // 우리가 깐 게 아니다
2088
+ fs.rmSync(dir, { recursive: true, force: true });
2089
+ process.stderr.write(`genetics: 옛 스킬 자리를 치웠다(${old}).\n`);
2090
+ } catch { /* 없으면 할 일 없다 */ }
2091
+ }
2092
+ let changed = 0;
2093
+ for (const name of fs.readdirSync(src)) {
2094
+ const to = path.join(dst, name);
2095
+ if (fs.existsSync(to) && fs.lstatSync(to).isSymbolicLink()) continue;
2096
+ const stamp = path.join(to, '.installed-version');
2097
+ let have = null;
2098
+ try { have = fs.readFileSync(stamp, 'utf8').trim(); } catch { /* 없으면 처음이다 */ }
2099
+ if (have === VERSION) continue;
2100
+ fs.mkdirSync(dst, { recursive: true });
2101
+ fs.rmSync(to, { recursive: true, force: true });
2102
+ fs.cpSync(path.join(src, name), to, { recursive: true });
2103
+ fs.writeFileSync(stamp, VERSION + '\n');
2104
+ changed += 1;
2105
+ }
2106
+ if (changed) {
2107
+ // **stdout 은 프로토콜 전용이다.** 여기에 한 글자라도 쓰면 클라이언트의 JSON 파싱이 깨진다.
2108
+ process.stderr.write(`genetics: 스킬 ${changed}개를 맞췄다(${VERSION}). 다음 세션부터 잡힌다.\n`);
2109
+ }
2110
+ } catch (e) {
2111
+ process.stderr.write(`genetics: 스킬을 펴지 못했다 — ${e.message} (서버는 계속 돈다)\n`);
2112
+ }
2113
+ }
2114
+
2115
+ if (IS_MAIN) {
2116
+ ensureSkillOnBoot();
2117
+ /*
2118
+ * **뜰 때 심박을 시작한다.** 이 프로세스가 사는 동안이 곧 「이 기계가 이 창을 열고 있는
2119
+ * 동안」이라, 시작·끝을 따로 알릴 필요가 없다. 자격이나 주소가 없으면 조용히 안 뛴다 —
2120
+ * 꺼진 것이지 고장이 아니다(그림 자격이 없을 때와 같은 태도).
2121
+ */
2122
+ startBeat();
2123
+ process.stdin.setEncoding('utf8');
2124
+ process.stdin.on('data', (chunk) => { buf += chunk; pump(); });
2125
+ process.stdin.on('end', () => { pump(true); stopBeat().catch(() => {}); });
2126
+ // 창을 닫으면 목록에서 **즉시** 빠진다. 못 알려도 90초 뒤 서버가 스스로 지운다.
2127
+ /*
2128
+ * ★ **SIGHUP 이 빠져 있었다.** 사람이 「대화창을 닫는다」고 할 때 실제로 오는 신호가 그것인데
2129
+ * (터미널이 사라지면 셸이 자식에게 SIGHUP 을 보낸다), 처리기가 없으면 노드 기본 동작으로
2130
+ * 죽어 `stopBeat()` 을 안 거친다 — 서버는 그 대화가 아직 열려 있다고 믿는다.
2131
+ * 실측(요청을 직접 세는 장치, 사람 토큰): SIGTERM 3/3 · **SIGHUP 0/3** · SIGKILL 0/3.
2132
+ * SIGKILL·절전·전원차단은 어차피 못 잡으니 서버 쪽 유예는 여전히 필요하다 — 다만 가장 흔한
2133
+ * 「창 닫기」만은 이 한 줄로 잡힌다.
2134
+ */
2135
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
2136
+ process.on(sig, () => { stopBeat().finally(() => process.exit(0)); });
2137
+ }
2138
+ }
2139
+
2140
+ // 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
2141
+ export { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW };