genetics-mcp 2.9.3 → 2.9.5

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.
Binary file
package/bin/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "aarch64-apple-darwin": "221ace8f89b6dc3f28879c02c2ca0a324ec2a9ba5ba3c295305e8b2d43d1a396",
3
- "aarch64-unknown-linux-musl": "168621db08da7e63cc00dafe8847f65cba7db61ccbef61daa9d7322fd6b717e2",
4
- "x86_64-apple-darwin": "28eb940e42721bba6d83dde101be4bbe39bfb12250e5bc45bde7db15810378e9",
5
- "x86_64-unknown-linux-musl": "f221f2087e5f29c2a485e27b41a1c1bf6ddebcd62963952cc5e066cca04da110"
2
+ "aarch64-apple-darwin": "606a0641d503fd1ae929dc5f1dac08dac1b5ef7264a03f5b31ba0c3997287750",
3
+ "aarch64-unknown-linux-musl": "90f09361d90b4aac6d44faf549e3ffa2770b29a01f582307c6c8e705a1e8796a",
4
+ "x86_64-apple-darwin": "7858dbc8d974f68c73ebaf73b2886356899e44224f389e9fab030f15dcb02c08",
5
+ "x86_64-unknown-linux-musl": "813d13fc8d61b032a61dff03a3ac2f7b09816f70624025a8ccd0aa9dbdfc0f7a"
6
6
  }
