genetics-mcp 2.2.1 → 2.4.1

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
@@ -26,6 +26,7 @@ import crypto from 'node:crypto';
26
26
  import fs from 'node:fs';
27
27
  import os from 'node:os';
28
28
  import path from 'node:path';
29
+ import { pipeline as streamPipeline } from 'node:stream/promises';
29
30
  import { fileURLToPath } from 'node:url';
30
31
 
31
32
  /**
@@ -556,6 +557,66 @@ async function api(method, pathname, body, opts = {}) {
556
557
  return r.data;
557
558
  }
558
559
 
560
+ /**
561
+ * **본문을 파일로 흘린다. 봉투를 안 만든다.**
562
+ *
563
+ * `api()` 는 `res.text()` → `JSON.parse` 를 무조건 하고 `res.headers` 를 버린다. 세션 본문에는
564
+ * 그게 두 번 비싸다 — ① 10MB 본문이 JS 문자열로 힙에 한 벌 올라오고 `writeFileSync` 가 또 한 벌
565
+ * 만든다, ② 서버가 헤더에 실어 주는 메타(`X-Lim-*`)를 읽을 방법이 아예 없다.
566
+ * 실측: 902b54cd 는 **10,083,933 바이트 · 5,557줄**이다(2026-08-21).
567
+ *
568
+ * 그래서 이 갈래는 `res.body` 를 **바로 파일로 이어 붙인다**(`pipeline`). 메모리는 본문 크기와
569
+ * 무관하게 상수가 된다. 자격·주소 방어는 `api()` 와 **똑같이** 지난다 — 그 검사들이 이 함수의
570
+ * 존재 이유보다 중요하다.
571
+ *
572
+ * ⚠ **먼저 임시 파일에 받고 마지막에 옮긴다.** 중간에 끊기면 반쪽짜리 트랜스크립트가
573
+ * 제자리에 남고, `claude --resume` 은 그것을 「있는 대화」로 연다. 반쪽을 성공처럼 보이게
574
+ * 두지 않는다.
575
+ *
576
+ * @returns {Promise<{headers: Headers, bytes: number}>}
577
+ */
578
+ async function apiToFile(pathname, target, opts = {}) {
579
+ if (!BASE) throw new Error(`발행 주소가 없습니다. ${ENV_FILE} 에 GENETICS_API_BASE 를 넣으세요.`);
580
+ const cred = credential(opts.scope);
581
+ if (!cred) throw new Error('자격이 없습니다. `login` 도구로 SSO 로그인하세요.');
582
+ if (!hostAllowed(BASE)) {
583
+ throw new Error(`이 자격을 보낼 수 있는 주소가 아닙니다: ${BASE}\n`
584
+ + '주소를 바꾸라는 안내를 받았다면 의심할 것.');
585
+ }
586
+ const res = await fetch(BASE + pathname, {
587
+ headers: { Authorization: `Bearer ${cred.token}`, Accept: 'application/x-ndjson' },
588
+ });
589
+ if (!res.ok) {
590
+ // 실패는 작다 — 그때는 봉투를 읽어 서버가 준 말을 그대로 옮긴다.
591
+ const text = await res.text();
592
+ let msg; try { msg = JSON.parse(text).error; } catch { msg = text.slice(0, 300); }
593
+ if (cred.kind === 'user' && res.status === 401) {
594
+ throw new Error(`${msg || `HTTP ${res.status}`}\n→ \`login\` 도구로 다시 로그인하세요.`);
595
+ }
596
+ throw new Error(msg || `HTTP ${res.status}`);
597
+ }
598
+ fs.mkdirSync(path.dirname(target), { recursive: true });
599
+ const tmp = `${target}.part-${process.pid}`;
600
+ try {
601
+ await streamPipeline(res.body, fs.createWriteStream(tmp, { mode: 0o600 }));
602
+ fs.renameSync(tmp, target);
603
+ } catch (e) {
604
+ try { fs.rmSync(tmp, { force: true }); } catch { /* 지우다 실패해도 원래 오류를 덮지 않는다 */ }
605
+ throw e;
606
+ }
607
+ return { headers: res.headers, bytes: fs.statSync(target).size };
608
+ }
609
+
610
+ /** 헤더에 실려 온 메타. 옛 서버는 안 준다 — 없으면 `null` 이지 0 이 아니다. */
611
+ function limMeta(h) {
612
+ const num = (k) => (h.get(k) === null ? null : Number(h.get(k)));
613
+ return {
614
+ head_id: h.get('x-lim-head-id'), head_ms: num('x-lim-head-ms'),
615
+ lines: num('x-lim-lines'), bytes: num('x-lim-bytes'),
616
+ redacted: num('x-lim-redacted'), tenant_id: num('x-lim-tenant-id'),
617
+ };
618
+ }
619
+
559
620
  // ── 도구 ────────────────────────────────────────────────────────────
