genetics-mcp 2.6.0 → 2.7.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.
Files changed (2) hide show
  1. package/genetics.mjs +108 -13
  2. package/package.json +2 -2
package/genetics.mjs CHANGED
@@ -1520,7 +1520,9 @@ async function beatTick() {
1520
1520
  * 비용: 저장소를 아는 걸음으로 바꿔 **1,436ms → 320ms**(실측). 그리고 세포가 저장소마다
1521
1521
  * 자물쇠를 잡으므로, MCP 가 세션 수만큼 떠 있어도 **한 번만** 일한다.
1522
1522
  */
1523
- const OBSERVE_EVERY_MS = 20_000;
1523
+ // 시험은 20초를 못 기다린다. **갈아 끼울 수 있게** 둔다(`GCELL_STATE_DIR` 과 같은 규율) —
1524
+ // 다만 하한을 둬서 실수로 0.01초가 들어가 기계를 태우는 일은 막는다.
1525
+ const OBSERVE_EVERY_MS = Math.max(200, Number(cfg.GENETICS_OBSERVE_MS) || 20_000);
1524
1526
  let observeTimer = null;
1525
1527
 
1526
1528
  function observeTick() {
@@ -1534,6 +1536,8 @@ function observeTick() {
1534
1536
  c.unref?.();
1535
1537
  c.on?.('error', () => {});
1536
1538
  } catch { /* 최선만 다한다 */ }
1539
+ // 세포가 방금 갱신했을 사실을 보고, 바뀌었으면 알린다(한 tick 늦는다 — 자식이 비동기다).
1540
+ if (SEND) notifyToolsChanged(SEND);
1537
1541
  }
1538
1542
 
1539
1543
  function startObserve() {
@@ -1562,6 +1566,19 @@ async function stopBeat() {
1562
1566
  }
1563
1567
 
1564
1568
  const TOOLS = [
1569
+ /*
1570
+ * ★ **이 도구는 부르지 않아도 일한다.** 설명 자체가 세계 사실을 실어 나른다(`toolsNow()`).
1571
+ * 도구 목록은 매 턴 모델 문맥에 있으므로, 부르기 전에 이미 눈앞에 놓인다.
1572
+ * 부르면 더 많이(8건) 말한다 — 설명에는 3건만 싣는다.
1573
+ */
1574
+ {
1575
+ name: 'world',
1576
+ description:
1577
+ '이 저장소에서 **다른 세션·다른 기계**가 최근에 무엇을 바꿨나. '
1578
+ + '「누가 언제 무엇을」만 말하고 무엇을 할지는 안 말한다. '
1579
+ + '고치기 전에 「내가 알고 있는 것이 아직 맞나」가 궁금하면 이것을 본다.',
1580
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
1581
+ },
1565
1582
  {
1566
1583
  name: 'login',
1567
1584
  description:
@@ -2014,21 +2031,89 @@ function cellClean(s) {
2014
2031
  .slice(0, 200);
2015
2032
  }
2016
2033
 
2017
- function worldDelta() {
2018
- const { bin, why } = cellCheck();
2019
- if (!bin) return cellNotice(why);
2034
+ /**
2035
+ * 세계 사실을 **줄 단위로**. 도구 응답 꼬리와 도구 설명이 **같은 것**을 쓰게 하려고 뺐다 —
2036
+ * 두 곳에서 따로 지으면 같은 사실을 다르게 말하게 된다.
2037
+ *
2038
+ * ⚠ **시각을 함께 말한다.** 오늘 사고에서 결정적이었던 정보가 정확히 「20:04 에」였다.
2039
+ * `gcell delta` 의 글 출력은 2026-08-23 에 고쳤는데, MCP 는 `--json` 을 **자기가 포맷**하고
2040
+ * 있어서 여기만 여전히 시각을 버리고 있었다. 같은 결함이 두 자리에 있었던 것이다.
2041
+ */
2042
+ function worldFacts(max = 8) {
2043
+ const { bin } = cellCheck();
2044
+ if (!bin) return null; // 세포가 없다 — 「없다」와 구별한다
2020
2045
  try {
2021
2046
  const r = spawnSync(bin, ['delta', '--root', process.cwd(), '--json'],
2022
2047
  { encoding: 'utf8', timeout: 800 });
2023
- if (r.error) return cellNotice('세포를 띄우지 못했다');
2024
- if (r.status !== 0 || !r.stdout) return '';
2048
+ if (r.error || r.status !== 0 || !r.stdout) return [];
2025
2049
  const d = JSON.parse(r.stdout);
2026
2050
  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('세포의 답을 못 읽었다'); }
2051
+ const out = ch.slice(0, max).map((c) => {
2052
+ const at = Number(c.at) || 0;
2053
+ const t = at > 0
2054
+ ? new Date(at * 1000).toTimeString().slice(0, 5)
2055
+ : '시각 미상';
2056
+ return `${cellClean(c.path)} — ${cellClean(c.client)} 세션이 ${t}에 썼다`;
2057
+ });
2058
+ if (ch.length > max) out.push(`그 밖 ${ch.length - max}건`);
2059
+ return out;
2060
+ } catch { return null; }
2061
+ }
2062
+
2063
+ function worldDelta() {
2064
+ const { bin, why } = cellCheck();
2065
+ if (!bin) return cellNotice(why);
2066
+ const facts = worldFacts(8);
2067
+ if (facts === null) return cellNotice('세포의 답을 못 읽었다');
2068
+ if (!facts.length) return ''; // 조용한 구간은 조용하다.
2069
+ return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
2070
+ }
2071
+
2072
+ /*
2073
+ * ★★ **도구 설명이 유일한 통로다** — 이 파일의 스모크가 2026-08-20 에 그렇게 적어 뒀고
2074
+ * (「initialize 의 instructions 는 선택 필드라 클라이언트가 안 실을 수 있다」),
2075
+ * 2026-08-23 에 Codex 로 **실증됐다**: 지침에 있는 문장을 물었더니 codex 는 문맥에서 답하지
2076
+ * 못하고 `rg` 로 저장소를 뒤져 `mcp/genetics.mjs` 를 읽어 찾았다. 즉 그 세션의 문맥에
2077
+ * instructions 가 **없었다.**
2078
+ *
2079
+ * 그런데 도구 목록은 **구조상 반드시 문맥에 있다**(없으면 모델이 도구를 못 부른다).
2080
+ * 그래서 세계 사실을 거기에 싣는다. 부르지 않아도 **눈앞에 놓인다** —
2081
+ * README 가 적어 둔 그 원칙(「검색 도구가 아니라 눈앞에 놓기」)의 유일한 이식 경로다.
2082
+ *
2083
+ * ⚠ 다른 도구의 설명은 **안 건드린다.** 발동 낱말이 곧 기능이라(스모크가 그것을 붙든다)
2084
+ * 변하는 문장을 섞으면 도구 선택이 흔들린다. 자리를 하나 따로 둔다.
2085
+ */
2086
+ /*
2087
+ * ★ **세션 도중에도 갱신한다.** 위 `toolsNow()` 는 `tools/list` 시점의 사실을 싣는데,
2088
+ * 그 시점은 사실상 **세션 시작**이다. 그러면 「자리를 비운 사이」는 덮어도
2089
+ * 「지금 막 일어난 일」은 못 덮는다 — 그리고 오늘 사고가 정확히 후자였다
2090
+ * (내가 일하는 동안 옆 세션이 배포했다).
2091
+ *
2092
+ * codex 가 `notifications/tools/list_changed` 를 안다(바이너리 7회). 사실이 **바뀌었을 때만**
2093
+ * 보낸다 — 매 tick 보내면 클라이언트가 매번 다시 묻고, 그건 소음이다.
2094
+ */
2095
+ let toolsSig = null;
2096
+ let toolsNotifyReady = false; // initialize 전에는 안 보낸다
2097
+ // stdout 에 쓰는 문은 **하나**다(`send`). 그 함수가 아래 범위에 있어 여기로 이어 둔다 —
2098
+ // 두 곳에서 각자 쓰면 응답 한가운데에 알림이 끼어 프로토콜이 깨진다.
2099
+ let SEND = null;
2100
+
2101
+ function notifyToolsChanged(sendFn) {
2102
+ const facts = worldFacts(3);
2103
+ const sig = facts === null ? null : facts.join('|');
2104
+ if (sig === null) return; // 세포를 못 읽었다 — 지어내지 않는다
2105
+ if (toolsSig === null) { toolsSig = sig; return; } // 첫 관측은 기준선일 뿐이다
2106
+ if (sig === toolsSig) return; // 조용한 구간은 조용하다
2107
+ toolsSig = sig;
2108
+ if (!toolsNotifyReady) return;
2109
+ try { sendFn({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); } catch { /* 최선만 */ }
2110
+ }
2111
+
2112
+ function toolsNow() {
2113
+ const facts = worldFacts(3);
2114
+ if (!facts || !facts.length) return TOOLS; // 조용한 구간은 조용하다
2115
+ const head = `\n\n지금 이 저장소:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
2116
+ return TOOLS.map((t) => (t.name === 'world' ? { ...t, description: t.description + head } : t));
2032
2117
  }
2033
2118
 
2034
2119
  /**
@@ -2309,6 +2394,12 @@ async function callTool(name, args = {}) {
2309
2394
  return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}`
2310
2395
  + pendingText(r) + warnText(r);
2311
2396
  }
2397
+ case 'world': {
2398
+ const facts = worldFacts(8);
2399
+ if (facts === null) return cellNotice('세포의 답을 못 읽었다');
2400
+ if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
2401
+ return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
2402
+ }
2312
2403
  case 'remember_case': {
2313
2404
  const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
2314
2405
  title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
@@ -2516,6 +2607,7 @@ process.stdout.on('error', (e) => {
2516
2607
  function send(msg) {
2517
2608
  process.stdout.write(JSON.stringify(msg) + '\n');
2518
2609
  }
2610
+ SEND = send;
2519
2611
 
2520
2612
  let buf = '';
2521
2613
 
@@ -2561,7 +2653,9 @@ async function handle(line) {
2561
2653
  CLIENT = req.params?.clientInfo || null;
2562
2654
  result = {
2563
2655
  protocolVersion: req.params?.protocolVersion || '2024-11-05',
2564
- capabilities: { tools: {} },
2656
+ // ★ `listChanged` 를 **선언해야** 알림을 보낼 자격이 생긴다. 명세상 안 알리고
2657
+ // 보내면 클라이언트가 버려도 할 말이 없다.
2658
+ capabilities: { tools: { listChanged: true } },
2565
2659
  /*
2566
2660
  * 자기소개 이름. **도구 이름 접두사와 다른 물건이다** — 그 접두사는 클라이언트 설정
2567
2661
  * 키(`claude mcp add --scope user <키>`)에서 나오고, 패키지는 그 값을 못 바꾼다.
@@ -2599,8 +2693,9 @@ async function handle(line) {
2599
2693
  '내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
2600
2694
  ].join('\n'),
2601
2695
  };
2696
+ toolsNotifyReady = true;
2602
2697
  } else if (req.method === 'tools/list') {
2603
- result = { tools: TOOLS };
2698
+ result = { tools: toolsNow() };
2604
2699
  } else if (req.method === 'tools/call') {
2605
2700
  const text = await callTool(req.params?.name, req.params?.arguments || {});
2606
2701
  result = { content: [{ type: 'text', text }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.6.0",
3
+ "version": "2.7.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
  }