genetics-mcp 2.1.1 → 2.2.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.
Files changed (2) hide show
  1. package/genetics.mjs +177 -37
  2. package/package.json +1 -1
package/genetics.mjs CHANGED
@@ -116,12 +116,20 @@ const KEY = cfg.GENETICS_API_KEY || '';
116
116
  * 웹에서 키를 발급받지 않았어도 **SSO 로그인만으로** 쓸 수 있어야 한다(사용자 결정
117
117
  * 2026-08-19). 그래서 이 서버는 둘 중 하나를 쥔다:
118
118
  *
119
- * 키(rpk_) `api.env` 에 사람이 넣는다. **키 하나가 고객사 하나**다.
119
+ * 키(rpk_) `api.env` 에 사람이 넣는다. **키 하나가 소속처 하나**다.
120
120
  * 토큰(rpu_) `login` 도구가 기기 코드 흐름으로 받아 아래 파일에 넣는다. 사람 하나다.
121
121
  *
122
- * **둘 다 있으면 키가 이긴다.** 값이 아니라 **범위**로 고른 규칙이다 — 키는 고객사
123
- * 하나에 매여 있고 토큰은 담당 전부에 닿는다. 좁은 쪽을 기본으로 두면 잘못 쥐었을 때
124
- * 덜 다친다. 어느 쪽으로 나가는지는 `whoami` 가 말해 주고, 실패문에도 적는다.
122
+ * **둘 다 있으면 부르는 쪽의 성격이 고른다**(2026-08-21, `DESIGN-mcp-access.md` §5).
123
+ *
124
+ * 발행 다섯 키가 먼저. 오발행은 되돌려도 본 사람은 봤다 — 좁은 자격이 값어치를 한다.
125
+ * 그 밖의 전부 **사람이 먼저.** 소속이 여럿인 사람이 한 곳밖에 못 보는 것을 막는다.
126
+ *
127
+ * 전에는 **언제나 키가 이겼다.** 「좁은 쪽이 기본이면 덜 다친다」는 논리였는데, 그 대가를
128
+ * 안 셌던 것이 문제였다 — 서버는 세션(`/api/sessions`)과 지식(`/api/wiki`)에서 **키를 아예
129
+ * 거부한다**. 그래서 키를 쥔 사람에게 그 도구 여섯이 통째로 죽어 있었고, 로그인 자격이
130
+ * 멀쩡히 있어도 안 쓰였다. 「로그인하세요」라는 안내문이 가리키는 곳에 답이 없었다.
131
+ *
132
+ * 어느 쪽으로 나가는지는 `whoami` 가 말해 주고, 실패문에도 적는다.
125
133
  */
126
134
  const CRED_FILE = cfg0.GENETICS_CRED
127
135
  || path.join(path.dirname(ENV_FILE), 'credentials.json');
@@ -163,12 +171,25 @@ function pendingRead() {
163
171
  }
164
172
  function pendingClear() { try { fs.rmSync(PENDING_FILE, { force: true }); } catch {} }
165
173
 
