genetics-mcp 2.7.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 +167 -4
- package/package.json +1 -1
- 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
|
// **안 바뀌었으면 안 보낸다.** 이 설계가 실제로 버는 것은 바이트가 아니라 이것이다.
|
|
@@ -2144,7 +2261,41 @@ function deviceLabel() {
|
|
|
2144
2261
|
try { return `${os.userInfo().username}@${os.hostname()}`; } catch { return os.hostname(); }
|
|
2145
2262
|
}
|
|
2146
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
|
+
|
|
2147
2297
|
async function callTool(name, args = {}) {
|
|
2298
|
+
requireArgs(name, args);
|
|
2148
2299
|
switch (name) {
|
|
2149
2300
|
case 'login': {
|
|
2150
2301
|
/*
|
|
@@ -2208,8 +2359,19 @@ async function callTool(name, args = {}) {
|
|
|
2208
2359
|
? '\n(키도 있습니다 — **발행 다섯만** 키로 나가고 나머지는 이 신원으로 갑니다.)'
|
|
2209
2360
|
: '';
|
|
2210
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 (방금 설정을 바꿨다면 이 창을 다시 열어야 반영됩니다 — 시작할 때 한 번만 읽습니다.)';
|
|
2211
2373
|
return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
|
|
2212
|
-
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
|
|
2374
|
+
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}${pushOff}`;
|
|
2213
2375
|
}
|
|
2214
2376
|
|
|
2215
2377
|
case 'list_my_tenants': {
|
|
@@ -2905,4 +3067,5 @@ if (IS_MAIN) {
|
|
|
2905
3067
|
}
|
|
2906
3068
|
|
|
2907
3069
|
// 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
|
|
2908
|
-
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
|
@@ -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' 서식이 없습니다.")
|