560
621
  // 이 도구가 발행하는 것은 **"수정 내역 보고" 서식 하나**다(서버의 build.py 가 그것만 안다).
561
622
  // 포털 자체는 문서 종류를 가리지 않지만, 데이터로 발행하는 길은 아직 이 서식뿐이다.
@@ -1018,28 +1079,34 @@ async function pushSession(tenant, uuid, title, dryRun) {
1018
1079
  *
1019
1080
  * ⚠ **줄 경계에서만 나눈다.** 검열도 비밀 재검사도 줄 단위라, 한 줄이 두 조각에 걸치면
1020
1081
  * 검열을 안 거친 반쪽이 올라간다. 서버도 같은 것을 확인하고 거절한다(두 겹).
1082
+ *
1083
+ * ★ **dry_run 도 같은 길을 간다.** 전에는 `|| dryRun` 으로 시험 실행만 통째로 보냈고, 그
1084
+ * 본문이 봉투 상한(16MB)에 걸려 413 — 긴 대화는 **시험 실행이 불가능**했다(2026-08-21 운영,
1085
+ * 검열 뒤 43.9MB). 「먼저 dry_run」이 권장 절차라 사실상 올릴 길이 없었다. 서버가 조각
1086
+ * dry_run 을 받는다(조각마다 세고 파일만 안 만든다). `body.dry_run` 이 조각마다 실린다.
1021
1087
  */
1022
1088
  const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
1023
- if (parts.length === 1 || dryRun) {
1089
+ if (parts.length === 1) {
1024
1090
  const r = await api('PUT', '/api/sessions', body);
1025
1091
  if (!dryRun) indexNote(uuid, { tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
1026
- return { r, before };
1092
+ return { r, before, parts: 1 };
1027
1093
  }
1028
1094
 
1029
1095
  const uploadId = crypto.randomBytes(12).toString('hex');
1030
1096
  let last = null;
1031
1097
  for (let i = 0; i < parts.length; i++) {
1032
- // 메타(제목·경로·도구)는 **조각마다** 싣는다. 작고, 마지막 조각에서만 쓰이지만
1098
+ // 메타(제목·경로·도구·dry_run)는 **조각마다** 싣는다. 작고, 마지막 조각에서만 쓰이지만
1033
1099
  // 어느 조각이 마지막인지 서버가 아는 것과 부르는 쪽이 아는 것이 어긋날 이유가 없다.
1034
1100
  last = await api('PUT', '/api/sessions', {
1035
1101
  ...body, jsonl: parts[i],
1036
1102
  upload_id: uploadId, part: i + 1, parts: parts.length,
1037
1103
  });
1038
- process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} 올림 `
1039
- + `(${(parts[i].length / 1048576).toFixed(1)}MB)\n`);
1104
+ // 바이트로 적는다 — `.length` 는 UTF-16 단위라 한글이 3배로 어긋난다(splitAtLines 의 그 함정).
1105
+ process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} ${dryRun ? '시험' : '올림'} `
1106
+ + `(${(Buffer.byteLength(parts[i], 'utf8') / 1048576).toFixed(1)}MB)\n`);
1040
1107
  }
1041
- indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms });
1042
- return { r: last, before };
1108
+ if (!dryRun) indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms });
1109
+ return { r: last, before, parts: parts.length };
1043
1110
  }
1044
1111
 