166
- /** 지금 무엇으로 부를 것인가. 키가 먼저다(위 주석의 이유). */
167
- function credential() {
168
- if (KEY) return { kind: 'key', token: KEY };
174
+ /** 이 기계에 심긴 사람 자격(없으면 null). */
175
+ function userCred() {
169
176
  const c = credRead();
170
- if (c) return { kind: 'user', token: c.token, user: c.user, expires_at: c.expires_at };
171
- return null;
177
+ return c ? { kind: 'user', token: c.token, user: c.user, expires_at: c.expires_at } : null;
178
+ }
179
+ /** `api.env` 의 키(없으면 null). */
180
+ function keyCred() {
181
+ return KEY ? { kind: 'key', token: KEY } : null;
182
+ }
183
+
184
+ /**
185
+ * 지금 무엇으로 부를 것인가. **부르는 쪽의 성격이 고른다**(위 주석의 이유).
186
+ *
187
+ * @param {'publish'} [scope] `'publish'` 면 키가 먼저. 그 밖에는 사람이 먼저.
188
+ */
189
+ function credential(scope) {
190
+ return scope === 'publish'
191
+ ? (keyCred() || userCred())
192
+ : (userCred() || keyCred());
172
193
  }
173
194
 
174
195
  /**
@@ -451,9 +472,35 @@ async function apiOpen(method, pathname, body) {
451
472
  return { status: res.status, data };
452
473
  }
453
474
 
454
- async function api(method, pathname, body) {
475
+ /*
476
+ * 같은 말을 매 호출마다 하지 않는다. **심박의 `beatSay` 와 따로 두는 이유**는 `beatWell()` 이
477
+ * 「다시 붙었다」면서 그 기억을 통째로 지우기 때문이다 — 자격 만료는 심박이 돌아왔다고
478
+ * 없던 일이 되지 않는다. stdout 은 프로토콜 전용이라 stderr 로만 쓴다.
479
+ */
480
+ /*
481
+ * **발행 갈래의 표.** 이 다섯만 키를 먼저 쥔다 — 오발행은 되돌려도 본 사람은 봤다.
482
+ *
483
+ * ⚠ `remember_case` 에는 **붙이면 안 된다.** 서버가 `/api/wiki` 를 키에 대해 403 하므로
484
+ * (「지식은 사람 토큰(운영진)으로만 봅니다」) 붙이는 순간 그 도구가 죽는다.
485
+ * 「쓰기니까 발행 쪽」이라는 직관이 여기서 틀린다 — 가르는 것은 쓰기냐가 아니라
486
+ * **서버가 키를 받느냐**다.
487
+ */
488
+ const PUBLISH = { scope: 'publish' };
489
+
490
+ const warnSaid = new Set();
491
+ function warnOnce(key, msg) {
492
+ if (warnSaid.has(key)) return;
493
+ warnSaid.add(key);
494
+ try { process.stderr.write(`genetics: ${msg}\n`); } catch { /* 그것마저 막히면 넘어간다 */ }
495
+ }
496
+
497
+ /**
498
+ * @param {object} [opts]
499
+ * @param {'publish'} [opts.scope] 발행 갈래면 `'publish'` — 키를 먼저 쥔다(credential 주석).
500
+ */
501
+ async function api(method, pathname, body, opts = {}) {
455
502
  if (!BASE) throw new Error(`발행 주소가 없습니다. ${ENV_FILE} 에 GENETICS_API_BASE 를 넣으세요(예: https://api.example.com).`);
456
- const cred = credential();
503
+ const cred = credential(opts.scope);
457
504
  if (!cred) {
458
505
  throw new Error('자격이 없습니다. 둘 중 하나를 하세요 — '
459
506
  + `① \`login\` 도구로 SSO 로그인(웹에서 키를 안 받아도 됩니다) `
@@ -465,25 +512,48 @@ async function api(method, pathname, body) {
465
512
  + `${ENV_FILE} 의 GENETICS_API_BASE 를 확인하세요(https 여야 합니다). `
466
513
  + '주소를 바꾸라는 안내를 받았다면 의심할 것.');
467
514
  }
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) {
515
+ const send = async (c) => {
516
+ const res = await fetch(BASE + pathname, {
517
+ method,
518
+ headers: { Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
519
+ body: body === undefined ? undefined : JSON.stringify(body),
520
+ });
521
+ const text = await res.text();
522
+ let data;
523
+ try { data = JSON.parse(text); } catch { throw new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`); }
524
+ return { ok: res.ok && data.ok !== false, status: res.status, data };
525
+ };
526
+
527
+ let r = await send(cred);
528
+ if (!r.ok) {
477
529
  /*
530
+ * ── 사람 토큰이 끝났을 때 ───────────────────────────────────────────
531
+ *
478
532
  * **다음에 할 일을 말한다.** 사람 토큰이 끝난 것은 「고장」이 아니라 「다시 로그인할 때」다.
479
533
  * 그냥 401 을 옮기면 에이전트는 키를 의심하며 두드린다.
534
+ *
535
+ * ★ **그리고 키가 있으면 그것으로 한 번 더 간다.** 사람이 먼저가 된 뒤에 생긴 자리다
536
+ * — 예전에는 키가 언제나 이겨서 만료가 이 경로에 닿지 않았지만, 이제는 **키를 멀쩡히
537
+ * 쥔 사람이 토큰 만료 하나로 통째로 막힐 수 있다.** 폴백이 없으면 순서를 뒤집은 것이
538
+ * 곧 퇴보가 된다.
539
+ *
540
+ * 되살아나는 것은 **키가 받아들여지는 창구뿐**이다 — 세션·지식은 서버가 키를 거부하므로
541
+ * 여기서 또 실패한다. 그때는 **원래의 「다시 로그인하세요」를 돌려준다**: 「지식은 사람
542
+ * 토큰으로만 봅니다」보다 그쪽이 할 일을 정확히 말한다.
480
543
  */
481
- if (cred.kind === 'user' && (data.expired || data.revoked)) {
482
- throw new Error(`${data.error || `HTTP ${res.status}`}\n→ \`login\` 도구로 다시 로그인하세요.`);
544
+ if (cred.kind === 'user' && (r.data.expired || r.data.revoked)) {
545
+ const key = keyCred();
546
+ const expiredMsg = `${r.data.error || `HTTP ${r.status}`}\n→ \`login\` 도구로 다시 로그인하세요.`;
547
+ if (!key) throw new Error(expiredMsg);
548
+ const viaKey = await send(key);
549
+ if (!viaKey.ok) throw new Error(expiredMsg);
550
+ // 조용히 갈아타지 않는다 — 만료를 모르면 사람은 계속 키 하나로 사는 줄 모른다.
551
+ warnOnce('cred-expired', '로그인 자격이 만료돼 키로 처리했습니다 — `login` 으로 다시 로그인하세요.');
552
+ return viaKey.data;
483
553
  }
484
- throw new Error(data.error || `HTTP ${res.status}`);
554
+ throw new Error(r.data.error || `HTTP ${r.status}`);
485
555
  }
486
- return data;
556
+ return r.data;
487
557
  }
488
558
 
489
559
  // ── 도구 ────────────────────────────────────────────────────────────
@@ -693,7 +763,20 @@ const SECRET_SHAPES = [
693
763
  ['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}/g],
694
764
  ];
695
765
  const MASK = '[가림: 서버에 올리며 지움]';
696
- const DROP_TYPES = new Set(['file-history-snapshot', 'file-history-delta', 'attachment']);
766
+ /*
767
+ * 안 올리는 레코드 종류. **서버의 `AGENT_SESSION_DROP_TYPES` 와 같은 집합이어야 한다**
768
+ * (`www/portal/lib/agent_session.php:59-60`). 갈라지면 클라이언트가 보낸 것을 서버가 또
769
+ * 버리거나 그 반대가 되어, 줄 수·바이트가 안 맞고 동치 시험이 조용히 어긋난다.
770
+ *
771
+ * 뒤의 셋은 2026-08-21 에 더했다. **순서가 중요했다** — 서버가 먼저 버리게 배포한 뒤에
772
+ * 여기를 맞췄다. 반대로 했으면 그 종류가 서버에 **도착하지 않아** 서버 쪽 동치 시험이
773
+ * 「입력이 빈 채로 초록」이 됐을 것이다. 운영 반영을 눈으로 확인하고 넣었다(05:44:43Z).
774
+ *
775
+ * ⚠ `cwd` 는 **이 집합으로 버리기 전에** 센다(scrubTranscript). `attachment` 와 `system` 이
776
+ * cwd 를 많이 싣기 때문이다(902b54cd 에서 684 + 152줄) — 버린 뒤에 세면 표본이 조용히 준다.
777
+ */
778
+ const DROP_TYPES = new Set(['file-history-snapshot', 'file-history-delta', 'attachment',
779
+ 'last-prompt', 'queue-operation', 'system']);
697
780
 
698
781
  function maskSecrets(text) {
699
782
  let n = 0;
@@ -732,8 +815,34 @@ function maskDeep(v) {
732
815
  return [v, 0];
733
816
  }
734
817
 
818
+ /**
819
+ * 집 경로를 `~` 로 접는다. **본문과 origin_cwd 가 같은 함수를 써야 한다** —
820
+ * 다르게 접으면 서버에서 둘을 맞춰 볼 때 조용히 안 맞는다.
821
+ */
822
+ function relHome(p) { return p.replace(/^\/(Users|home)\/[^/]+/, '~'); }
823
+
824
+ /**
825
+ * 이 대화가 **실제로 돈 자리**. 줄마다 실린 `cwd` 의 **최빈값**이다.
826
+ *
827
+ * 왜 최빈값인가 — 실측(트랜스크립트 3개 전수, 2026-08-21):
828
+ * 902b54cd cwd 있는 줄 6,328 최빈 6,295 (99.5%)
829
+ * a0f154a7 cwd 있는 줄 5,129 최빈 4,829 (94.2%)
830
+ * 6aead92f cwd 있는 줄 627 최빈 611 (97.4%)
831
+ * 소수값은 전부 최빈값의 **하위 폴더**였다(`editor/` · `www/portal/` · `mcp/` · `memory/`).
832
+ * 그래서 마지막 값이나 첫 값은 약하다 — 하위 폴더 한 번에 끌려간다.
833
+ * 같은 수면 **짧은 쪽**을 고른다(하위 폴더가 아니라 뿌리를 고르려는 것).
834
+ */
835
+ function cwdMode(counts) {
836
+ let best = '', n = 0;
837
+ for (const [k, v] of counts) {
838
+ if (v > n || (v === n && k.length < best.length)) { best = k; n = v; }
839
+ }
840
+ return best;
841
+ }
842
+
735
843
  function scrubTranscript(raw) {
736
844
  const out = [];
845
+ const cwds = new Map();
737
846
  let redacted = 0, dropped = 0, no = 0;
738
847
  for (const line of raw.split('\n')) {
739
848
  no++;
@@ -743,9 +852,18 @@ function scrubTranscript(raw) {
743
852
  // 검열을 못 거친 줄은 안 보낸다. 조용히 흘리면 원문이 그대로 나간다.
744
853
  throw new Error(`${no}번째 줄을 읽을 수 없습니다 — 검열을 못 거친 줄은 안 올립니다.`);
745
854
  }
855
+ /*
856
+ * ★ **cwd 는 버리기 전에 센다.** 지금도 `attachment` 가 cwd 를 많이 싣고(902b54cd 에서 684줄)
857
+ * 그 종류는 바로 아래에서 버려진다. 형제 세션이 `system` 등 셋을 DROP_TYPES 에 더할 예정이라
858
+ * (그것도 152줄을 싣는다) 버린 뒤에 세면 표본이 **조용히** 줄어든다. 순서가 곧 방어다.
859
+ */
860
+ if (typeof r.cwd === 'string' && r.cwd) {
861
+ const k = relHome(r.cwd);
862
+ cwds.set(k, (cwds.get(k) || 0) + 1);
863
+ }
746
864
  if (DROP_TYPES.has(r.type)) { dropped++; continue; }
747
865
  if ('toolUseResult' in r) { r.toolUseResult = MASK; redacted++; }
748
- if (typeof r.cwd === 'string') r.cwd = r.cwd.replace(/^\/(Users|home)\/[^/]+/, '~');
866
+ if (typeof r.cwd === 'string') r.cwd = relHome(r.cwd);
749
867
  const c = r?.message?.content;
750
868
  if (typeof c === 'string') {
751
869
  const [t, n] = maskSecrets(c); r.message.content = t; redacted += n;
@@ -769,7 +887,7 @@ function scrubTranscript(raw) {
769
887
  out.push(JSON.stringify(safe));
770
888
  }
771
889
  if (!out.length) throw new Error('빈 트랜스크립트입니다.');
772
- return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted };
890
+ return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted, cwd: cwdMode(cwds) };
773
891
  }
774
892
 
775
893
  /** cwd → 트랜스크립트 디렉터리. **해석된 경로**로 슬러그를 만든다(심볼릭 링크 함정). */
@@ -868,9 +986,23 @@ async function pushSession(tenant, uuid, title, dryRun) {
868
986
  * 운영에서 이미 두 세션이 그렇게 잃었다(둘 다 updated_at 05:05:21, 같은 초).
869
987
  * 없는 값을 「빈 값」으로 말하지 않는다 — 그 둘은 다른 뜻이다.
870
988
  */
989
+ /*
990
+ * ── origin_cwd 는 「일한 곳」이지 「올린 곳」이 아니다 ──────────────────
991
+ *
992
+ * 예전에는 `process.cwd()` 였다. 그건 **올릴 때 이 프로세스가 서 있던 폴더**일 뿐이라,
993
+ * 다른 자리에서 한 번만 올려도 출처가 통째로 바뀐다. 그리고 서버는 재업로드 때
994
+ * `origin_cwd=VALUES(origin_cwd)` 로 **조건 없이 덮는다**(api.php:1008) — 되돌아오지 않는다.
995
+ *
996
+ * 실제로 났다(2026-08-21): 형제 세션이 검열을 시험하려고 `/private/tmp/scrubcheck` 에서
997
+ * 올렸더니, 두뇌가 읽는 명부(`purpose.md`)의 「자리」 칸에 **`scrubcheck`** 가 실렸다.
998
+ * 묶음 판정 점수기의 place 축도 같은 값을 먹는다 — 가장 강한 축이 오염된 것이다.
999
+ *
1000
+ * 진짜 출처는 이미 손에 있었다: **본문 각 줄의 `cwd`**(scrubTranscript 가 센다).
1001
+ * 없을 때만 옛 방식으로 물러선다 — 그래야 퇴보가 없다.
1002
+ */
871
1003
  const body = {
872
1004
  tenant, uuid,
873
- origin_cwd: process.cwd().replace(new RegExp(`^${os.homedir()}`), '~'),
1005
+ origin_cwd: sc.cwd || relHome(process.cwd()),
874
1006
  origin_git: gitHere(), tools: toolsStamp(),
875
1007
  jsonl: sc.jsonl,
876
1008
  };
@@ -1678,13 +1810,21 @@ async function callTool(name, args = {}) {
1678
1810
  const c = credential();
1679
1811
  if (!c) return `자격이 없습니다. \`login\` 을 부르거나 ${ENV_FILE} 에 키를 넣으세요.`;
1680
1812
  if (c.kind === 'key') {
1681
- const other = credRead();
1682
- const also = other ? `\n(로그인 자격도 있습니다: ${other.user?.email} — 키가 있어 그쪽이 안 쓰입니다.)` : '';
1683
- return `고객사 키로 붙어 있습니다. 키가 곧 고객사이므로 그 고객사에만 발행됩니다.${also}`;
1813
+ // 여기 오는 것은 **사람 자격이 없을 때뿐**이다(credential 은 이제 사람을 먼저 쥔다).
1814
+ return '소속처 키로 붙어 있습니다. 키 하나가 소속처 하나라 그곳에만 발행됩니다.\n'
1815
+ + '세션과 지식(`resume_session`·`search_cases` 등)은 서버가 키를 받지 않습니다 — '
1816
+ + '쓰시려면 `login` 으로 SSO 로그인하세요.';
1684
1817
  }
1818
+ /*
1819
+ * 사람으로 붙었는데 키도 있으면 **그 사실을 말한다.** 발행만 키로 나가므로,
1820
+ * 안 말하면 「왜 이 소속처로 발행됐지」를 사람이 짐작해야 한다.
1821
+ */
1822
+ const alsoKey = keyCred()
1823
+ ? '\n(키도 있습니다 — **발행 다섯만** 키로 나가고 나머지는 이 신원으로 갑니다.)'
1824
+ : '';
1685
1825
  const me = await api('GET', '/api/me');
1686
1826
  return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
1687
- + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}`;
1827
+ + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
1688
1828
  }
1689
1829
 
1690
1830
  case 'logout': {
@@ -1773,7 +1913,7 @@ async function callTool(name, args = {}) {
1773
1913
  const body = { ...data, tenant: args.tenant, slug: args.slug, publish };
1774
1914
  if (args.published_at) body.published_at = args.published_at;
1775
1915
  if (args.dry_run) body.dry_run = true;
1776
- const r = await api('POST', '/api/reports', body);
1916
+ const r = await api('POST', '/api/reports', body, PUBLISH);
1777
1917
  if (r.dry_run) {
1778
1918
  return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
1779
1919
  + `${r.tenant} · ${r.title} (건 ${r.cases}) · 저장하면 ${r.would_publish ? '바로 공개' : '초안'}`
@@ -1790,20 +1930,20 @@ async function callTool(name, args = {}) {
1790
1930
  case 'release_report': {
1791
1931
  const body = { tenant: args.tenant };
1792
1932
  if (args.published_at) body.published_at = args.published_at;
1793
- const r = await api('POST', `/api/reports/${Number(args.id)}/publish`, body);
1933
+ const r = await api('POST', `/api/reports/${Number(args.id)}/publish`, body, PUBLISH);
1794
1934
  return (r.changed ? '열었습니다 — 고객이 지금부터 볼 수 있습니다.' : '이미 열려 있습니다.')
1795
1935
  + `\n${r.url}`;
1796
1936
  }
1797
1937
  case 'unpublish_report': {
1798
1938
  const body = { tenant: args.tenant };
1799
1939
  if (args.reason) body.reason = args.reason;
1800
- const r = await api('POST', `/api/reports/${Number(args.id)}/unpublish`, body);
1940
+ const r = await api('POST', `/api/reports/${Number(args.id)}/unpublish`, body, PUBLISH);
1801
1941
  return (r.changed
1802
1942
  ? `내렸습니다 — 고객 목록과 열람에서 빠졌습니다(문서와 이력은 그대로).`
1803
1943
  : '이미 내려가 있습니다.') + `\n다시 열려면 release_report(id: ${Number(args.id)}).`;
1804
1944
  }
1805
1945
  case 'report_assets': {
1806
- const r = await api('GET', `/api/reports/${Number(args.id)}/assets`);
1946
+ const r = await api('GET', `/api/reports/${Number(args.id)}/assets`, undefined, PUBLISH);
1807
1947
  if (!r.total) return '이 문서에는 서버가 채울 그림이 없습니다.';
1808
1948
  const mark = { done: '도착', failed: '실패', queued: '그리는 중', waiting: '차례 기다림' };
1809
1949
  const lines = (r.slots || []).map((s) =>
@@ -1815,7 +1955,7 @@ async function callTool(name, args = {}) {
1815
1955
  const data = await materializeAssets(args.data);
1816
1956
  const payload = { ...data, tenant: args.tenant };
1817
1957
  if (args.dry_run) payload.dry_run = true;
1818
- const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload);
1958
+ const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, PUBLISH);
1819
1959
  if (r.dry_run) {
1820
1960
  return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
1821
1961
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",