genetics-mcp 2.6.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 +275 -17
- package/package.json +2 -2
- package/skills/genetics-report/assets/render.py +1 -1
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 (
|
|
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
|
|
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
|
// **안 바뀌었으면 안 보낸다.** 이 설계가 실제로 버는 것은 바이트가 아니라 이것이다.
|
|
@@ -1520,7 +1637,9 @@ async function beatTick() {
|
|
|
1520
1637
|
* 비용: 저장소를 아는 걸음으로 바꿔 **1,436ms → 320ms**(실측). 그리고 세포가 저장소마다
|
|
1521
1638
|
* 자물쇠를 잡으므로, MCP 가 세션 수만큼 떠 있어도 **한 번만** 일한다.
|
|
1522
1639
|
*/
|
|
1523
|
-
|
|
1640
|
+
// 시험은 20초를 못 기다린다. **갈아 끼울 수 있게** 둔다(`GCELL_STATE_DIR` 과 같은 규율) —
|
|
1641
|
+
// 다만 하한을 둬서 실수로 0.01초가 들어가 기계를 태우는 일은 막는다.
|
|
1642
|
+
const OBSERVE_EVERY_MS = Math.max(200, Number(cfg.GENETICS_OBSERVE_MS) || 20_000);
|
|
1524
1643
|
let observeTimer = null;
|
|
1525
1644
|
|
|
1526
1645
|
function observeTick() {
|
|
@@ -1534,6 +1653,8 @@ function observeTick() {
|
|
|
1534
1653
|
c.unref?.();
|
|
1535
1654
|
c.on?.('error', () => {});
|
|
1536
1655
|
} catch { /* 최선만 다한다 */ }
|
|
1656
|
+
// 세포가 방금 갱신했을 사실을 보고, 바뀌었으면 알린다(한 tick 늦는다 — 자식이 비동기다).
|
|
1657
|
+
if (SEND) notifyToolsChanged(SEND);
|
|
1537
1658
|
}
|
|
1538
1659
|
|
|
1539
1660
|
function startObserve() {
|
|
@@ -1562,6 +1683,19 @@ async function stopBeat() {
|
|
|
1562
1683
|
}
|
|
1563
1684
|
|
|
1564
1685
|
const TOOLS = [
|
|
1686
|
+
/*
|
|
1687
|
+
* ★ **이 도구는 부르지 않아도 일한다.** 설명 자체가 세계 사실을 실어 나른다(`toolsNow()`).
|
|
1688
|
+
* 도구 목록은 매 턴 모델 문맥에 있으므로, 부르기 전에 이미 눈앞에 놓인다.
|
|
1689
|
+
* 부르면 더 많이(8건) 말한다 — 설명에는 3건만 싣는다.
|
|
1690
|
+
*/
|
|
1691
|
+
{
|
|
1692
|
+
name: 'world',
|
|
1693
|
+
description:
|
|
1694
|
+
'이 저장소에서 **다른 세션·다른 기계**가 최근에 무엇을 바꿨나. '
|
|
1695
|
+
+ '「누가 언제 무엇을」만 말하고 무엇을 할지는 안 말한다. '
|
|
1696
|
+
+ '고치기 전에 「내가 알고 있는 것이 아직 맞나」가 궁금하면 이것을 본다.',
|
|
1697
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1698
|
+
},
|
|
1565
1699
|
{
|
|
1566
1700
|
name: 'login',
|
|
1567
1701
|
description:
|
|
@@ -2014,21 +2148,89 @@ function cellClean(s) {
|
|
|
2014
2148
|
.slice(0, 200);
|
|
2015
2149
|
}
|
|
2016
2150
|
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2151
|
+
/**
|
|
2152
|
+
* 세계 사실을 **줄 단위로**. 도구 응답 꼬리와 도구 설명이 **같은 것**을 쓰게 하려고 뺐다 —
|
|
2153
|
+
* 두 곳에서 따로 지으면 같은 사실을 다르게 말하게 된다.
|
|
2154
|
+
*
|
|
2155
|
+
* ⚠ **시각을 함께 말한다.** 오늘 사고에서 결정적이었던 정보가 정확히 「20:04 에」였다.
|
|
2156
|
+
* `gcell delta` 의 글 출력은 2026-08-23 에 고쳤는데, MCP 는 `--json` 을 **자기가 포맷**하고
|
|
2157
|
+
* 있어서 여기만 여전히 시각을 버리고 있었다. 같은 결함이 두 자리에 있었던 것이다.
|
|
2158
|
+
*/
|
|
2159
|
+
function worldFacts(max = 8) {
|
|
2160
|
+
const { bin } = cellCheck();
|
|
2161
|
+
if (!bin) return null; // 세포가 없다 — 「없다」와 구별한다
|
|
2020
2162
|
try {
|
|
2021
2163
|
const r = spawnSync(bin, ['delta', '--root', process.cwd(), '--json'],
|
|
2022
2164
|
{ encoding: 'utf8', timeout: 800 });
|
|
2023
|
-
if (r.error
|
|
2024
|
-
if (r.status !== 0 || !r.stdout) return '';
|
|
2165
|
+
if (r.error || r.status !== 0 || !r.stdout) return [];
|
|
2025
2166
|
const d = JSON.parse(r.stdout);
|
|
2026
2167
|
const ch = Array.isArray(d.changes) ? d.changes : [];
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2168
|
+
const out = ch.slice(0, max).map((c) => {
|
|
2169
|
+
const at = Number(c.at) || 0;
|
|
2170
|
+
const t = at > 0
|
|
2171
|
+
? new Date(at * 1000).toTimeString().slice(0, 5)
|
|
2172
|
+
: '시각 미상';
|
|
2173
|
+
return `${cellClean(c.path)} — ${cellClean(c.client)} 세션이 ${t}에 썼다`;
|
|
2174
|
+
});
|
|
2175
|
+
if (ch.length > max) out.push(`그 밖 ${ch.length - max}건`);
|
|
2176
|
+
return out;
|
|
2177
|
+
} catch { return null; }
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function worldDelta() {
|
|
2181
|
+
const { bin, why } = cellCheck();
|
|
2182
|
+
if (!bin) return cellNotice(why);
|
|
2183
|
+
const facts = worldFacts(8);
|
|
2184
|
+
if (facts === null) return cellNotice('세포의 답을 못 읽었다');
|
|
2185
|
+
if (!facts.length) return ''; // 조용한 구간은 조용하다.
|
|
2186
|
+
return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/*
|
|
2190
|
+
* ★★ **도구 설명이 유일한 통로다** — 이 파일의 스모크가 2026-08-20 에 그렇게 적어 뒀고
|
|
2191
|
+
* (「initialize 의 instructions 는 선택 필드라 클라이언트가 안 실을 수 있다」),
|
|
2192
|
+
* 2026-08-23 에 Codex 로 **실증됐다**: 지침에 있는 문장을 물었더니 codex 는 문맥에서 답하지
|
|
2193
|
+
* 못하고 `rg` 로 저장소를 뒤져 `mcp/genetics.mjs` 를 읽어 찾았다. 즉 그 세션의 문맥에
|
|
2194
|
+
* instructions 가 **없었다.**
|
|
2195
|
+
*
|
|
2196
|
+
* 그런데 도구 목록은 **구조상 반드시 문맥에 있다**(없으면 모델이 도구를 못 부른다).
|
|
2197
|
+
* 그래서 세계 사실을 거기에 싣는다. 부르지 않아도 **눈앞에 놓인다** —
|
|
2198
|
+
* README 가 적어 둔 그 원칙(「검색 도구가 아니라 눈앞에 놓기」)의 유일한 이식 경로다.
|
|
2199
|
+
*
|
|
2200
|
+
* ⚠ 다른 도구의 설명은 **안 건드린다.** 발동 낱말이 곧 기능이라(스모크가 그것을 붙든다)
|
|
2201
|
+
* 변하는 문장을 섞으면 도구 선택이 흔들린다. 자리를 하나 따로 둔다.
|
|
2202
|
+
*/
|
|
2203
|
+
/*
|
|
2204
|
+
* ★ **세션 도중에도 갱신한다.** 위 `toolsNow()` 는 `tools/list` 시점의 사실을 싣는데,
|
|
2205
|
+
* 그 시점은 사실상 **세션 시작**이다. 그러면 「자리를 비운 사이」는 덮어도
|
|
2206
|
+
* 「지금 막 일어난 일」은 못 덮는다 — 그리고 오늘 사고가 정확히 후자였다
|
|
2207
|
+
* (내가 일하는 동안 옆 세션이 배포했다).
|
|
2208
|
+
*
|
|
2209
|
+
* codex 가 `notifications/tools/list_changed` 를 안다(바이너리 7회). 사실이 **바뀌었을 때만**
|
|
2210
|
+
* 보낸다 — 매 tick 보내면 클라이언트가 매번 다시 묻고, 그건 소음이다.
|
|
2211
|
+
*/
|
|
2212
|
+
let toolsSig = null;
|
|
2213
|
+
let toolsNotifyReady = false; // initialize 전에는 안 보낸다
|
|
2214
|
+
// stdout 에 쓰는 문은 **하나**다(`send`). 그 함수가 아래 범위에 있어 여기로 이어 둔다 —
|
|
2215
|
+
// 두 곳에서 각자 쓰면 응답 한가운데에 알림이 끼어 프로토콜이 깨진다.
|
|
2216
|
+
let SEND = null;
|
|
2217
|
+
|
|
2218
|
+
function notifyToolsChanged(sendFn) {
|
|
2219
|
+
const facts = worldFacts(3);
|
|
2220
|
+
const sig = facts === null ? null : facts.join('|');
|
|
2221
|
+
if (sig === null) return; // 세포를 못 읽었다 — 지어내지 않는다
|
|
2222
|
+
if (toolsSig === null) { toolsSig = sig; return; } // 첫 관측은 기준선일 뿐이다
|
|
2223
|
+
if (sig === toolsSig) return; // 조용한 구간은 조용하다
|
|
2224
|
+
toolsSig = sig;
|
|
2225
|
+
if (!toolsNotifyReady) return;
|
|
2226
|
+
try { sendFn({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); } catch { /* 최선만 */ }
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function toolsNow() {
|
|
2230
|
+
const facts = worldFacts(3);
|
|
2231
|
+
if (!facts || !facts.length) return TOOLS; // 조용한 구간은 조용하다
|
|
2232
|
+
const head = `\n\n지금 이 저장소:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
|
|
2233
|
+
return TOOLS.map((t) => (t.name === 'world' ? { ...t, description: t.description + head } : t));
|
|
2032
2234
|
}
|
|
2033
2235
|
|
|
2034
2236
|
/**
|
|
@@ -2059,7 +2261,41 @@ function deviceLabel() {
|
|
|
2059
2261
|
try { return `${os.userInfo().username}@${os.hostname()}`; } catch { return os.hostname(); }
|
|
2060
2262
|
}
|
|
2061
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
|
+
|
|
2062
2297
|
async function callTool(name, args = {}) {
|
|
2298
|
+
requireArgs(name, args);
|
|
2063
2299
|
switch (name) {
|
|
2064
2300
|
case 'login': {
|
|
2065
2301
|
/*
|
|
@@ -2123,8 +2359,19 @@ async function callTool(name, args = {}) {
|
|
|
2123
2359
|
? '\n(키도 있습니다 — **발행 다섯만** 키로 나가고 나머지는 이 신원으로 갑니다.)'
|
|
2124
2360
|
: '';
|
|
2125
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 (방금 설정을 바꿨다면 이 창을 다시 열어야 반영됩니다 — 시작할 때 한 번만 읽습니다.)';
|
|
2126
2373
|
return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
|
|
2127
|
-
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
|
|
2374
|
+
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}${pushOff}`;
|
|
2128
2375
|
}
|
|
2129
2376
|
|
|
2130
2377
|
case 'list_my_tenants': {
|
|
@@ -2309,6 +2556,12 @@ async function callTool(name, args = {}) {
|
|
|
2309
2556
|
return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}`
|
|
2310
2557
|
+ pendingText(r) + warnText(r);
|
|
2311
2558
|
}
|
|
2559
|
+
case 'world': {
|
|
2560
|
+
const facts = worldFacts(8);
|
|
2561
|
+
if (facts === null) return cellNotice('세포의 답을 못 읽었다');
|
|
2562
|
+
if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
|
|
2563
|
+
return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
|
|
2564
|
+
}
|
|
2312
2565
|
case 'remember_case': {
|
|
2313
2566
|
const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
|
|
2314
2567
|
title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
|
|
@@ -2516,6 +2769,7 @@ process.stdout.on('error', (e) => {
|
|
|
2516
2769
|
function send(msg) {
|
|
2517
2770
|
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
2518
2771
|
}
|
|
2772
|
+
SEND = send;
|
|
2519
2773
|
|
|
2520
2774
|
let buf = '';
|
|
2521
2775
|
|
|
@@ -2561,7 +2815,9 @@ async function handle(line) {
|
|
|
2561
2815
|
CLIENT = req.params?.clientInfo || null;
|
|
2562
2816
|
result = {
|
|
2563
2817
|
protocolVersion: req.params?.protocolVersion || '2024-11-05',
|
|
2564
|
-
|
|
2818
|
+
// ★ `listChanged` 를 **선언해야** 알림을 보낼 자격이 생긴다. 명세상 안 알리고
|
|
2819
|
+
// 보내면 클라이언트가 버려도 할 말이 없다.
|
|
2820
|
+
capabilities: { tools: { listChanged: true } },
|
|
2565
2821
|
/*
|
|
2566
2822
|
* 자기소개 이름. **도구 이름 접두사와 다른 물건이다** — 그 접두사는 클라이언트 설정
|
|
2567
2823
|
* 키(`claude mcp add --scope user <키>`)에서 나오고, 패키지는 그 값을 못 바꾼다.
|
|
@@ -2599,8 +2855,9 @@ async function handle(line) {
|
|
|
2599
2855
|
'내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
|
|
2600
2856
|
].join('\n'),
|
|
2601
2857
|
};
|
|
2858
|
+
toolsNotifyReady = true;
|
|
2602
2859
|
} else if (req.method === 'tools/list') {
|
|
2603
|
-
result = { tools:
|
|
2860
|
+
result = { tools: toolsNow() };
|
|
2604
2861
|
} else if (req.method === 'tools/call') {
|
|
2605
2862
|
const text = await callTool(req.params?.name, req.params?.arguments || {});
|
|
2606
2863
|
result = { content: [{ type: 'text', text }] };
|
|
@@ -2810,4 +3067,5 @@ if (IS_MAIN) {
|
|
|
2810
3067
|
}
|
|
2811
3068
|
|
|
2812
3069
|
// 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
|
|
2813
|
-
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.
|
|
3
|
+
"version": "2.8.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
|
}
|
|
@@ -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-
|
|
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' 서식이 없습니다.")
|