1045
1112
  /* ── 코로케이션 심박 ───────────────────────────────────────────────────
@@ -1453,7 +1520,17 @@ const TOOLS = [
1453
1520
  },
1454
1521
  {
1455
1522
  name: 'whoami',
1456
- description: '지금 무엇으로 붙어 있는지 — 고객사 키인지 사람 로그인인지, 사람이면 누구인지.',
1523
+ description: '지금 무엇으로 붙어 있는지 — 사람 로그인인지 소속처 키인지, 사람이면 누구인지.',
1524
+ inputSchema: { type: 'object', properties: {} },
1525
+ },
1526
+ {
1527
+ name: 'list_my_tenants',
1528
+ description:
1529
+ '**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant` 이름을 여기서 얻는다.\n'
1530
+ + '이름을 모르는 채로 `tenant` 를 **지어내지 말 것** — 이것부터 부르고, 여럿이면 사용자에게 고르게 한다.\n'
1531
+ + '지금 폴더에 대해 서버가 **본 것**을 함께 준다 — 「이 자리는 지금까지 어디에서 올라왔나」. '
1532
+ + '그것은 **기록이지 근거가 아니다**: 서버가 한 곳으로 좁히지 못하거나 자리를 못 읽으면 '
1533
+ + '그렇게 말한다. 좁혀지지 않으면 **사용자에게 물어라.**',
1457
1534
  inputSchema: { type: 'object', properties: {} },
1458
1535
  },
1459
1536
  {
@@ -1464,7 +1541,7 @@ const TOOLS = [
1464
1541
  {
1465
1542
  name: 'list_reports',
1466
1543
  description:
1467
- '이 키가 담당하는 고객사의 문서 목록. 각 줄의 대괄호가 **문서 상태**이고, ' +
1544
+ '내가 닿을 수 있는 문서 목록(사람으로 붙으면 소속처 전부, 키면 그 한 곳). 각 줄의 대괄호가 **문서 상태**이고, ' +
1468
1545
  '무엇으로 만들어졌는지·API 로 읽고 쓸 수 있는지를 서버가 판정해 준다. ' +
1469
1546
  '편집기가 정본인 문서는 API 로 못 고친다 — 사람이 편집 화면에서 고쳐야 한다. ' +
1470
1547
  '응답 끝의 다음 page 안내를 따라가면 100건 뒤의 문서도 볼 수 있다.',
@@ -1504,7 +1581,7 @@ const TOOLS = [
1504
1581
  {
1505
1582
  name: 'publish_report',
1506
1583
  description:
1507
- '새 보고서를 만든다. 고객사는 키가 정하므로 지정하지 않는다. ' +
1584
+ '새 보고서를 만든다. 키로 붙어 있으면 소속처는 키가 정한다. ' +
1508
1585
  'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
1509
1586
  '**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
1510
1587
  'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
@@ -1512,7 +1589,7 @@ const TOOLS = [
1512
1589
  inputSchema: {
1513
1590
  type: 'object',
1514
1591
  properties: {
1515
- tenant: { type: 'string', description: '발행 대상 고객사 이름. 키가 가리키는 고객사와 다르면 거부된다(오발행 방지).' },
1592
+ tenant: { type: 'string', description: '문서를 받을 소속처 이름. 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
1516
1593
  slug: { type: 'string', description: '파일 이름이 될 식별자. 영문 소문자·숫자·하이픈 (예: fixes-20260812)' },
1517
1594
  published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
1518
1595
  publish: {
@@ -1536,7 +1613,7 @@ const TOOLS = [
1536
1613
  inputSchema: {
1537
1614
  type: 'object',
1538
1615
  properties: {
1539
- tenant: { type: 'string', description: '대상 고객사 이름. 키와 다르면 거부된다.' },
1616
+ tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
1540
1617
  id: { type: 'integer' },
1541
1618
  published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
1542
1619
  },
@@ -1551,7 +1628,7 @@ const TOOLS = [
1551
1628
  inputSchema: {
1552
1629
  type: 'object',
1553
1630
  properties: {
1554
- tenant: { type: 'string', description: '대상 고객사 이름. 키와 다르면 거부된다.' },
1631
+ tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
1555
1632
  id: { type: 'integer' },
1556
1633
  reason: { type: 'string', description: '왜 내리는지. 서버 로그에 남아 다음 사람이 이유를 안다.' },
1557
1634
  },
@@ -1580,7 +1657,7 @@ const TOOLS = [
1580
1657
  inputSchema: {
1581
1658
  type: 'object',
1582
1659
  properties: {
1583
- tenant: { type: 'string', description: '대상 고객사 이름. 키와 다르면 거부된다(오발행 방지).' },
1660
+ tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다(오발행 방지).' },
1584
1661
  id: { type: 'integer' },
1585
1662
  dry_run: {
1586
1663
  type: 'boolean',
@@ -1595,7 +1672,7 @@ const TOOLS = [
1595
1672
  {
1596
1673
  name: 'remember_case',
1597
1674
  description:
1598
- '**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그 고객사 지식의 `cases/` 에 적히고 '
1675
+ '**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그 소속처 지식의 `cases/` 에 적히고 '
1599
1676
  + '다음에 누가(어느 기계에서든) `search_cases` 로 찾아 쓴다. 같은 제목이면 **덮어쓴다** — '
1600
1677
  + '그래서 「갱신」·「업데이트」도 이 도구다.\n'
1601
1678
  + '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
@@ -1611,7 +1688,7 @@ const TOOLS = [
1611
1688
  inputSchema: {
1612
1689
  type: 'object',
1613
1690
  properties: {
1614
- tenant: { type: 'string', description: '대상 고객사 이름.' },
1691
+ tenant: { type: 'string', description: '대상 소속처 이름.' },
1615
1692
  title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
1616
1693
  symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
1617
1694
  cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
@@ -1625,7 +1702,7 @@ const TOOLS = [
1625
1702
  {
1626
1703
  name: 'search_cases',
1627
1704
  description:
1628
- '**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그 고객사 지식(사례·개념·대상 전부)를 '
1705
+ '**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그 소속처 지식(사례·개념·대상 전부)을 '
1629
1706
  + '증상 문장으로 훑어 점수순으로 돌려준다.\n'
1630
1707
  + '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
1631
1708
  + ' "전에도 이런 적 있었나" · "우리 이거 겪지 않았나" · "지식에 있나 찾아봐" · '
@@ -1637,7 +1714,7 @@ const TOOLS = [
1637
1714
  inputSchema: {
1638
1715
  type: 'object',
1639
1716
  properties: {
1640
- tenant: { type: 'string', description: '대상 고객사 이름.' },
1717
+ tenant: { type: 'string', description: '대상 소속처 이름.' },
1641
1718
  q: { type: 'string', description: '증상 문장. 원인이 아니라 현상으로.' },
1642
1719
  limit: { type: 'integer', minimum: 1, maximum: 20, description: '몇 건까지(기본 5).' },
1643
1720
  fast: { type: 'boolean', description: '의미 확장을 건너뛴다(빠르지만 덜 찾는다). 기본은 확장 켬 — '
@@ -1649,12 +1726,12 @@ const TOOLS = [
1649
1726
  {
1650
1727
  name: 'list_sessions',
1651
1728
  description:
1652
- '이 고객사에 올라와 있는 **작업 세션 대화** 목록. 담당자가 부재일 때 무엇을 이어받을 수 있는지 본다. '
1729
+ '이 소속처에 올라와 있는 **작업 세션 대화** 목록. 담당자가 부재일 때 무엇을 이어받을 수 있는지 본다. '
1653
1730
  + '응답 끝의 다음 page 안내를 따라가면 100건 뒤의 세션도 볼 수 있다.',
1654
1731
  inputSchema: {
1655
1732
  type: 'object',
1656
1733
  properties: {
1657
- tenant: { type: 'string', description: '대상 고객사 이름.' },
1734
+ tenant: { type: 'string', description: '대상 소속처 이름.' },
1658
1735
  page: {
1659
1736
  type: 'integer', minimum: 1, maximum: 1000,
1660
1737
  description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
@@ -1674,11 +1751,12 @@ const TOOLS = [
1674
1751
  '`claude --resume <uuid>` 로 이어 갈 수 있다.\n' +
1675
1752
  '올라가지 않는 것: 도구 실행 결과·첨부·파일 이력·집 경로·비밀 모양(토큰·비밀번호·개인키). ' +
1676
1753
  '**도구 호출의 짝과 인자는 남는다** — 그게 없으면 대화가 안 열린다.\n' +
1677
- '쓰기 전에 무엇이 빠지는지만 보고 싶으면 dry_run: true.',
1754
+ '긴 대화는 **알아서 조각으로** 나눠 보내고 서버가 잇는다 — 크기 때문에 사람이 할 일은 없다.\n' +
1755
+ '쓰기 전에 무엇이 빠지는지만 보고 싶으면 dry_run: true(조각도 같은 길을 간다).',
1678
1756
  inputSchema: {
1679
1757
  type: 'object',
1680
1758
  properties: {
1681
- tenant: { type: 'string', description: '대상 고객사 이름.' },
1759
+ tenant: { type: 'string', description: '대상 소속처 이름.' },
1682
1760
  uuid: { type: 'string', description: '세션 id. `--resume` 에 쓰는 그 값.' },
1683
1761
  title: { type: 'string', description: '목록에서 알아볼 한 줄. 생략하면 빈 값.' },
1684
1762
  dry_run: { type: 'boolean', description: '참이면 아무것도 저장하지 않고 검열 결과만 돌려준다.' },
@@ -1702,7 +1780,7 @@ const TOOLS = [
1702
1780
  inputSchema: {
1703
1781
  type: 'object',
1704
1782
  properties: {
1705
- tenant: { type: 'string', description: '대상 고객사 이름.' },
1783
+ tenant: { type: 'string', description: '대상 소속처 이름.' },
1706
1784
  uuid: { type: 'string', description: '이어받을 세션 id.' },
1707
1785
  into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
1708
1786
  + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
@@ -1722,7 +1800,7 @@ const TOOLS = [
1722
1800
  inputSchema: {
1723
1801
  type: 'object',
1724
1802
  properties: {
1725
- tenant: { type: 'string', description: '대상 고객사 이름.' },
1803
+ tenant: { type: 'string', description: '대상 소속처 이름.' },
1726
1804
  uuid: { type: 'string', description: '받을 세션 id.' },
1727
1805
  into: { type: 'string', description: '어느 디렉터리에서 이어 갈 것인가(생략하면 지금 폴더). '
1728
1806
  + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 이어 갈 자리다.' },
@@ -1799,7 +1877,7 @@ async function callTool(name, args = {}) {
1799
1877
  base: BASE,
1800
1878
  });
1801
1879
  const warn = KEY
1802
- ? `\n\n⚠ 이 기계에는 고객사 키도 있습니다. **발행은 키로 나갑니다**(좁은 쪽이 기본). `
1880
+ ? `\n\n⚠ 이 기계에는 소속처 키도 있습니다. **발행은 키로 나갑니다**(좁은 쪽이 기본). `
1803
1881
  + `로그인 자격으로 쓰려면 ${ENV_FILE} 의 GENETICS_API_KEY 를 지우세요.`
1804
1882
  : '';
1805
1883
  return `로그인했습니다 — ${data.user.name}(${data.user.email}) · ${data.user.role}\n`
@@ -1827,6 +1905,51 @@ async function callTool(name, args = {}) {
1827
1905
  + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
1828
1906
  }
1829
1907
 
1908
+ case 'list_my_tenants': {
1909
+ /*
1910
+ * **폴더를 보내되, 그것으로 고르지 않는다.** 서버는 이 값으로 순서만 매기고 `guess` 를
1911
+ * 안 준다(api.php 의 `/api/tenants` 주석). 여기서도 하나를 골라 주지 않는다 —
1912
+ * **후보가 하나뿐일 때만** 그것이 답이고, 그건 추론이 아니라 선택지가 없는 것이다(§6-4).
1913
+ *
1914
+ * 접는 규칙은 올릴 때와 **같은 함수**를 쓴다. 다르면 서버에 쌓인 값과 안 맞아 조용히 0건이 된다.
1915
+ */
1916
+ const r = await api('GET', `/api/tenants?cwd=${encodeURIComponent(relHome(process.cwd()))}`);
1917
+ const list = Array.isArray(r.tenants) ? r.tenants : [];
1918
+ if (!list.length) return '속한 곳이 없습니다. 관리자에게 담당 지정을 요청하세요.';
1919
+
1920
+ /*
1921
+ * ⚠ **서버가 쓴 문장을 그대로 싣는다.** 여기서 다시 말을 지어내면 두 벌이 되고,
1922
+ * 그 두 벌은 반드시 갈린다. 서버는 4분법(한 곳/여러 곳/못 봤다/못 읽었다)으로만
1923
+ * 답하고 **점수를 안 준다** — 우도비는 결정에 아무것도 안 더하면서 오용에는
1924
+ * 전부를 더하기 때문이다(api.php 의 /api/tenants 머리말).
1925
+ *
1926
+ * ⚠ 옛 서버는 `place_reading` 을 안 준다 — 그때는 **아무 말도 안 한다**(빈 문자열).
1927
+ * 없는 값을 「0건」이나 「모름」으로 옮기면 그것이 또 근거로 읽힌다.
1928
+ */
1929
+ const says = (r.place_reading && typeof r.place_reading.says === 'string')
1930
+ ? r.place_reading.says : '';
1931
+ const seen = (t) => {
1932
+ const p = t.place;
1933
+ if (!p) return '';
1934
+ const rel = p.match === 'exact' ? '같은 자리' : '위아래 자리';
1935
+ // **개수가 아니라 날 수다** — 같은 날 백 번 올려도 1이다.
1936
+ const d = Number(p.attested_days) > 0 ? ` ${p.attested_days}일` : '';
1937
+ return ` · ${rel}${d}`;
1938
+ };
1939
+
1940
+ if (list.length === 1) {
1941
+ return `속한 곳이 하나입니다 — **${list[0].name}**\n`
1942
+ + '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.'
1943
+ + (says ? `\n\n${says}` : '');
1944
+ }
1945
+ const lines = list.map((t) => {
1946
+ const when = t.last_used_at ? String(t.last_used_at).slice(0, 10) : '기록 없음';
1947
+ return ` · ${t.name}${seen(t)} · 최근 ${when}`;
1948
+ });
1949
+ return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
1950
+ + `${lines.join('\n')}\n`
1951
+ + (says ? `\n${says}` : '');
1952
+ }
1830
1953
  case 'logout': {
1831
1954
  if (!credRead()) return '이 기계에는 심긴 로그인 자격이 없습니다.';
1832
1955
  fs.rmSync(CRED_FILE, { force: true });
@@ -1865,7 +1988,7 @@ async function callTool(name, args = {}) {
1865
1988
  return `#${x.id} ${tn}${x.title} (${x.published_at || '미발행'})${sl}${tag}\n ${x.url}`;
1866
1989
  });
