genetics-mcp 2.9.3 → 2.9.4
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 +80 -23
- package/package.json +1 -1
- package/skills/genetics-cell-id/SKILL.md +18 -3
package/genetics.mjs
CHANGED
|
@@ -2269,6 +2269,25 @@ const TOOLS = [
|
|
|
2269
2269
|
required: ['tenant', 'path', 'verdict'],
|
|
2270
2270
|
},
|
|
2271
2271
|
},
|
|
2272
|
+
{
|
|
2273
|
+
name: 'review_verdict',
|
|
2274
|
+
description:
|
|
2275
|
+
'**두뇌가 보낸 검토(백신 메일: 「충돌 — …」「중복 — …」「문서 필요 — …」)에 대한 이 세션의 판정을 적는다** — '
|
|
2276
|
+
+ '「그 지적대로 고쳤다(applied) / 그 지적이 틀렸다(refuted)」. 그 메일을 읽고 실제로 고쳤거나 근거를 보고 틀렸다고 '
|
|
2277
|
+
+ '확인했을 때만 부른다. 이것은 세션 판정이라 **이 세션에게 같은 검토를 다시 놓지 않는 데만** 쓰이고, 두뇌의 '
|
|
2278
|
+
+ '학습(굽기)에는 사람이 지식 화면에서 닫은 판정만 실린다. title 은 메일 제목의 「— 」 뒤를 그대로.',
|
|
2279
|
+
inputSchema: {
|
|
2280
|
+
type: 'object',
|
|
2281
|
+
properties: {
|
|
2282
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
2283
|
+
title: { type: 'string', description: '검토 제목 — 메일 제목 「충돌 — <제목>」의 <제목> 그대로.' },
|
|
2284
|
+
verdict: { type: 'string', enum: ['applied', 'refuted'], description: '이 세션의 판정.' },
|
|
2285
|
+
type: { type: 'string', enum: ['contradiction', 'duplicate', 'missing-page'], description: '검토 종류(선택 — 충돌=contradiction·중복=duplicate·문서 필요=missing-page). 비우면 서버가 셋 중 있는 것을 고른다.' },
|
|
2286
|
+
note: { type: 'string', description: '한 줄 근거(선택, 200자).' },
|
|
2287
|
+
},
|
|
2288
|
+
required: ['tenant', 'title', 'verdict'],
|
|
2289
|
+
},
|
|
2290
|
+
},
|
|
2272
2291
|
{
|
|
2273
2292
|
name: 'list_sessions',
|
|
2274
2293
|
description:
|
|
@@ -2781,8 +2800,8 @@ function brainSays() {
|
|
|
2781
2800
|
const MAIL_POLL_MS = 60_000; // 사람이 기다린다 — 백신(120초)보다 자주 본다
|
|
2782
2801
|
const MAIL_PAGE = 20;
|
|
2783
2802
|
let mailAt = 0;
|
|
2784
|
-
|
|
2785
|
-
|
|
2803
|
+
const mailCursors = new Map(); // tenant → 마지막 커서 (소속처마다 따로 — 한 세션이 여러 곳에 주소를 댄다)
|
|
2804
|
+
const mailAddrAt = new Map(); // tenant → 마지막으로 주소를 댄 때
|
|
2786
2805
|
const mailQueue = []; // 아직 안 말한 통(서버가 렌더한 글자를 그대로 싣는다)
|
|
2787
2806
|
const mailBack = []; // 내가 부쳤다 만료된 것
|
|
2788
2807
|
const mailBackSeen = new Set();
|
|
@@ -2877,17 +2896,26 @@ function myAgentName() {
|
|
|
2877
2896
|
* 0f 의 답장이 꼬리에 영영 안 실렸다. 색인이 없으면 mail 도구가 마지막으로 쓴 소속처를
|
|
2878
2897
|
* 힌트로 쓴다(그 소속처에 주소를 댔으니 받을 곳도 거기다).
|
|
2879
2898
|
*/
|
|
2880
|
-
|
|
2899
|
+
/*
|
|
2900
|
+
* ⚠⚠ **받기는 「주소를 댄 소속처 전부」를 돈다** (2026-08-25 app-4a 실측 — 0a8c9d3 의 남은 구멍).
|
|
2901
|
+
* 0a8c9d3 은 힌트를 「색인이 비었을 때」의 폴백으로만 뒀다. 그런데 색인에 **다른** 소속처가
|
|
2902
|
+
* 박힌 세션(app-4a: 색인=앳플리, mail 도구=Lim 데모)은 힌트가 영영 안 먹고, 폴은 색인 쪽만
|
|
2903
|
+
* 봐서 **보낸 곳과 받는 곳이 갈렸다** — 「보낼 수 있는데 받지 못한다」의 두 번째 갈래.
|
|
2904
|
+
* 게다가 mailPull 이 소속처 하나만 조회했다(한 세션이 여러 곳에 주소를 댈 수 있는데).
|
|
2905
|
+
* 처방: 색인의 소속처 ∪ 이 프로세스에서 mail 도구가 성공한 소속처 전부를 **매 바퀴 다** 돈다.
|
|
2906
|
+
* 폴백이 아니라 합집합이다 — 우선순위를 두면 언젠가 또 한쪽이 조용히 진다.
|
|
2907
|
+
*/
|
|
2908
|
+
const mailTenants = new Map(); // tenant 이름 → tenant_id(모르면 0). mail 도구가 성공할 때 적는다
|
|
2881
2909
|
function mailReady() {
|
|
2882
2910
|
const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
|
|
2883
2911
|
if (!mine) return null;
|
|
2884
|
-
let e = indexRead()[mine];
|
|
2885
|
-
if (!e?.tenant && mailTenantHint) e = { ...(e || {}), ...mailTenantHint };
|
|
2886
|
-
if (!e?.tenant) return null; // 아직 어느 소속처인지 모른다 — 받기엔 이름이면 족하다
|
|
2887
2912
|
const cred = credential();
|
|
2888
|
-
// 세계 장부와 같다 — 서버가 사람 토큰만
|
|
2889
|
-
|
|
2890
|
-
|
|
2913
|
+
if (!cred || cred.kind !== 'user') return null; // 세계 장부와 같다 — 서버가 사람 토큰만 받는다
|
|
2914
|
+
const e = indexRead()[mine];
|
|
2915
|
+
const set = new Map(mailTenants);
|
|
2916
|
+
if (e?.tenant && !set.has(e.tenant)) set.set(e.tenant, Number(e.tenant_id) || 0);
|
|
2917
|
+
if (!set.size) return null; // 아직 어느 소속처에도 주소를 안 댔다
|
|
2918
|
+
return { mine, tenants: set, indexTenant: e?.tenant || '' , indexEntry: e };
|
|
2891
2919
|
}
|
|
2892
2920
|
|
|
2893
2921
|
/**
|
|
@@ -2896,10 +2924,10 @@ function mailReady() {
|
|
|
2896
2924
|
* 이름이 없어도 등록한다 — 받을 수는 있어야 하기 때문이다. 이름이 생기면 다시 댄다.
|
|
2897
2925
|
*/
|
|
2898
2926
|
async function mailRegister(tenant, mine, force = false) {
|
|
2899
|
-
if (!force && Date.now() - mailAddrAt < MAIL_POLL_MS) return;
|
|
2900
|
-
mailAddrAt
|
|
2927
|
+
if (!force && Date.now() - (mailAddrAt.get(tenant) || 0) < MAIL_POLL_MS) return null;
|
|
2928
|
+
mailAddrAt.set(tenant, Date.now());
|
|
2901
2929
|
const name = myAgentName();
|
|
2902
|
-
await api('POST', '/api/mail/register', {
|
|
2930
|
+
return await api('POST', '/api/mail/register', {
|
|
2903
2931
|
tenant,
|
|
2904
2932
|
session: mine,
|
|
2905
2933
|
name: name || '',
|
|
@@ -2918,32 +2946,42 @@ async function mailRegister(tenant, mine, force = false) {
|
|
|
2918
2946
|
async function mailPull() {
|
|
2919
2947
|
const r0 = mailReady();
|
|
2920
2948
|
if (!r0) return;
|
|
2921
|
-
const { mine,
|
|
2949
|
+
const { mine, tenants, indexTenant, indexEntry } = r0;
|
|
2922
2950
|
if (Date.now() - mailAt < MAIL_POLL_MS) return;
|
|
2923
2951
|
mailAt = Date.now();
|
|
2924
2952
|
|
|
2925
|
-
|
|
2953
|
+
for (const [tenant] of tenants) {
|
|
2954
|
+
try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null); }
|
|
2955
|
+
catch { /* 한 소속처가 막혀도 나머지는 돈다 — 다음 바퀴에 다시 */ }
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/** 한 소속처의 내 편지함을 긷고 **받았다고 도장을 찍는다**(도장은 큐에 넣기 **전에** — 재점화 방지). */
|
|
2960
|
+
async function mailPullOne(tenant, mine, indexEntry) {
|
|
2961
|
+
try { await mailRegister(tenant, mine); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
|
|
2926
2962
|
|
|
2927
|
-
if (
|
|
2963
|
+
if (!mailCursors.has(tenant)) mailCursors.set(tenant, Number(indexEntry?.mail_cursor || 0));
|
|
2964
|
+
let cursor = mailCursors.get(tenant);
|
|
2928
2965
|
/*
|
|
2929
2966
|
* ⚠ **자리(`cwd`)를 안 보낸다.** 창구가 그것으로 거르던 동안 주소 있는 메일이 조용히
|
|
2930
2967
|
* 사라졌다(실측 2026-08-25: cwd 없이 1통 · cwd 있게 0통). 메일은 폴더가 아니라
|
|
2931
2968
|
* 세션에 부친 것이라 **주소가 이미 필터**다.
|
|
2932
2969
|
*/
|
|
2933
|
-
const q = '?tenant=' + encodeURIComponent(
|
|
2934
|
-
+ '&since=' +
|
|
2970
|
+
const q = '?tenant=' + encodeURIComponent(tenant)
|
|
2971
|
+
+ '&since=' + cursor
|
|
2935
2972
|
+ '&session=' + encodeURIComponent(mine)
|
|
2936
2973
|
+ '&limit=' + MAIL_PAGE;
|
|
2937
2974
|
const r = await api('GET', '/api/mail' + q);
|
|
2938
2975
|
|
|
2939
2976
|
const rows = Array.isArray(r?.mail) ? r.mail : [];
|
|
2940
|
-
if (typeof r?.cursor === 'number' && r.cursor >
|
|
2941
|
-
|
|
2977
|
+
if (typeof r?.cursor === 'number' && r.cursor > cursor) { cursor = r.cursor; mailCursors.set(tenant, cursor); }
|
|
2978
|
+
// 색인에는 색인의 소속처 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
|
|
2979
|
+
if (indexEntry?.tenant_id) indexNote(mine, { tenant, tenant_id: indexEntry.tenant_id, mail_cursor: cursor });
|
|
2942
2980
|
|
|
2943
2981
|
if (rows.length) {
|
|
2944
2982
|
const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
|
|
2945
2983
|
if (ids.length) {
|
|
2946
|
-
try { await api('POST', '/api/mail/ack', { tenant
|
|
2984
|
+
try { await api('POST', '/api/mail/ack', { tenant, ids }); }
|
|
2947
2985
|
catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
|
|
2948
2986
|
}
|
|
2949
2987
|
/*
|
|
@@ -3392,6 +3430,14 @@ async function callTool(name, args = {}) {
|
|
|
3392
3430
|
return `${r.says || '판정을 적었다.'}\n`
|
|
3393
3431
|
+ '(세션이 중계한 판정이다 — 사람이 지식 화면에서 누른 판정만 도구 직전 알림·검색에 반영된다.)';
|
|
3394
3432
|
}
|
|
3433
|
+
case 'review_verdict': {
|
|
3434
|
+
const r = await api('POST', '/api/wiki/review_verdict?tenant=' + encodeURIComponent(args.tenant), {
|
|
3435
|
+
title: args.title, verdict: args.verdict, type: args.type || '', note: args.note || '',
|
|
3436
|
+
...myCitation(), // 세션 uuid 는 우리가 채운다 — 없으면 서버가 안 적는다
|
|
3437
|
+
});
|
|
3438
|
+
return `${r.says || '판정을 적었다.'}\n`
|
|
3439
|
+
+ '(세션 판정이다 — 이 세션에게 같은 검토를 다시 놓지 않는 데만 쓰인다. 두뇌의 기억에는 사람이 화면에서 닫은 판정만 실린다.)';
|
|
3440
|
+
}
|
|
3395
3441
|
case 'list_sessions': {
|
|
3396
3442
|
const query = new URLSearchParams({ tenant: String(args.tenant) });
|
|
3397
3443
|
if (args.page !== undefined) query.set('page', String(args.page));
|
|
@@ -3574,8 +3620,19 @@ async function callTool(name, args = {}) {
|
|
|
3574
3620
|
+ '이름은 `ListAgents` 를 한 번 부르면 생긴다 — 하네스가 「This session is …」를 '
|
|
3575
3621
|
+ '전사에 적고, 이 도구가 그것을 읽는다. 부른 뒤 다시 보내면 된다.';
|
|
3576
3622
|
}
|
|
3577
|
-
|
|
3623
|
+
let reg;
|
|
3624
|
+
try { reg = await mailRegister(args.tenant, mine, true); }
|
|
3578
3625
|
catch (e) { return `주소를 대지 못했다 — ${e.message}`; }
|
|
3626
|
+
/*
|
|
3627
|
+
* ⚠ 이름을 **남이 쥐고 있으면** 보내기가 409(이름 없음)로 막힌다 — 서버 오류문만 보면
|
|
3628
|
+
* 무엇을 해야 하는지 모른다. 창구가 준 사실(held_by)을 여기서 사람 말로 앞세운다.
|
|
3629
|
+
* (하네스가 짧은 이름을 재사용한다 — 2026-08-25 운영 실측, app-4a 둘.)
|
|
3630
|
+
*/
|
|
3631
|
+
if (reg?.held_by) {
|
|
3632
|
+
return `이 세션의 이름을 살아 있는 다른 세션(${String(reg.held_by).slice(0, 8)})이 쥐고 있어 이름 없이 등록됐다 — `
|
|
3633
|
+
+ '이름 없는 주소로는 보낼 수 없다. 그 세션이 닫히면(5분 조용) 이름이 돌아온다. '
|
|
3634
|
+
+ `그때까지 이 세션에 닿으려면 uuid(${mine})를 쓰면 된다.`;
|
|
3635
|
+
}
|
|
3579
3636
|
|
|
3580
3637
|
const r = await api('POST', '/api/mail', {
|
|
3581
3638
|
tenant: args.tenant,
|
|
@@ -3592,8 +3649,8 @@ async function callTool(name, args = {}) {
|
|
|
3592
3649
|
// ★ 받을 곳을 기억한다 — 색인 밖 세션도 이 소속처에서 받는다(mailReady 힌트 + 색인).
|
|
3593
3650
|
// ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
|
|
3594
3651
|
// GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
|
|
3595
|
-
|
|
3596
|
-
if (r?.tenant_id) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id });
|
|
3652
|
+
mailTenants.set(args.tenant, r?.tenant_id || 0); // 이 소속처에서도 받는다 — 합집합, 폴백 아님
|
|
3653
|
+
if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id });
|
|
3597
3654
|
return `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}번째.\n`
|
|
3598
3655
|
+ `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
|
|
3599
3656
|
+ (r.says ? `${r.says}\n` : '')
|
package/package.json
CHANGED
|
@@ -157,9 +157,22 @@ mail({tenant: "<소속처>", to: "brain", body: "…"}) # 기계를 넘어
|
|
|
157
157
|
|
|
158
158
|
받은 것에 답할 때 `SendMessage` 는 그 메시지의 `from` 을 그대로 `to` 에 쓰고, `mail` 은 `thread` 를 씁니다.
|
|
159
159
|
|
|
160
|
-
`mail` 의 `to` 에 올 수 있는 것은
|
|
161
|
-
|
|
162
|
-
|
|
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
|
## 무엇을 하지 않는가
|