Binary file
Binary file
package/genetics.mjs CHANGED
@@ -2047,9 +2047,10 @@ const TOOLS = [
2047
2047
  name: 'list_reports',
2048
2048
  description:
2049
2049
  '내가 닿을 수 있는 문서 목록(사람으로 붙으면 소속처 전부, 키면 그 한 곳). 각 줄의 대괄호가 **문서 상태**이고, ' +
2050
- '무엇으로 만들어졌는지·API 로 읽고 쓸 수 있는지를 서버가 판정해 준다. ' +
2051
- '편집기가 정본인 문서는 API 로 못 고친다 — 사람이 편집 화면에서 고쳐야 한다. ' +
2052
- '응답 끝의 다음 page 안내를 따라가면 100건 뒤의 문서도 볼 수 있다.',
2050
+ '무엇으로 만들어졌는지를 서버가 판정해 준다. ' +
2051
+ '**사람이 편집기로 고친 문서도 API 로 고칠 수 있다**(2026-08-25 거절 제거) — 대신 줄에 ' +
2052
+ '「덮음: …」이 붙어 **무엇을 덮게 되는지 미리** 말해 준다. 덮으면 덮기 직전 판이 원장에 남고 ' +
2053
+ '되돌릴 수 있다. 응답 끝의 다음 page 안내를 따라가면 100건 뒤의 문서도 볼 수 있다.',
2053
2054
  inputSchema: {
2054
2055
  type: 'object',
2055
2056
  properties: {
@@ -2067,9 +2068,12 @@ const TOOLS = [
2067
2068
  {
2068
2069
  name: 'get_report',
2069
2070
  description:
2070
- '발행에 쓰인 데이터를 그대로 받아온다. 이걸 고쳐 update_report 로 되돌려 보내면 수정 발행이 된다 — ' +
2071
- '**단 편집기가 정본인 문서는 읽을 수는 있어도 되돌려 보낼 수 없다.** ' +
2072
- '목록의 can_write 가 그 답이고, 거절될 때는 응답에 편집 화면 주소가 실려 온다.',
2071
+ '발행에 쓰인 데이터를 그대로 받아온다. 이걸 고쳐 update_report 로 되돌려 보내면 수정 발행이 된다. ' +
2072
+ '**발행 데이터가 없는 문서(편집기 일반 문서·업로드본)도 열린다** — 그때는 data 가 null 이고, ' +
2073
+ '왜 없는지(note)와 지금 고객이 보는 것의 요약(outline)이 대신 실린다. ' +
2074
+ '어느 경우든 응답 **머리 두 줄**에 정본이 어느 쪽인지(JSON / 발행물)와 본문 판번호(body_rev)가 ' +
2075
+ '붙는다 — 정본이 발행물이면 아래 data 는 **낡은 기록**이라 그대로 되돌려 보내면 사람이 고친 것을 덮는다. ' +
2076
+ '덮어쓰기 전에 **무엇을 덮는지 볼 수 있어야 하므로** 읽기는 막지 않는다.',
2073
2077
  inputSchema: {
2074
2078
  type: 'object',
2075
2079
  properties: {
@@ -2269,6 +2273,25 @@ const TOOLS = [
2269
2273
  required: ['tenant', 'path', 'verdict'],
2270
2274
  },
2271
2275
  },
2276
+ {
2277
+ name: 'review_verdict',
2278
+ description:
2279
+ '**두뇌가 보낸 검토(백신 메일: 「충돌 — …」「중복 — …」「문서 필요 — …」)에 대한 이 세션의 판정을 적는다** — '
2280
+ + '「그 지적대로 고쳤다(applied) / 그 지적이 틀렸다(refuted)」. 그 메일을 읽고 실제로 고쳤거나 근거를 보고 틀렸다고 '
2281
+ + '확인했을 때만 부른다. 이것은 세션 판정이라 **이 세션에게 같은 검토를 다시 놓지 않는 데만** 쓰이고, 두뇌의 '
2282
+ + '학습(굽기)에는 사람이 지식 화면에서 닫은 판정만 실린다. title 은 메일 제목의 「— 」 뒤를 그대로.',
2283
+ inputSchema: {
2284
+ type: 'object',
2285
+ properties: {
2286
+ tenant: { type: 'string', description: '대상 소속처 이름.' },
2287
+ title: { type: 'string', description: '검토 제목 — 메일 제목 「충돌 — <제목>」의 <제목> 그대로.' },
2288
+ verdict: { type: 'string', enum: ['applied', 'refuted'], description: '이 세션의 판정.' },
2289
+ type: { type: 'string', enum: ['contradiction', 'duplicate', 'missing-page'], description: '검토 종류(선택 — 충돌=contradiction·중복=duplicate·문서 필요=missing-page). 비우면 서버가 셋 중 있는 것을 고른다.' },
2290
+ note: { type: 'string', description: '한 줄 근거(선택, 200자).' },
2291
+ },
2292
+ required: ['tenant', 'title', 'verdict'],
2293
+ },
2294
+ },
2272
2295
  {
2273
2296
  name: 'list_sessions',
2274
2297
  description:
@@ -2428,6 +2451,24 @@ function warnText(r) {
2428
2451
  : '';
2429
2452
  }
2430
2453
 
2454
+ /* 발행물의 **구조**를 사람이 읽는 줄로 옮긴다.
2455
+ * 두 곳이 쓴다 — `get_report(outline: true)` 와, **데이터가 없는 문서의 기본 조회**다.
2456
+ * 후자는 서버가 outline 을 알아서 실어 주는데(그것이 지금 고객이 보는 것의 유일한 요약),
2457
+ * 예전에는 도구가 `data` 만 돌려주느라 그 요약을 통째로 버렸다. 같은 말을 두 곳에서
2458
+ * 따로 빚으면 언젠가 갈리므로 여기 하나로 둔다. */
2459
+ function outlineText(outline) {
2460
+ const o = outline || {};
2461
+ const c = o.counts || {};
2462
+ const lines = (o.blocks || []).map((b) =>
2463
+ ` ${b.tag}.${b.class}${b.side ? ` [${b.side}]` : ''}${b.text ? ` — ${b.text}` : ''}`);
2464
+ return `발행물 구조 — 절 ${c.sections ?? '?'} · 건 ${c.cases ?? '?'} · 장 ${c.chapters ?? '?'}`
2465
+ + ` · 그림 ${c.images ?? '?'}(표지마크 ${c.cover_mark ?? 0})`
2466
+ + ` · 좌우 ${c.side_l ?? 0}/${c.side_r ?? 0}`
2467
+ + ` · 도해 ${c.logic ?? 0} · 원칙카드 ${c.principles ?? 0}\n`
2468
+ + lines.join('\n')
2469
+ + (o.empty?.length ? `\n\n⚠ 빈 블록 ${o.empty.length}건: ${o.empty.join(', ')}` : '\n\n빈 블록 없음');
2470
+ }
2471
+
2431
2472
  /* 그림이 아직 오는 중이라는 사실을 **발행 응답에 붙인다.**
2432
2473
  * 안 붙이면 에이전트는 그림 없는 문서를 받아 들고 「왜 빠졌지」에서 멈추고,
2433
2474
  * 대개 그 자리에서 다시 발행한다 — 그러면 방금 시작한 생성이 통째로 버려진다. */
@@ -2781,8 +2822,8 @@ function brainSays() {
2781
2822
  const MAIL_POLL_MS = 60_000; // 사람이 기다린다 — 백신(120초)보다 자주 본다
2782
2823
  const MAIL_PAGE = 20;
2783
2824
  let mailAt = 0;
2784
- let mailCursor = null; // null = 아직 색인에서 안 읽었다
2785
- let mailAddrAt = 0; // 마지막으로 주소를 댄 때
2825
+ const mailCursors = new Map(); // tenant → 마지막 커서 (소속처마다 따로 — 한 세션이 여러 곳에 주소를 댄다)
2826
+ const mailAddrAt = new Map(); // tenant → 마지막으로 주소를 댄 때
2786
2827
  const mailQueue = []; // 아직 안 말한 통(서버가 렌더한 글자를 그대로 싣는다)
2787
2828
  const mailBack = []; // 내가 부쳤다 만료된 것
2788
2829
  const mailBackSeen = new Set();
@@ -2877,17 +2918,26 @@ function myAgentName() {
2877
2918
  * 0f 의 답장이 꼬리에 영영 안 실렸다. 색인이 없으면 mail 도구가 마지막으로 쓴 소속처를
2878
2919
  * 힌트로 쓴다(그 소속처에 주소를 댔으니 받을 곳도 거기다).
2879
2920
  */
2880
- let mailTenantHint = null; // { tenant, tenant_id } — mail 도구가 성공할 때 적는다
2921
+ /*
2922
+ * ⚠⚠ **받기는 「주소를 댄 소속처 전부」를 돈다** (2026-08-25 app-4a 실측 — 0a8c9d3 의 남은 구멍).
2923
+ * 0a8c9d3 은 힌트를 「색인이 비었을 때」의 폴백으로만 뒀다. 그런데 색인에 **다른** 소속처가
2924
+ * 박힌 세션(app-4a: 색인=앳플리, mail 도구=Lim 데모)은 힌트가 영영 안 먹고, 폴은 색인 쪽만
2925
+ * 봐서 **보낸 곳과 받는 곳이 갈렸다** — 「보낼 수 있는데 받지 못한다」의 두 번째 갈래.
2926
+ * 게다가 mailPull 이 소속처 하나만 조회했다(한 세션이 여러 곳에 주소를 댈 수 있는데).
2927
+ * 처방: 색인의 소속처 ∪ 이 프로세스에서 mail 도구가 성공한 소속처 전부를 **매 바퀴 다** 돈다.
2928
+ * 폴백이 아니라 합집합이다 — 우선순위를 두면 언젠가 또 한쪽이 조용히 진다.
2929
+ */
2930
+ const mailTenants = new Map(); // tenant 이름 → tenant_id(모르면 0). mail 도구가 성공할 때 적는다
2881
2931
  function mailReady() {
2882
2932
  const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
2883
2933
  if (!mine) return null;
2884
- let e = indexRead()[mine];
2885
- if (!e?.tenant && mailTenantHint) e = { ...(e || {}), ...mailTenantHint };
2886
- if (!e?.tenant) return null; // 아직 어느 소속처인지 모른다 — 받기엔 이름이면 족하다
2887
2934
  const cred = credential();
2888
- // 세계 장부와 같다 — 서버가 사람 토큰만 받는다.
2889
- if (!cred || cred.kind !== 'user') return null;
2890
- return { mine, e };
2935
+ if (!cred || cred.kind !== 'user') return null; // 세계 장부와 같다 — 서버가 사람 토큰만 받는다
2936
+ const e = indexRead()[mine];
2937
+ const set = new Map(mailTenants);
2938
+ if (e?.tenant && !set.has(e.tenant)) set.set(e.tenant, Number(e.tenant_id) || 0);
2939
+ if (!set.size) return null; // 아직 어느 소속처에도 주소를 안 댔다
2940
+ return { mine, tenants: set, indexTenant: e?.tenant || '' , indexEntry: e };
2891
2941
  }
2892
2942
 
2893
2943
  /**
@@ -2896,10 +2946,10 @@ function mailReady() {
2896
2946
  * 이름이 없어도 등록한다 — 받을 수는 있어야 하기 때문이다. 이름이 생기면 다시 댄다.
2897
2947
  */
2898
2948
  async function mailRegister(tenant, mine, force = false) {
2899
- if (!force && Date.now() - mailAddrAt < MAIL_POLL_MS) return;
2900
- mailAddrAt = Date.now();
2949
+ if (!force && Date.now() - (mailAddrAt.get(tenant) || 0) < MAIL_POLL_MS) return null;
2950
+ mailAddrAt.set(tenant, Date.now());
2901
2951
  const name = myAgentName();
2902
- await api('POST', '/api/mail/register', {
2952
+ return await api('POST', '/api/mail/register', {
2903
2953
  tenant,
2904
2954
  session: mine,
2905
2955
  name: name || '',
@@ -2918,32 +2968,42 @@ async function mailRegister(tenant, mine, force = false) {
2918
2968
  async function mailPull() {
2919
2969
  const r0 = mailReady();
2920
2970
  if (!r0) return;
2921
- const { mine, e } = r0;
2971
+ const { mine, tenants, indexTenant, indexEntry } = r0;
2922
2972
  if (Date.now() - mailAt < MAIL_POLL_MS) return;
2923
2973
  mailAt = Date.now();
2924
2974
 
2925
- try { await mailRegister(e.tenant, mine); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
2975
+ for (const [tenant] of tenants) {
2976
+ try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null); }
2977
+ catch { /* 한 소속처가 막혀도 나머지는 돈다 — 다음 바퀴에 다시 */ }
2978
+ }
2979
+ }
2980
+
2981
+ /** 한 소속처의 내 편지함을 긷고 **받았다고 도장을 찍는다**(도장은 큐에 넣기 **전에** — 재점화 방지). */
2982
+ async function mailPullOne(tenant, mine, indexEntry) {
2983
+ try { await mailRegister(tenant, mine); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
2926
2984
 
2927
- if (mailCursor === null) mailCursor = Number(e.mail_cursor || 0);
2985
+ if (!mailCursors.has(tenant)) mailCursors.set(tenant, Number(indexEntry?.mail_cursor || 0));
2986
+ let cursor = mailCursors.get(tenant);
2928
2987
  /*
2929
2988
  * ⚠ **자리(`cwd`)를 안 보낸다.** 창구가 그것으로 거르던 동안 주소 있는 메일이 조용히
2930
2989
  * 사라졌다(실측 2026-08-25: cwd 없이 1통 · cwd 있게 0통). 메일은 폴더가 아니라
2931
2990
  * 세션에 부친 것이라 **주소가 이미 필터**다.
2932
2991
  */
2933
- const q = '?tenant=' + encodeURIComponent(e.tenant)
2934
- + '&since=' + mailCursor
2992
+ const q = '?tenant=' + encodeURIComponent(tenant)
2993
+ + '&since=' + cursor
2935
2994
  + '&session=' + encodeURIComponent(mine)
2936
2995
  + '&limit=' + MAIL_PAGE;
2937
2996
  const r = await api('GET', '/api/mail' + q);
2938
2997
 
2939
2998
  const rows = Array.isArray(r?.mail) ? r.mail : [];
2940
- if (typeof r?.cursor === 'number' && r.cursor > mailCursor) mailCursor = r.cursor;
2941
- indexNote(mine, { tenant: e.tenant, tenant_id: e.tenant_id, mail_cursor: mailCursor });
2999
+ if (typeof r?.cursor === 'number' && r.cursor > cursor) { cursor = r.cursor; mailCursors.set(tenant, cursor); }
3000
+ // 색인에는 색인의 소속처 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
3001
+ if (indexEntry?.tenant_id) indexNote(mine, { tenant, tenant_id: indexEntry.tenant_id, mail_cursor: cursor });
2942
3002
 
2943
3003
  if (rows.length) {
2944
3004
  const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
2945
3005
  if (ids.length) {
2946
- try { await api('POST', '/api/mail/ack', { tenant: e.tenant, ids }); }
3006
+ try { await api('POST', '/api/mail/ack', { tenant, ids }); }
2947
3007
  catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
2948
3008
  }
2949
3009
  /*
@@ -3199,7 +3259,20 @@ async function callTool(name, args = {}) {
3199
3259
  const bits = [];
3200
3260
  if (x.state_label) bits.push(x.state_label);
3201
3261
  else if (x.editable === false) bits.push('데이터 없음');
3202
- if (x.can_write === false) bits.push('API 로 수정 불가');
3262
+ /*
3263
+ * ⚠ 전에는 여기서 `can_write === false` 를 「API 로 수정 불가」로 찍었다. 그런데
3264
+ * 서버는 2026-08-25 거절 제거 뒤 **그 칸을 아예 안 보낸다**(늘 참이라 죽은 칸이었다).
3265
+ * 그래서 이 가지는 **한 번도 안 찍히면서** 도구 설명만 옛 정책을 말하고 있었다.
3266
+ * 지금 서버가 주는 것은 「이 문서를 API 로 고치면 **무엇을 덮게 되는가**」다.
3267
+ */
3268
+ const OVERWRITES = {
3269
+ 'human-draft': '덮음: 사람의 편집 초안',
3270
+ 'published-html': '덮음: 사람이 발행한 본문',
3271
+ both: '덮음: 초안 + 발행 본문',
3272
+ };
3273
+ if (x.overwrites && x.overwrites !== 'none') bits.push(OVERWRITES[x.overwrites] || `덮음: ${x.overwrites}`);
3274
+ // 옛 서버와도 물린다 — 그쪽은 아직 거절하던 판이다.
3275
+ else if (x.can_write === false) bits.push('API 로 수정 불가(옛 서버)');
3203
3276
  const tag = bits.length ? ` [${bits.join(' · ')}]` : '';
3204
3277
  // slug 를 싣는다. 중복 slug 는 409 로 잘 막히지만 **미리 알 수가 없었다** —
3205
3278
  // 목록에 이름표가 없어 새 이름을 고를 때 참고할 것이 없었다(2026-08-18 검토).
@@ -3240,19 +3313,30 @@ async function callTool(name, args = {}) {
3240
3313
  case 'get_report': {
3241
3314
  const q = args.outline ? '?outline=1' : '';
3242
3315
  const r = await api('GET', `/api/reports/${Number(args.id)}${q}`);
3243
- if (!args.outline) return JSON.stringify(r.data, null, 2);
3316
+ if (!args.outline) {
3317
+ /*
3318
+ * ⚠ 전에는 여기서 **`r.data` 만** 돌려줬다. 서버가 정성껏 붙인 것이 이 한 줄에서
3319
+ * 전부 사라졌다 — 데이터가 없는 문서는 그냥 `null` 이 왔고(왜 없는지 안 말한다),
3320
+ * 정본이 발행물로 넘어간 문서는 **「아래 data 는 낡았다」는 경고**를 잃었고,
3321
+ * 낙관 잠금에 쓸 `body_rev` 도 안 왔다. 도구 설명에는 「실린다」고 적혀 있었다 —
3322
+ * **설명이 코드보다 앞서 있었다**(2026-08-26 실측).
3323
+ *
3324
+ * 응답 **모양은 안 바꾼다**(본문은 여전히 `data` 그대로다). 대신 그 앞에 머리를
3325
+ * 두 줄 붙인다 — 읽는 쪽이 첫 줄에서 못 보고 지나칠 수 없게.
3326
+ */
3327
+ const head = [];
3328
+ if (r.note) head.push(`⚠ ${r.note}`);
3329
+ const owner = r.body_owner === 'html' ? '발행물(HTML)' : 'JSON';
3330
+ head.push(`[정본 ${owner} · 본문 판 body_rev=${r.body_rev ?? '?'}`
3331
+ + (r.sha256 ? ` · 지금 발행물 ${r.bytes}B sha256:${String(r.sha256).slice(0, 12)}…` : '')
3332
+ + `] — 고칠 때는 update_report 에 body_rev 를 함께 실으면, 그 사이 누가 고쳤을 때 409 로 멈춘다.`);
3333
+ // 데이터가 없으면 서버가 outline 을 함께 준다 — 그것이 지금 고객이 보는 것의 유일한 요약이다.
3334
+ if (r.data === null && r.outline) head.push(outlineText(r.outline));
3335
+ return `${head.join('\n')}\n${JSON.stringify(r.data, null, 2)}`;
3336
+ }
3244
3337
  // 구조만 본다 — 데이터는 이미 다른 호출로 받을 수 있고, 둘을 같이 실으면
3245
- // 문맥이 두 배가 된다.
3246
- const o = r.outline || {};
3247
- const c = o.counts || {};
3248
- const lines = (o.blocks || []).map((b) =>
3249
- ` ${b.tag}.${b.class}${b.side ? ` [${b.side}]` : ''}${b.text ? ` — ${b.text}` : ''}`);
3250
- return `발행물 구조 — 절 ${c.sections ?? '?'} · 건 ${c.cases ?? '?'} · 장 ${c.chapters ?? '?'}`
3251
- + ` · 그림 ${c.images ?? '?'}(표지마크 ${c.cover_mark ?? 0})`
3252
- + ` · 좌우 ${c.side_l ?? 0}/${c.side_r ?? 0}`
3253
- + ` · 도해 ${c.logic ?? 0} · 원칙카드 ${c.principles ?? 0}\n`
3254
- + lines.join('\n')
3255
- + (o.empty?.length ? `\n\n⚠ 빈 블록 ${o.empty.length}건: ${o.empty.join(', ')}` : '\n\n빈 블록 없음');
3338
+ // 문맥이 두 배가 된다. (같은 말을 데이터 없는 문서에도 쓰므로 함수로 뺐다.)
3339
+ return outlineText(r.outline);
3256
3340
  }
3257
3341
  case 'publish_report': {
3258
3342
  const data = await materializeAssets(args.data);
@@ -3392,6 +3476,14 @@ async function callTool(name, args = {}) {
3392
3476
  return `${r.says || '판정을 적었다.'}\n`
3393
3477
  + '(세션이 중계한 판정이다 — 사람이 지식 화면에서 누른 판정만 도구 직전 알림·검색에 반영된다.)';
3394
3478
  }
3479
+ case 'review_verdict': {
3480
+ const r = await api('POST', '/api/wiki/review_verdict?tenant=' + encodeURIComponent(args.tenant), {
3481
+ title: args.title, verdict: args.verdict, type: args.type || '', note: args.note || '',
3482
+ ...myCitation(), // 세션 uuid 는 우리가 채운다 — 없으면 서버가 안 적는다
3483
+ });
3484
+ return `${r.says || '판정을 적었다.'}\n`
3485
+ + '(세션 판정이다 — 이 세션에게 같은 검토를 다시 놓지 않는 데만 쓰인다. 두뇌의 기억에는 사람이 화면에서 닫은 판정만 실린다.)';
3486
+ }
3395
3487
  case 'list_sessions': {
3396
3488
  const query = new URLSearchParams({ tenant: String(args.tenant) });
3397
3489
  if (args.page !== undefined) query.set('page', String(args.page));
@@ -3574,8 +3666,18 @@ async function callTool(name, args = {}) {
3574
3666
  + '이름은 `ListAgents` 를 한 번 부르면 생긴다 — 하네스가 「This session is …」를 '
3575
3667
  + '전사에 적고, 이 도구가 그것을 읽는다. 부른 뒤 다시 보내면 된다.';
3576
3668
  }
3577
- try { await mailRegister(args.tenant, mine, true); }
3669
+ let reg;
3670
+ try { reg = await mailRegister(args.tenant, mine, true); }
3578
3671
  catch (e) { return `주소를 대지 못했다 — ${e.message}`; }
3672
+ /*
3673
+ * ⚠ 이름을 **남이 쥐고 있으면** 보내기가 409(이름 없음)로 막힌다 — 서버 오류문만 보면
3674
+ * 무엇을 해야 하는지 모른다. 창구가 준 사실(held_by)을 여기서 사람 말로 앞세운다.
3675
+ * (하네스가 짧은 이름을 재사용한다 — 2026-08-25 운영 실측, app-4a 둘.)
3676
+ */
3677
+ // 이름을 남이 쥐고 있어도 **보낸다** — 발신자 표시는 서버가 uuid 앞 8자로 세운다(침묵 금지).
3678
+ const heldNote = reg?.held_by
3679
+ ? `(이 세션의 이름은 살아 있는 다른 세션 ${String(reg.held_by).slice(0, 8)} 이 쥐고 있어 이 통은 «${mine.slice(0, 8)}» 이름으로 나간다 — 남이 이 세션에 닿으려면 uuid ${mine} 로.)\n`
3680
+ : '';
3579
3681
 
3580
3682
  const r = await api('POST', '/api/mail', {
3581
3683
  tenant: args.tenant,
@@ -3592,9 +3694,9 @@ async function callTool(name, args = {}) {
3592
3694
  // ★ 받을 곳을 기억한다 — 색인 밖 세션도 이 소속처에서 받는다(mailReady 힌트 + 색인).
3593
3695
  // ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
3594
3696
  // GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
3595
- mailTenantHint = { tenant: args.tenant, tenant_id: r?.tenant_id || 0 };
3596
- if (r?.tenant_id) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id });
3597
- return `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}번째.\n`
3697
+ mailTenants.set(args.tenant, r?.tenant_id || 0); // 이 소속처에서도 받는다 — 합집합, 폴백 아님
3698
+ if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id });
3699
+ return heldNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}번째.\n`
3598
3700
  + `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
3599
3701
  + (r.says ? `${r.says}\n` : '')
3600
3702
  + '답이 오면 다른 도구를 부를 때 응답 꼬리에 저절로 실린다 — 따로 확인할 필요 없다.';
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.9.3",
3
+ "version": "2.9.5",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "type": "module",
8
8
  "bin": {
9
- "genetics-mcp": "./genetics.mjs"
9
+ "genetics-mcp": "genetics.mjs"
10
10
  },
11
11
  "files": [
12
12
  "genetics.mjs",
@@ -157,9 +157,22 @@ mail({tenant: "<소속처>", to: "brain", body: "…"}) # 기계를 넘어
157
157
 
158
158
  받은 것에 답할 때 `SendMessage` 는 그 메시지의 `from` 을 그대로 `to` 에 쓰고, `mail` 은 `thread` 를 씁니다.
159
159
 
160
- `mail` 의 `to` 에 올 수 있는 것은 **셋뿐**입니다 — `gcell agents` 나 `world` 가 아는 **세포 이름**,
161
- **세션 uuid**, 또는 두뇌를 뜻하는 **`brain`**. **지어내지 마세요.** 셋 중 하나가 아니면 사용자에게
162
- 묻습니다. 소속처 이름을 모르면 `list_my_tenants` 로 먼저 확인합니다.
160
+ `mail` 의 `to` 에 올 수 있는 것은 **셋뿐**입니다. **지어내지 마세요** — 셋 중 하나가 아니면
161
+ 사용자에게 묻습니다. 소속처 이름을 모르면 `list_my_tenants` 로 먼저 확인합니다.
162
+
163
+ | `to` | 언제 쓰나 |
164
+ |---|---|
165
+ | **세션 uuid** | **확실히 닿아야 할 때.** 하네스가 재사용하지 않는 유일한 열쇠입니다 |
166
+ | 세포 이름 (`app-2f`) | 이 기계 안에서 사람이 부를 때. **불안정한 별칭입니다 — 아래** |
167
+ | `brain` | 두뇌에게. 주소가 필요 없습니다 |
168
+
169
+ **이름은 유일하지 않습니다.** 하네스가 짧은 이름을 프로젝트·시간을 넘어 재사용하므로 **같은
170
+ 이름을 쥔 산 세션이 따로 있을 수 있습니다.** 그러면 나중에 등록하는 쪽은 이름 없이(받기만) 서고,
171
+ 그 이름으로 부친 통은 **먼저 쥔 쪽에게 갑니다.**
172
+
173
+ 2026-08-25 실측 — 이름 접미가 2자리 16진(256가지)인데 한 기계에 세션 13개가 돌아
174
+ 충돌 확률이 **26.6%** 입니다. 실제로 났습니다: 이름으로 부친 통(실 `bc3b4f59`)은 39초 만에
175
+ **동명이인에게** 배달됐고, uuid 로 부친 통(실 `9003652b`)은 47초 만에 제자리에 닿았습니다.
163
176
 
164
177
  받는 것은 부를 필요가 없습니다 — 온 것은 **다른 도구를 부를 때 응답 꼬리에 저절로 실립니다.**
165
178
 
@@ -193,6 +206,8 @@ mail({tenant: "<소속처>", to: "brain", body: "…"}) # 기계를 넘어
193
206
  | 세포는 이 기계만 본다 | 다른 기계를 **보는** 것은 `world`, **말 거는** 것은 `mail` |
194
207
  | `SendMessage` 로 다른 기계에 보냈다 | **안 간다.** 그건 이 기계 전용이다 — `mail` 을 쓴다 |
195
208
  | 왕복이 길어진다 | 상한이 있다 — **사람이 낀 실 5회, 기계끼리 3회.** 넘으면 사람에게 올라간다 |
209
+ | 이름으로 부쳤는데 딴 세션이 받았다 | 같은 이름의 **산 세션**이 있다. 이름은 별칭이니 **uuid** 로 부친다 |
210
+ | 보냈는데 답이 없다 | 받는 쪽 꼬리는 **그쪽이 도구를 불러야** 붙는다 — 폴 60초 + 그 간격이다(실측 47·39초) |
196
211
  | `status` 에 내 줄이 표시되지 않는다 | 내 uuid 는 전사 파일 이름에서 얻는다 |
197
212
 
198
213
  ## 무엇을 하지 않는가