1867
1990
  // 서버가 `tenant: null` 을 주면 「고객사 하나에 매인 자격이 아니다」는 뜻이다.
1868
- const head = r.tenant === null ? '담당 고객사 전체' : `고객사: ${r.tenant}`;
1991
+ const head = r.tenant === null ? '내 소속처 전부' : `소속처: ${r.tenant}`;
1869
1992
  /*
1870
1993
  * pagination이 없는 옛 서버와도 물린다. 새 서버면 **전체 건수와 현재 범위**를 말하고,
1871
1994
  * next를 그대로 다음 도구 호출로 보여 준다 — 첫 100건만 보인 채 끝나는 막다른 길을
@@ -2027,14 +2150,16 @@ async function callTool(name, args = {}) {
2027
2150
  + (follow.length ? `\n\n${follow.join('\n')}` : '');
2028
2151
  }
2029
2152
  case 'push_session': {
2030
- const { r, before } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run);
2153
+ const { r, before, parts } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run);
2031
2154
  const cut = `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
2155
+ // 조각으로 갔으면 말한다 — 크기 때문에 사람이 할 일이 없다는 것을 결과가 보여 준다.
2156
+ const how = parts > 1 ? ` · ${parts}조각으로` : '';
2032
2157
  if (r.dry_run) {
2033
2158
  return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
2034
- + `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}`;
2159
+ + `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}`;
2035
2160
  }
2036
2161
  return `${r.replaced ? '갈아 끼웠습니다' : '올렸습니다'} — ${r.tenant} · ${args.uuid.slice(0, 8)}\n`
2037
- + `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}\n`
2162
+ + `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}${how}\n`
2038
2163
  + '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`';
2039
2164
  }
2040
2165
  case 'resume_session': {
@@ -2045,10 +2170,22 @@ async function callTool(name, args = {}) {
2045
2170
  * 그래서 서버가 구운 요지를 **응답 본문에 실어** 지금 창의 맥락에 그대로 들어가게 한다 —
2046
2171
  * 붙여넣기+엔터 한 번으로 끝나야 「이어받기」이지, 창을 새로 열어야 하면 절차서다.
2047
2172
  */
