genetics-mcp 2.7.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
  // **안 바뀌었으면 안 보낸다.** 이 설계가 실제로 버는 것은 바이트가 아니라 이것이다.
@@ -2144,7 +2261,41 @@ function deviceLabel() {
2144
2261
  try { return `${os.userInfo().username}@${os.hostname()}`; } catch { return os.hostname(); }
2145
2262
  }
2146
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
+
2147
2297
  async function callTool(name, args = {}) {
2298
+ requireArgs(name, args);
2148
2299
  switch (name) {
2149
2300
  case 'login': {
2150
2301
  /*
@@ -2208,8 +2359,19 @@ async function callTool(name, args = {}) {
2208
2359
  ? '\n(키도 있습니다 — **발행 다섯만** 키로 나가고 나머지는 이 신원으로 갑니다.)'
2209
2360
  : '';
2210
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 (방금 설정을 바꿨다면 이 창을 다시 열어야 반영됩니다 — 시작할 때 한 번만 읽습니다.)';
2211
2373
  return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
2212
- + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
2374
+ + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}${pushOff}`;
2213
2375
  }
2214
2376
 
2215
2377
  case 'list_my_tenants': {
@@ -2905,4 +3067,5 @@ if (IS_MAIN) {
2905
3067
  }
2906
3068
 
2907
3069
  // 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
2908
- 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.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -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' 서식이 없습니다.")