genetics-mcp 2.6.0 → 2.8.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 CHANGED
@@ -929,8 +929,32 @@ function scrubTranscript(raw) {
929
929
  if (typeof c === 'string') {
930
930
  const [t, n] = maskSecrets(c); r.message.content = t; redacted += n;
931
931
  } else if (Array.isArray(c)) {
932
- for (const b of c) {
932
+ for (let i = 0; i < c.length; i++) {
933
+ const b = c[i];
933
934
  if (!b || typeof b !== 'object') continue;
935
+ /*
936
+ * ★ **첨부 본문은 안 올린다.** 도구 설명이 이미 그렇게 약속한다("올라가지 않는 것:
937
+ * 도구 실행 결과·첨부"). 그런데 여기서 걷지 않아 `document`·`image` 의 base64 가
938
+ * 통째로 나갔다 — 그리고 그 한 줄이 서버의 줄 길이 검사에 걸려 **대화 전체가 막혔다.**
939
+ *
940
+ * 실측(2026-08-24): 376쪽 PDF 한 장이 line 628 에 7,782,947B.
941
+ * 81MB · 3,602줄 대화에서 상한을 넘는 줄은 **그 하나뿐**이었고, 나머지 1MB 초과 17줄은
942
+ * 전부 tool_result·image 라 어차피 가려졌다. 즉 첨부만 걷으면 통과한다.
943
+ *
944
+ * **자리는 남긴다.** 블록을 지우면 무엇이 오갔는지 대화에서 사라지고, 이어받은 사람은
945
+ * 「PDF를 참고하라」는 말만 보고 그 PDF 가 있었다는 사실을 모른다.
946
+ * `data` 만 비우지 않고 text 로 바꾸는 이유: 빈 base64 는 이어받을 때 렌더가 깨진다.
947
+ */
948
+ if ((b.type === 'document' || b.type === 'image') && b.source && typeof b.source === 'object') {
949
+ const bytes = Buffer.byteLength(String(b.source.data || ''), 'utf8');
950
+ const 무엇 = b.type === 'document' ? '문서' : '그림';
951
+ const 이름 = b.title ? ` 「${b.title}」` : '';
952
+ const 종류 = b.source.media_type ? ` ${b.source.media_type}` : '';
953
+ const 크기 = bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)}MB` : `${Math.round(bytes / 1024)}KB`;
954
+ c[i] = { type: 'text', text: `[가림: ${무엇} 첨부${이름}${종류} ${크기} — 서버에 올리며 뺐습니다]` };
955
+ redacted++;
956
+ continue;
957
+ }
934
958
  if (b.type === 'tool_result') { b.content = MASK; redacted++; } // 짝은 남긴다
935
959
  else if (b.type === 'tool_use' && b.input !== undefined) {
936
960
  const [t, n] = maskSecrets(JSON.stringify(b.input));
@@ -1322,6 +1346,68 @@ function myCitation() {
1322
1346
  return { session_uuid: uuid, head_id: head.id };
1323
1347
  }
1324
1348
 
1349
+ /**
1350
+ * **같은 폴더의 직전 세션과 같은 소속처로 고른다.** 순수 함수 — 시험이 걷는다.
1351
+ *
1352
+ * ── 왜 있나 ───────────────────────────────────────────────────────────────
1353
+ * 자동 올리기는 **색인에 있는 세션만** 본다(`beatTargets`). 그런데 색인에 넣는 길은
1354
+ * 전부 사람이 부른 도구 안이다(`pushSession`·`list_sessions`·`resume`·`pull`).
1355
+ * 그래서 **새로 시작한 세션은 색인 밖**이고, 한 번은 손으로 올려야 등록된다 —
1356
+ * 잊으면 그날 것이 통째로 안 간다(실측: 하루가 비었던 적이 있다, `8e14066`).
1357
+ *
1358
+ * 기계가 소속처를 못 고르는 것이 걸림돌이었다(이 기계에 소속처가 5곳). 규칙을 정했다
1359
+ * (사용자 결정 2026-08-24): **같은 작업 폴더의 세션과 같은 소속처.** 표를 안 만들고,
1360
+ * 같은 폴더면 같은 일이라 잘못 갈 위험이 낮다.
1361
+ *
1362
+ * ⚠ **섞여 있으면 안 고른다.** 한 폴더에 두 소속처가 있으면 추측이 되고, 추측으로
1363
+ * 남의 고객사에 대화가 올라가면 되돌릴 수 없다. 실측 2026-08-24: 폴더 6곳 중 5곳이
1364
+ * 소속처 하나로 깨끗했고, 섞인 1곳은 **전사 사본이 없는 것들**이라 폴더가 없었다.
1365
+ *
1366
+ * @param string $myDir 지금 세션 전사가 사는 폴더
1367
+ * @param array rows `[{dir, tenant, tenant_id}]` — 색인된 세션들
1368
+ * @return null|{tenant,tenant_id}|{ambiguous:number}
1369
+ */
1370
+ function adoptTenantOf(myDir, rows) {
1371
+ if (!myDir) return null;
1372
+ const same = (rows || []).filter((r) => r && r.dir === myDir && r.tenant_id);
1373
+ if (!same.length) return null;
1374
+ const ids = [...new Set(same.map((r) => String(r.tenant_id)))];
1375
+ if (ids.length > 1) return { ambiguous: ids.length };
1376
+ return { tenant: same[0].tenant, tenant_id: same[0].tenant_id };
1377
+ }
1378
+
1379
+ let adoptSaid = false;
1380
+ /** 위 규칙을 이 기계에 적용한다 — 색인에 한 줄을 더할 뿐, **올리지는 않는다.** */
1381
+ function indexAdoptCurrent() {
1382
+ const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
1383
+ if (!mine) return;
1384
+ const idx = indexRead();
1385
+ if (idx[mine]?.tenant_id) return; // 이미 등록돼 있다
1386
+ const f = transcriptOf(mine);
1387
+ if (!f) return; // 이 기계에 사본이 없다
1388
+ const myDir = path.dirname(f);
1389
+ const rows = [];
1390
+ for (const [u, e] of Object.entries(idx)) {
1391
+ if (!e?.tenant_id) continue;
1392
+ const p = transcriptOf(u);
1393
+ if (p) rows.push({ dir: path.dirname(p), tenant: e.tenant, tenant_id: e.tenant_id });
1394
+ }
1395
+ const pick = adoptTenantOf(myDir, rows);
1396
+ if (!pick) return;
1397
+ if (pick.ambiguous) {
1398
+ // **조용히 넘기지 않는다** — 왜 안 올라가는지 사람이 알아야 한다.
1399
+ if (!adoptSaid) {
1400
+ adoptSaid = true;
1401
+ process.stderr.write('genetics: 이 폴더에 소속처가 ' + pick.ambiguous
1402
+ + '곳 섞여 있어 자동 등록을 안 한다 — 한 번 `push_session` 으로 정해 주면 그 뒤는 자동이다.\n');
1403
+ }
1404
+ return;
1405
+ }
1406
+ indexNote(mine, { tenant: pick.tenant, tenant_id: pick.tenant_id, head_id: '', head_ms: 0 });
1407
+ process.stderr.write('genetics: 이 세션을 «' + pick.tenant
1408
+ + '» 로 등록했다 — 같은 폴더의 앞 세션과 같은 곳이다. 이제 자동으로 올라간다.\n');
1409
+ }
1410
+
1325
1411
  function beatTargets() {
1326
1412
  const idx = indexRead();
1327
1413
  const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
@@ -1452,10 +1538,39 @@ function beatWell() {
1452
1538
  * 끄는 길을 함께 둔다(`GENETICS_NO_AUTO_PUSH=1`). 이 값은 「내부 대화가 서버에 쌓인다」를
1453
1539
  * 뜻하므로, 끄는 길이 없으면 결정이 아니라 함정이 된다. */
1454
1540
  const AUTO_PUSH = !cfg.GENETICS_NO_AUTO_PUSH;
1541
+
1542
+ /*
1543
+ * ⚠⚠ **꺼져 있으면 말한다.** 전에는 `if (!AUTO_PUSH) return;` 한 줄로 조용히 빠졌다.
1544
+ *
1545
+ * 그 침묵의 값을 2026-08-23 에 쟀다: 이 기계의 세션이 **하루 넘게**(8-21 14:20, 6,094줄에서
1546
+ * 멈춤 — 로컬은 10,476줄) 서버에 안 올라갔고, 본문이 없으니 두뇌가 위키를 못 굽고,
1547
+ * 그래서 그날 배운 것이 팀 기억에 **한 장도** 안 남았다. 아무도 그 사실을 몰랐다 —
1548
+ * 파고들어야 보였다.
1549
+ *
1550
+ * 이 저장소는 세포를 필수로 만들며 규칙을 세웠다: **「없으면 막지 말고 알려주면서 유도한다」.**
1551
+ * 같은 규칙이 여기에도 걸린다. 끄는 것은 사람의 선택이지만, **끈 줄 모르는 것은 선택이 아니다.**
1552
+ */
1553
+ let autoPushSaid = false;
1554
+ function autoPushSilence() {
1555
+ if (autoPushSaid) return;
1556
+ autoPushSaid = true;
1557
+ // stdout 은 프로토콜이다 — 사람이 보는 자리(stderr)로만 말한다.
1558
+ process.stderr.write(
1559
+ 'genetics: 대화 본문 자동 올리기가 꺼져 있다(GENETICS_NO_AUTO_PUSH). '
1560
+ + '이 기계의 세션은 서버에 안 쌓이고, 그래서 두뇌가 그 대화로 팀 기억을 못 굽는다.\n'
1561
+ /*
1562
+ * ⚠ **설정은 시작할 때 한 번만 읽는다**(위 `cfg`). 파일을 고쳐도 돌고 있는 이 프로세스는
1563
+ * 옛 값을 든 채다 — 그래서 켠 사람이 같은 문장을 다시 보고 「안 켜졌다」고 읽는다.
1564
+ * 실측 2026-08-24: 내가 그 함정에 빠졌다. 「끈 줄 모르는 것은 선택이 아니다」와 같은 이유로
1565
+ * **켠 줄 모르는 것도 선택이 아니다.**
1566
+ */
1567
+ + ' (설정을 방금 바꿨다면 이 창을 다시 열어야 반영된다 — 시작할 때 한 번만 읽는다.)\n');
1568
+ }
1455
1569
  const autoPushAt = new Map();
1456
1570
 
1457
1571
  async function autoPushIfAhead(reply, recs) {
1458
- if (!AUTO_PUSH || !reply) return;
1572
+ if (!AUTO_PUSH) { autoPushSilence(); return; }
1573
+ if (!reply) return;
1459
1574
  const now = Date.now();
1460
1575
  for (const v of reply) {
1461
1576
  if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) continue;
@@ -1490,6 +1605,8 @@ async function beatTick() {
1490
1605
  if (beatRunning) return;
1491
1606
  beatRunning = true;
1492
1607
  try {
1608
+ // 새 세션을 같은 폴더의 앞 세션과 같은 소속처로 등록한다(색인만 — 올리기는 아래가 한다).
1609
+ if (AUTO_PUSH) { try { indexAdoptCurrent(); } catch { /* 등록 실패가 심박을 멈추면 안 된다 */ } }
1493
1610
  const recs = beatTargets();
1494
1611
  if (!recs.length) return;
1495
1612
  // **안 바뀌었으면 안 보낸다.** 이 설계가 실제로 버는 것은 바이트가 아니라 이것이다.
@@ -1520,7 +1637,9 @@ async function beatTick() {
1520
1637
  * 비용: 저장소를 아는 걸음으로 바꿔 **1,436ms → 320ms**(실측). 그리고 세포가 저장소마다
1521
1638
  * 자물쇠를 잡으므로, MCP 가 세션 수만큼 떠 있어도 **한 번만** 일한다.
1522
1639
  */
1523
- const OBSERVE_EVERY_MS = 20_000;
1640
+ // 시험은 20초를 못 기다린다. **갈아 끼울 수 있게** 둔다(`GCELL_STATE_DIR` 과 같은 규율) —
1641
+ // 다만 하한을 둬서 실수로 0.01초가 들어가 기계를 태우는 일은 막는다.
1642
+ const OBSERVE_EVERY_MS = Math.max(200, Number(cfg.GENETICS_OBSERVE_MS) || 20_000);
1524
1643
  let observeTimer = null;
1525
1644
 
1526
1645
  function observeTick() {
@@ -1534,6 +1653,8 @@ function observeTick() {
1534
1653
  c.unref?.();
1535
1654
  c.on?.('error', () => {});
1536
1655
  } catch { /* 최선만 다한다 */ }
1656
+ // 세포가 방금 갱신했을 사실을 보고, 바뀌었으면 알린다(한 tick 늦는다 — 자식이 비동기다).
1657
+ if (SEND) notifyToolsChanged(SEND);
1537
1658
  }
1538
1659
 
1539
1660
  function startObserve() {
@@ -1562,6 +1683,19 @@ async function stopBeat() {
1562
1683
  }
1563
1684
 
1564
1685
  const TOOLS = [
1686
+ /*
1687
+ * ★ **이 도구는 부르지 않아도 일한다.** 설명 자체가 세계 사실을 실어 나른다(`toolsNow()`).
1688
+ * 도구 목록은 매 턴 모델 문맥에 있으므로, 부르기 전에 이미 눈앞에 놓인다.
1689
+ * 부르면 더 많이(8건) 말한다 — 설명에는 3건만 싣는다.
1690
+ */
1691
+ {
1692
+ name: 'world',
1693
+ description:
1694
+ '이 저장소에서 **다른 세션·다른 기계**가 최근에 무엇을 바꿨나. '
1695
+ + '「누가 언제 무엇을」만 말하고 무엇을 할지는 안 말한다. '
1696
+ + '고치기 전에 「내가 알고 있는 것이 아직 맞나」가 궁금하면 이것을 본다.',
1697
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
1698
+ },
1565
1699
  {
1566
1700
  name: 'login',
1567
1701
  description:
@@ -2014,21 +2148,89 @@ function cellClean(s) {
2014
2148
  .slice(0, 200);
2015
2149
  }
2016
2150
 
2017
- function worldDelta() {
2018
- const { bin, why } = cellCheck();
2019
- if (!bin) return cellNotice(why);
2151
+ /**
2152
+ * 세계 사실을 **줄 단위로**. 도구 응답 꼬리와 도구 설명이 **같은 것**을 쓰게 하려고 뺐다 —
2153
+ * 두 곳에서 따로 지으면 같은 사실을 다르게 말하게 된다.
2154
+ *
2155
+ * ⚠ **시각을 함께 말한다.** 오늘 사고에서 결정적이었던 정보가 정확히 「20:04 에」였다.
2156
+ * `gcell delta` 의 글 출력은 2026-08-23 에 고쳤는데, MCP 는 `--json` 을 **자기가 포맷**하고
2157
+ * 있어서 여기만 여전히 시각을 버리고 있었다. 같은 결함이 두 자리에 있었던 것이다.
2158
+ */
2159
+ function worldFacts(max = 8) {
2160
+ const { bin } = cellCheck();
2161
+ if (!bin) return null; // 세포가 없다 — 「없다」와 구별한다
2020
2162
  try {
2021
2163
  const r = spawnSync(bin, ['delta', '--root', process.cwd(), '--json'],
2022
2164
  { encoding: 'utf8', timeout: 800 });
2023
- if (r.error) return cellNotice('세포를 띄우지 못했다');
2024
- if (r.status !== 0 || !r.stdout) return '';
2165
+ if (r.error || r.status !== 0 || !r.stdout) return [];
2025
2166
  const d = JSON.parse(r.stdout);
2026
2167
  const ch = Array.isArray(d.changes) ? d.changes : [];
2027
- if (!ch.length) return ''; // 조용한 구간은 조용하다.
2028
- const lines = ch.slice(0, 8).map((c) => ` · ${cellClean(c.path)} — ${cellClean(c.client)} 세션이 썼다`);
2029
- if (ch.length > 8) lines.push(` · 그 밖 ${ch.length - 8}건`);
2030
- return `이 저장소에서 최근에 바뀐 것:\n${lines.join('\n')}`;
2031
- } catch { return cellNotice('세포의 답을 못 읽었다'); }
2168
+ const out = ch.slice(0, max).map((c) => {
2169
+ const at = Number(c.at) || 0;
2170
+ const t = at > 0
2171
+ ? new Date(at * 1000).toTimeString().slice(0, 5)
2172
+ : '시각 미상';
2173
+ return `${cellClean(c.path)} — ${cellClean(c.client)} 세션이 ${t}에 썼다`;
2174
+ });
2175
+ if (ch.length > max) out.push(`그 밖 ${ch.length - max}건`);
2176
+ return out;
2177
+ } catch { return null; }
2178
+ }
2179
+
2180
+ function worldDelta() {
2181
+ const { bin, why } = cellCheck();
2182
+ if (!bin) return cellNotice(why);
2183
+ const facts = worldFacts(8);
2184
+ if (facts === null) return cellNotice('세포의 답을 못 읽었다');
2185
+ if (!facts.length) return ''; // 조용한 구간은 조용하다.
2186
+ return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
2187
+ }
2188
+
2189
+ /*
2190
+ * ★★ **도구 설명이 유일한 통로다** — 이 파일의 스모크가 2026-08-20 에 그렇게 적어 뒀고
2191
+ * (「initialize 의 instructions 는 선택 필드라 클라이언트가 안 실을 수 있다」),
2192
+ * 2026-08-23 에 Codex 로 **실증됐다**: 지침에 있는 문장을 물었더니 codex 는 문맥에서 답하지
2193
+ * 못하고 `rg` 로 저장소를 뒤져 `mcp/genetics.mjs` 를 읽어 찾았다. 즉 그 세션의 문맥에
2194
+ * instructions 가 **없었다.**
2195
+ *
2196
+ * 그런데 도구 목록은 **구조상 반드시 문맥에 있다**(없으면 모델이 도구를 못 부른다).
2197
+ * 그래서 세계 사실을 거기에 싣는다. 부르지 않아도 **눈앞에 놓인다** —
2198
+ * README 가 적어 둔 그 원칙(「검색 도구가 아니라 눈앞에 놓기」)의 유일한 이식 경로다.
2199
+ *
2200
+ * ⚠ 다른 도구의 설명은 **안 건드린다.** 발동 낱말이 곧 기능이라(스모크가 그것을 붙든다)
2201
+ * 변하는 문장을 섞으면 도구 선택이 흔들린다. 자리를 하나 따로 둔다.
2202
+ */
2203
+ /*
2204
+ * ★ **세션 도중에도 갱신한다.** 위 `toolsNow()` 는 `tools/list` 시점의 사실을 싣는데,
2205
+ * 그 시점은 사실상 **세션 시작**이다. 그러면 「자리를 비운 사이」는 덮어도
2206
+ * 「지금 막 일어난 일」은 못 덮는다 — 그리고 오늘 사고가 정확히 후자였다
2207
+ * (내가 일하는 동안 옆 세션이 배포했다).
2208
+ *
2209
+ * codex 가 `notifications/tools/list_changed` 를 안다(바이너리 7회). 사실이 **바뀌었을 때만**
2210
+ * 보낸다 — 매 tick 보내면 클라이언트가 매번 다시 묻고, 그건 소음이다.
2211
+ */
2212
+ let toolsSig = null;
2213
+ let toolsNotifyReady = false; // initialize 전에는 안 보낸다
2214
+ // stdout 에 쓰는 문은 **하나**다(`send`). 그 함수가 아래 범위에 있어 여기로 이어 둔다 —
2215
+ // 두 곳에서 각자 쓰면 응답 한가운데에 알림이 끼어 프로토콜이 깨진다.
2216
+ let SEND = null;
2217
+
2218
+ function notifyToolsChanged(sendFn) {
2219
+ const facts = worldFacts(3);
2220
+ const sig = facts === null ? null : facts.join('|');
2221
+ if (sig === null) return; // 세포를 못 읽었다 — 지어내지 않는다
2222
+ if (toolsSig === null) { toolsSig = sig; return; } // 첫 관측은 기준선일 뿐이다
2223
+ if (sig === toolsSig) return; // 조용한 구간은 조용하다
2224
+ toolsSig = sig;
2225
+ if (!toolsNotifyReady) return;
2226
+ try { sendFn({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); } catch { /* 최선만 */ }
2227
+ }
2228
+
2229
+ function toolsNow() {
2230
+ const facts = worldFacts(3);
2231
+ if (!facts || !facts.length) return TOOLS; // 조용한 구간은 조용하다
2232
+ const head = `\n\n지금 이 저장소:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
2233
+ return TOOLS.map((t) => (t.name === 'world' ? { ...t, description: t.description + head } : t));
2032
2234
  }
2033
2235
 
2034
2236
  /**
@@ -2059,7 +2261,41 @@ function deviceLabel() {
2059
2261
  try { return `${os.userInfo().username}@${os.hostname()}`; } catch { return os.hostname(); }
2060
2262
  }
2061
2263
 
2264
+ /**
2265
+ * **필수 인자를 여기서 막는다.** 스키마에 `required` 를 적어 두고 아무도 안 봤다.
2266
+ *
2267
+ * ── 왜 있나 ───────────────────────────────────────────────────────────────
2268
+ * 2026-08-23 실측: `search_cases` 를 `q` 대신 `query` 로 불렀더니 오류가 아니라
2269
+ * **`args.q` 가 `undefined`** 가 되었고, 그것이 `encodeURIComponent(undefined)` 를 지나
2270
+ * `?q=undefined` 로 나갔다. 서버는 「빈 문자열인가」만 보므로 통과시켰고, 문자열
2271
+ * `"undefined"` 로 위키를 검색해 **아무 질의에나 똑같은 한 건**(점수 429)을 돌려줬다.
2272
+ * 부르는 쪽은 그것이 답인 줄 알았다 — 팀 기억 335장이 안 닿는 채로.
2273
+ *
2274
+ * **조용한 실패다.** 없는 인자는 없다고 말해야 한다. 그리고 한 도구가 아니라
2275
+ * **모든 도구**가 같은 병이었으므로 여기 한 곳에서 막는다.
2276
+ */
2277
+ function requireArgs(name, args) {
2278
+ const spec = TOOLS.find((t) => t.name === name);
2279
+ const need = spec?.inputSchema?.required;
2280
+ if (!Array.isArray(need)) return;
2281
+ const props = spec.inputSchema.properties || {};
2282
+ const miss = need.filter((k) => args[k] === undefined || args[k] === null || args[k] === '');
2283
+ if (!miss.length) return;
2284
+ // ⚠ **비슷한 이름을 함께 말한다** — 「q 가 없다」만 보면 `query` 를 준 쪽은 못 알아챈다.
2285
+ const given = Object.keys(args);
2286
+ const hint = miss
2287
+ .map((k) => {
2288
+ const near = given.filter((g) => g !== k && (g.includes(k) || k.includes(g)));
2289
+ return near.length ? `${k}(준 것: ${near.join('·')})` : k;
2290
+ })
2291
+ .join(', ');
2292
+ const all = Object.keys(props).join(' · ');
2293
+ throw new Error(`\`${name}\` 에 필수 인자가 없습니다: ${hint}\n`
2294
+ + `받는 이름은 이것뿐입니다 — ${all}`);
2295
+ }
2296
+
2062
2297
  async function callTool(name, args = {}) {
2298
+ requireArgs(name, args);
2063
2299
  switch (name) {
2064
2300
  case 'login': {
2065
2301
  /*
@@ -2123,8 +2359,19 @@ async function callTool(name, args = {}) {
2123
2359
  ? '\n(키도 있습니다 — **발행 다섯만** 키로 나가고 나머지는 이 신원으로 갑니다.)'
2124
2360
  : '';
2125
2361
  const me = await api('GET', '/api/me');
2362
+ /*
2363
+ * ⚠ **꺼진 것을 말한다.** 자동 올리기가 꺼져 있으면 이 기계의 대화는 서버에 안 쌓이고,
2364
+ * 그러면 두뇌가 그 대화로 팀 기억을 못 굽는다. 아무도 안 말하면 「왜 어제 배운 게
2365
+ * 지식에 없지」를 사람이 짐작해야 한다(2026-08-23 실측: 하루가 통째로 비었다).
2366
+ * **사실만 적는다** — 켜라고 하지 않는다. 끄는 것은 사람의 선택이다.
2367
+ */
2368
+ const pushOff = AUTO_PUSH ? '' :
2369
+ '\n대화 본문 자동 올리기는 꺼져 있습니다(GENETICS_NO_AUTO_PUSH) — '
2370
+ + '이 기계의 세션은 서버에 안 쌓이고, 두뇌가 그 대화로 팀 기억을 굽지 않습니다.'
2371
+ // ⚠ 설정은 **시작할 때 한 번** 읽는다. 방금 켠 사람이 이 문장을 또 보고 「안 켜졌다」고 읽는다.
2372
+ + '\n (방금 설정을 바꿨다면 이 창을 다시 열어야 반영됩니다 — 시작할 때 한 번만 읽습니다.)';
2126
2373
  return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
2127
- + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
2374
+ + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}${pushOff}`;
2128
2375
  }
2129
2376
 
2130
2377
  case 'list_my_tenants': {
@@ -2309,6 +2556,12 @@ async function callTool(name, args = {}) {
2309
2556
  return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}`
2310
2557
  + pendingText(r) + warnText(r);
2311
2558
  }
2559
+ case 'world': {
2560
+ const facts = worldFacts(8);
2561
+ if (facts === null) return cellNotice('세포의 답을 못 읽었다');
2562
+ if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
2563
+ return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
2564
+ }
2312
2565
  case 'remember_case': {
2313
2566
  const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
2314
2567
  title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
@@ -2516,6 +2769,7 @@ process.stdout.on('error', (e) => {
2516
2769
  function send(msg) {
2517
2770
  process.stdout.write(JSON.stringify(msg) + '\n');
2518
2771
  }
2772
+ SEND = send;
2519
2773
 
2520
2774
  let buf = '';
2521
2775
 
@@ -2561,7 +2815,9 @@ async function handle(line) {
2561
2815
  CLIENT = req.params?.clientInfo || null;
2562
2816
  result = {
2563
2817
  protocolVersion: req.params?.protocolVersion || '2024-11-05',
2564
- capabilities: { tools: {} },
2818
+ // ★ `listChanged` 를 **선언해야** 알림을 보낼 자격이 생긴다. 명세상 안 알리고
2819
+ // 보내면 클라이언트가 버려도 할 말이 없다.
2820
+ capabilities: { tools: { listChanged: true } },
2565
2821
  /*
2566
2822
  * 자기소개 이름. **도구 이름 접두사와 다른 물건이다** — 그 접두사는 클라이언트 설정
2567
2823
  * 키(`claude mcp add --scope user <키>`)에서 나오고, 패키지는 그 값을 못 바꾼다.
@@ -2599,8 +2855,9 @@ async function handle(line) {
2599
2855
  '내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
2600
2856
  ].join('\n'),
2601
2857
  };
2858
+ toolsNotifyReady = true;
2602
2859
  } else if (req.method === 'tools/list') {
2603
- result = { tools: TOOLS };
2860
+ result = { tools: toolsNow() };
2604
2861
  } else if (req.method === 'tools/call') {
2605
2862
  const text = await callTool(req.params?.name, req.params?.arguments || {});
2606
2863
  result = { content: [{ type: 'text', text }] };
@@ -2810,4 +3067,5 @@ if (IS_MAIN) {
2810
3067
  }
2811
3068
 
2812
3069
  // 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
2813
- export { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW };
3070
+ export { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
3071
+ scrubTranscript, splitAtLines, PUSH_PART_MAX };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -18,7 +18,7 @@
18
18
  "node": ">=18"
19
19
  },
20
20
  "scripts": {
21
- "test": "node smoke.mjs && bash ../cell/tests/cellcycle.sh",
21
+ "test": "node smoke.mjs && node notify.test.mjs && bash ../cell/tests/cellcycle.sh",
22
22
  "prepack": "find skills -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null; bash ../cell/build-all.sh",
23
23
  "prepublishOnly": "npm test"
24
24
  }
@@ -97,7 +97,7 @@ def templates_from_server(repo: pathlib.Path) -> dict:
97
97
  sys.exit('REPORT_TEMPLATES 를 못 읽었습니다 — report_publish.php 모양이 바뀌었습니다.')
98
98
  body = block.group(1)
99
99
  out = {}
100
- for name in re.findall(r"'([a-z-]+)'\s*=>", body):
100
+ for name in re.findall(r"^\s{4}'([a-z0-9-]+)'\s*=>", body, re.M):
101
101
  out[name] = None
102
102
  if 'lim-report' not in out:
103
103
  sys.exit("서버에 'lim-report' 서식이 없습니다.")