2173
+ /*
2174
+ * **두 부름으로 나눈다.**
2175
+ *
2176
+ * 예전에는 한 번에 받았다 — 요지와 본문이 같은 JSON 봉투에 실려 왔고, 본문 10MB 가
2177
+ * 문자열로 힙에 올라온 뒤 `writeFileSync` 가 또 한 벌을 만들었다. 그런데 **본문은 이 창이
2178
+ * 안 읽는다**(한 줄이 197KB 까지 간다). 창이 읽는 것은 요지뿐이다.
2179
+ *
2180
+ * ① `?no_body=1&digest=1` → 요지와 메타만. 서버가 본문 파일을 **읽지도 않는다**.
2181
+ * ② `?raw=1` → ndjson 을 파일로 **흘린다**(apiToFile). 봉투 0벌.
2182
+ *
2183
+ * ②는 파일을 놓을 때만 부른다 — 이미 있으면 안 덮으므로 받을 이유도 없다.
2184
+ */
2048
2185
  const chars = Math.max(2000, Math.min(80000, args.chars || 40000));
2186
+ const q = `?tenant=${encodeURIComponent(args.tenant)}`;
2049
2187
  const r = await api('GET',
2050
- `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}`
2051
- + `&digest=1&digest_chars=${chars}`);
2188
+ `/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
2052
2189
  indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
2053
2190
 
2054
2191
  // 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
@@ -2059,8 +2196,7 @@ async function callTool(name, args = {}) {
2059
2196
  if (fs.existsSync(target)) {
2060
2197
  placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
2061
2198
  } else {
2062
- fs.mkdirSync(dir, { recursive: true });
2063
- fs.writeFileSync(target, r.jsonl, { mode: 0o600 });
2199
+ await apiToFile(`/api/sessions/${encodeURIComponent(args.uuid)}${q}&raw=1`, target);
2064
2200
  }
2065
2201
 
2066
2202
  /*
@@ -2095,22 +2231,29 @@ async function callTool(name, args = {}) {
2095
2231
  + `\n──────── 이어받은 대화 ────────\n\n${r.digest || '(요지가 비어 있습니다)'}`;
2096
2232
  }
2097
2233
  case 'pull_session': {
2098
- const r = await api('GET',
2099
- `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}`);
2100
2234
  const into = args.into || process.cwd();
2101
2235
  const dir = transcriptDir(into);
2102
2236
  const target = path.join(dir, `${args.uuid}.jsonl`);
2103
- // **덮지 않는다.** 같은 uuid 가 이 기계에도 있으면 여기서 하던 대화가 사라진다.
2237
+ /*
2238
+ * **덮지 않는다.** 같은 uuid 가 이 기계에도 있으면 여기서 하던 대화가 사라진다.
2239
+ * ★ 그 검사를 **받기 전에** 한다 — 이 도구는 본문만 받으므로, 뒤에 두면 10MB 를
2240
+ * 다 받아 놓고 버리게 된다(옛 코드가 그랬다).
2241
+ */
2104
2242
  if (fs.existsSync(target)) {
2105
2243
  throw new Error(`이미 있습니다: ${target}\n덮지 않았습니다 — 이 기계에서 하던 대화가 사라집니다.`);
2106
2244
  }
2107
- fs.mkdirSync(dir, { recursive: true });
2108
- fs.writeFileSync(target, r.jsonl, { mode: 0o600 });
2109
- indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
2110
- return `받았습니다 — ${args.uuid.slice(0, 8)} · ${r.lines}줄 · ${(r.bytes / 1024).toFixed(0)}KB\n`
2245
+ // 봉투를 안 만든다 — 메타는 헤더로 온다(apiToFile 주석).
2246
+ const { headers, bytes } = await apiToFile(
2247
+ `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
2248
+ target);
2249
+ const m = limMeta(headers);
2250
+ indexNote(args.uuid, { tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms });
2251
+ // 헤더를 안 주는 옛 서버면 **지어내지 않는다** — 받은 바이트만 말한다.
2252
+ const size = `${((m.bytes ?? bytes) / 1024).toFixed(0)}KB`;
2253
+ return `받았습니다 — ${args.uuid.slice(0, 8)} · ${m.lines === null ? '' : `${m.lines}줄 · `}${size}\n`
2111
2254
  + `놓은 자리: ${target}\n`
2112
2255
  + `이어서 말하려면: cd ${into} && claude --resume ${args.uuid}\n`
2113
- + (r.redacted ? `※ 가린 자리 ${r.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
2256
+ + (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
2114
2257
  }
2115
2258
  default:
2116
2259
  throw new Error(`알 수 없는 도구: ${name}`);
@@ -2193,7 +2336,7 @@ async function handle(line) {
2193
2336
  * **도구 사이의 관계**만 적는다 — 중복하면 둘이 갈라진다.
2194
2337
  */
2195
2338
  instructions: [
2196
- 'Lim 문서 포털 — 고객사 문서 발행, 작업 세션 이식, 사례 기억을 다룬다.',
2339
+ 'genetics — 소속처 문서 발행, 작업 세션 이식, 사례 기억을 다룬다.',
2197
2340
  '',
2198
2341
  '무엇을 언제 쓰는가(관계만; 각 도구의 발동 조건은 그 도구 설명에 있다):',
2199
2342
  '· 고객에게 나갈 문서를 만들면 publish_report → release_report. 이미 나간 문서를 고치면 update_report.',
@@ -2207,8 +2350,10 @@ async function handle(line) {
2207
2350
  '· 고객에게 나갈 문서 → publish_report / update_report',
2208
2351
  '무엇인지 모호하면 묻기 전에 search_cases 로 먼저 뒤져 본다 — 이미 있으면 갱신이 답이다.',
2209
2352
  '',
2210
- '자격은 고객사 키(rpk_) 또는 사람 로그인(login) 하나면 된다. 고객사는 tenant 이름으로 고른다 —',
2211
- '담당이 아닌 고객사는 존재도 보이지 않으므로, 404 는 "없다"가 아니라 "당신 것이 아니다"일 수 있다.',
2353
+ '자격은 **`login` 으로 받는 SSO 하나면 된다** — 그것으로 내가 속한 소속처 전부에 닿는다.',
2354
+ '키(rpk_)도 쓸 수 있지만 **한 소속처에만** 매이고, 발행할 때만 그쪽이 먼저 쓰인다.',
2355
+ '어디로 갈지는 tenant 에 소속처 **이름**으로 적는다. 이름을 모르면 지어내지 말고 사용자에게 묻는다 —',
2356
+ '내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
2212
2357
  ].join('\n'),
2213
2358
  };
2214
2359
  } else if (req.method === 'tools/list') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.2.1",
3
+ "version": "2.4.1",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -16,7 +16,22 @@ references/qa.md 의 시각 검수 목록을 따로 돌 것.
16
16
  import json, pathlib, re, sys
17
17
 
18
18
  sys.path.insert(0, str(pathlib.Path(__file__).parent))
19
- import palette as P
19
+
20
+ # ── palette 는 **레포에서** 가져온다 ────────────────────────────────
21
+ #
22
+ # 예전에는 `assets/` 에 `palette.py` 사본이 있어서 위 한 줄이면 됐다. 2026-08-18 에 그 사본을
23
+ # 지웠고(렌더러의 원본은 레포 하나 — render.py 의 refuse_fork 가 `palette.py` 를 금지 목록에
24
+ # 넣어 두었다), **render.py 는 새 방식으로 갔는데 qa.py 는 안 갔다.** 그래서 이 파일은 그날부터
25
+ # `ModuleNotFoundError: No module named 'palette'` 로 죽어 있었다 — 이 저장소의 어느 커밋에도
26
+ # `assets/palette.py` 가 없다(전 역사 0개, 2026-08-22 확인).
27
+ #
28
+ # 찾는 규칙을 **여기서 새로 쓰지 않는다.** render.py 의 것을 그대로 부른다 —
29
+ # 두 벌로 두면 한쪽만 고쳐져 조용히 갈라진다(LIM_PORTAL 로 덮는 길도 함께 따라온다).
30
+ from render import find_builder, refuse_fork # noqa: E402 (경로를 넣은 뒤여야 한다)
31
+
32
+ refuse_fork() # 사본이 다시 생겼으면 여기서 멈춘다
33
+ sys.path.insert(0, str(find_builder().parent)) # <레포>/private/builder
34
+ import palette as P # noqa: E402
20
35
 
21
36
  FONT_ALLOW = {'Pretendard', 'Pretendard Variable', 'Inter', 'Lora', 'Poppins', 'Georgia',
22
37
  '맑은 고딕', 'Malgun Gothic', 'JetBrains Mono', '구름 산스 코드',
@@ -26,7 +26,9 @@ SKILL.md 는 "무엇을 만드는가"를 정하고, 이 문서는 **각 단계
26
26
  | 8 | 전달 | 고객본 `.html` | **발송 직전 사람 승인** | 내보내기 `confirm` |
27
27
 
28
28
  새 프로젝트에서 손대는 파일은 `report.json` 과 `cases.json` **둘뿐**이다.
29
- `build.py`·`palette.py`·`report.css`·`editor.js`·`qa.py` 는 프로젝트 고유 데이터를 하나도 갖고 있지 않다.
29
+ `build.py`·`palette.py`·`report.css`·`editor.js` 는 **레포**(`private/builder/`)에 있고,
30
+ `qa.py` 만 이 스킬 자산이다 — 넷은 렌더러라 원본이 레포 하나이고, `qa.py` 는 저작 도구라 여기 남는다.
31
+ 다섯 모두 프로젝트 고유 데이터를 하나도 갖고 있지 않다.
30
32
 
31
33
  명령은 두 줄이다.
32
34
 
@@ -25,7 +25,10 @@ python3 ~/.claude/skills/genetics-report/assets/qa.py report.json # 그다
25
25
  `forbid`(금지 정규식), `lang`, `allow_external`, `font.embed` 를 읽는다.
26
26
  같은 규칙을 빌드와 검수가 **한 파일에서** 읽으므로 두 벌이 갈라질 수 없다.
27
27
  - `out` 경로는 `report.json` 이 있는 디렉터리 기준으로 푼다.
28
- - `qa.py` 는 같은 폴더의 `palette.py` 를 import 한다(`sys.path.insert`). **둘을 떼어 놓으면 안 된다.**
28
+ - `qa.py` 는 `palette` 을 **레포에서** 가져온다(`<레포>/private/builder/palette.py`).
29
+ 찾는 규칙은 `render.py` 의 `find_builder()` 하나뿐이고 `LIM_PORTAL` 로 덮을 수 있다.
30
+ **`assets/` 에 사본을 두면 안 된다** — `refuse_fork()` 가 그것을 금지 목록으로 막는다
31
+ (2026-08-18 에 사본을 지운 이유: 나흘 만에 887줄이 갈라져 고객 문서가 브랜드 서식 없이 나갔다).
29
32
  - 종료 코드: 실패 1건이라도 있으면 `1`, 없으면 `0`. **경고는 종료 코드를 바꾸지 않는다.**
30
33
 
31
34
  실사례 출력(18건, 523,464B):