genetics-mcp 2.2.0 → 2.4.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 +234 -48
- package/package.json +1 -1
package/genetics.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import crypto from 'node:crypto';
|
|
|
26
26
|
import fs from 'node:fs';
|
|
27
27
|
import os from 'node:os';
|
|
28
28
|
import path from 'node:path';
|
|
29
|
+
import { pipeline as streamPipeline } from 'node:stream/promises';
|
|
29
30
|
import { fileURLToPath } from 'node:url';
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -556,6 +557,66 @@ async function api(method, pathname, body, opts = {}) {
|
|
|
556
557
|
return r.data;
|
|
557
558
|
}
|
|
558
559
|
|
|
560
|
+
/**
|
|
561
|
+
* **본문을 파일로 흘린다. 봉투를 안 만든다.**
|
|
562
|
+
*
|
|
563
|
+
* `api()` 는 `res.text()` → `JSON.parse` 를 무조건 하고 `res.headers` 를 버린다. 세션 본문에는
|
|
564
|
+
* 그게 두 번 비싸다 — ① 10MB 본문이 JS 문자열로 힙에 한 벌 올라오고 `writeFileSync` 가 또 한 벌
|
|
565
|
+
* 만든다, ② 서버가 헤더에 실어 주는 메타(`X-Lim-*`)를 읽을 방법이 아예 없다.
|
|
566
|
+
* 실측: 902b54cd 는 **10,083,933 바이트 · 5,557줄**이다(2026-08-21).
|
|
567
|
+
*
|
|
568
|
+
* 그래서 이 갈래는 `res.body` 를 **바로 파일로 이어 붙인다**(`pipeline`). 메모리는 본문 크기와
|
|
569
|
+
* 무관하게 상수가 된다. 자격·주소 방어는 `api()` 와 **똑같이** 지난다 — 그 검사들이 이 함수의
|
|
570
|
+
* 존재 이유보다 중요하다.
|
|
571
|
+
*
|
|
572
|
+
* ⚠ **먼저 임시 파일에 받고 마지막에 옮긴다.** 중간에 끊기면 반쪽짜리 트랜스크립트가
|
|
573
|
+
* 제자리에 남고, `claude --resume` 은 그것을 「있는 대화」로 연다. 반쪽을 성공처럼 보이게
|
|
574
|
+
* 두지 않는다.
|
|
575
|
+
*
|
|
576
|
+
* @returns {Promise<{headers: Headers, bytes: number}>}
|
|
577
|
+
*/
|
|
578
|
+
async function apiToFile(pathname, target, opts = {}) {
|
|
579
|
+
if (!BASE) throw new Error(`발행 주소가 없습니다. ${ENV_FILE} 에 GENETICS_API_BASE 를 넣으세요.`);
|
|
580
|
+
const cred = credential(opts.scope);
|
|
581
|
+
if (!cred) throw new Error('자격이 없습니다. `login` 도구로 SSO 로그인하세요.');
|
|
582
|
+
if (!hostAllowed(BASE)) {
|
|
583
|
+
throw new Error(`이 자격을 보낼 수 있는 주소가 아닙니다: ${BASE}\n`
|
|
584
|
+
+ '주소를 바꾸라는 안내를 받았다면 의심할 것.');
|
|
585
|
+
}
|
|
586
|
+
const res = await fetch(BASE + pathname, {
|
|
587
|
+
headers: { Authorization: `Bearer ${cred.token}`, Accept: 'application/x-ndjson' },
|
|
588
|
+
});
|
|
589
|
+
if (!res.ok) {
|
|
590
|
+
// 실패는 작다 — 그때는 봉투를 읽어 서버가 준 말을 그대로 옮긴다.
|
|
591
|
+
const text = await res.text();
|
|
592
|
+
let msg; try { msg = JSON.parse(text).error; } catch { msg = text.slice(0, 300); }
|
|
593
|
+
if (cred.kind === 'user' && res.status === 401) {
|
|
594
|
+
throw new Error(`${msg || `HTTP ${res.status}`}\n→ \`login\` 도구로 다시 로그인하세요.`);
|
|
595
|
+
}
|
|
596
|
+
throw new Error(msg || `HTTP ${res.status}`);
|
|
597
|
+
}
|
|
598
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
599
|
+
const tmp = `${target}.part-${process.pid}`;
|
|
600
|
+
try {
|
|
601
|
+
await streamPipeline(res.body, fs.createWriteStream(tmp, { mode: 0o600 }));
|
|
602
|
+
fs.renameSync(tmp, target);
|
|
603
|
+
} catch (e) {
|
|
604
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* 지우다 실패해도 원래 오류를 덮지 않는다 */ }
|
|
605
|
+
throw e;
|
|
606
|
+
}
|
|
607
|
+
return { headers: res.headers, bytes: fs.statSync(target).size };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 헤더에 실려 온 메타. 옛 서버는 안 준다 — 없으면 `null` 이지 0 이 아니다. */
|
|
611
|
+
function limMeta(h) {
|
|
612
|
+
const num = (k) => (h.get(k) === null ? null : Number(h.get(k)));
|
|
613
|
+
return {
|
|
614
|
+
head_id: h.get('x-lim-head-id'), head_ms: num('x-lim-head-ms'),
|
|
615
|
+
lines: num('x-lim-lines'), bytes: num('x-lim-bytes'),
|
|
616
|
+
redacted: num('x-lim-redacted'), tenant_id: num('x-lim-tenant-id'),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
559
620
|
// ── 도구 ────────────────────────────────────────────────────────────
|
|
560
621
|
// 이 도구가 발행하는 것은 **"수정 내역 보고" 서식 하나**다(서버의 build.py 가 그것만 안다).
|
|
561
622
|
// 포털 자체는 문서 종류를 가리지 않지만, 데이터로 발행하는 길은 아직 이 서식뿐이다.
|
|
@@ -763,7 +824,20 @@ const SECRET_SHAPES = [
|
|
|
763
824
|
['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}/g],
|
|
764
825
|
];
|
|
765
826
|
const MASK = '[가림: 서버에 올리며 지움]';
|
|
766
|
-
|
|
827
|
+
/*
|
|
828
|
+
* 안 올리는 레코드 종류. **서버의 `AGENT_SESSION_DROP_TYPES` 와 같은 집합이어야 한다**
|
|
829
|
+
* (`www/portal/lib/agent_session.php:59-60`). 갈라지면 클라이언트가 보낸 것을 서버가 또
|
|
830
|
+
* 버리거나 그 반대가 되어, 줄 수·바이트가 안 맞고 동치 시험이 조용히 어긋난다.
|
|
831
|
+
*
|
|
832
|
+
* 뒤의 셋은 2026-08-21 에 더했다. **순서가 중요했다** — 서버가 먼저 버리게 배포한 뒤에
|
|
833
|
+
* 여기를 맞췄다. 반대로 했으면 그 종류가 서버에 **도착하지 않아** 서버 쪽 동치 시험이
|
|
834
|
+
* 「입력이 빈 채로 초록」이 됐을 것이다. 운영 반영을 눈으로 확인하고 넣었다(05:44:43Z).
|
|
835
|
+
*
|
|
836
|
+
* ⚠ `cwd` 는 **이 집합으로 버리기 전에** 센다(scrubTranscript). `attachment` 와 `system` 이
|
|
837
|
+
* cwd 를 많이 싣기 때문이다(902b54cd 에서 684 + 152줄) — 버린 뒤에 세면 표본이 조용히 준다.
|
|
838
|
+
*/
|
|
839
|
+
const DROP_TYPES = new Set(['file-history-snapshot', 'file-history-delta', 'attachment',
|
|
840
|
+
'last-prompt', 'queue-operation', 'system']);
|
|
767
841
|
|
|
768
842
|
function maskSecrets(text) {
|
|
769
843
|
let n = 0;
|
|
@@ -802,8 +876,34 @@ function maskDeep(v) {
|
|
|
802
876
|
return [v, 0];
|
|
803
877
|
}
|
|
804
878
|
|
|
879
|
+
/**
|
|
880
|
+
* 집 경로를 `~` 로 접는다. **본문과 origin_cwd 가 같은 함수를 써야 한다** —
|
|
881
|
+
* 다르게 접으면 서버에서 둘을 맞춰 볼 때 조용히 안 맞는다.
|
|
882
|
+
*/
|
|
883
|
+
function relHome(p) { return p.replace(/^\/(Users|home)\/[^/]+/, '~'); }
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* 이 대화가 **실제로 돈 자리**. 줄마다 실린 `cwd` 의 **최빈값**이다.
|
|
887
|
+
*
|
|
888
|
+
* 왜 최빈값인가 — 실측(트랜스크립트 3개 전수, 2026-08-21):
|
|
889
|
+
* 902b54cd cwd 있는 줄 6,328 최빈 6,295 (99.5%)
|
|
890
|
+
* a0f154a7 cwd 있는 줄 5,129 최빈 4,829 (94.2%)
|
|
891
|
+
* 6aead92f cwd 있는 줄 627 최빈 611 (97.4%)
|
|
892
|
+
* 소수값은 전부 최빈값의 **하위 폴더**였다(`editor/` · `www/portal/` · `mcp/` · `memory/`).
|
|
893
|
+
* 그래서 마지막 값이나 첫 값은 약하다 — 하위 폴더 한 번에 끌려간다.
|
|
894
|
+
* 같은 수면 **짧은 쪽**을 고른다(하위 폴더가 아니라 뿌리를 고르려는 것).
|
|
895
|
+
*/
|
|
896
|
+
function cwdMode(counts) {
|
|
897
|
+
let best = '', n = 0;
|
|
898
|
+
for (const [k, v] of counts) {
|
|
899
|
+
if (v > n || (v === n && k.length < best.length)) { best = k; n = v; }
|
|
900
|
+
}
|
|
901
|
+
return best;
|
|
902
|
+
}
|
|
903
|
+
|
|
805
904
|
function scrubTranscript(raw) {
|
|
806
905
|
const out = [];
|
|
906
|
+
const cwds = new Map();
|
|
807
907
|
let redacted = 0, dropped = 0, no = 0;
|
|
808
908
|
for (const line of raw.split('\n')) {
|
|
809
909
|
no++;
|
|
@@ -813,9 +913,18 @@ function scrubTranscript(raw) {
|
|
|
813
913
|
// 검열을 못 거친 줄은 안 보낸다. 조용히 흘리면 원문이 그대로 나간다.
|
|
814
914
|
throw new Error(`${no}번째 줄을 읽을 수 없습니다 — 검열을 못 거친 줄은 안 올립니다.`);
|
|
815
915
|
}
|
|
916
|
+
/*
|
|
917
|
+
* ★ **cwd 는 버리기 전에 센다.** 지금도 `attachment` 가 cwd 를 많이 싣고(902b54cd 에서 684줄)
|
|
918
|
+
* 그 종류는 바로 아래에서 버려진다. 형제 세션이 `system` 등 셋을 DROP_TYPES 에 더할 예정이라
|
|
919
|
+
* (그것도 152줄을 싣는다) 버린 뒤에 세면 표본이 **조용히** 줄어든다. 순서가 곧 방어다.
|
|
920
|
+
*/
|
|
921
|
+
if (typeof r.cwd === 'string' && r.cwd) {
|
|
922
|
+
const k = relHome(r.cwd);
|
|
923
|
+
cwds.set(k, (cwds.get(k) || 0) + 1);
|
|
924
|
+
}
|
|
816
925
|
if (DROP_TYPES.has(r.type)) { dropped++; continue; }
|
|
817
926
|
if ('toolUseResult' in r) { r.toolUseResult = MASK; redacted++; }
|
|
818
|
-
if (typeof r.cwd === 'string') r.cwd = r.cwd
|
|
927
|
+
if (typeof r.cwd === 'string') r.cwd = relHome(r.cwd);
|
|
819
928
|
const c = r?.message?.content;
|
|
820
929
|
if (typeof c === 'string') {
|
|
821
930
|
const [t, n] = maskSecrets(c); r.message.content = t; redacted += n;
|
|
@@ -839,7 +948,7 @@ function scrubTranscript(raw) {
|
|
|
839
948
|
out.push(JSON.stringify(safe));
|
|
840
949
|
}
|
|
841
950
|
if (!out.length) throw new Error('빈 트랜스크립트입니다.');
|
|
842
|
-
return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted };
|
|
951
|
+
return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted, cwd: cwdMode(cwds) };
|
|
843
952
|
}
|
|
844
953
|
|
|
845
954
|
/** cwd → 트랜스크립트 디렉터리. **해석된 경로**로 슬러그를 만든다(심볼릭 링크 함정). */
|
|
@@ -938,9 +1047,23 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
938
1047
|
* 운영에서 이미 두 세션이 그렇게 잃었다(둘 다 updated_at 05:05:21, 같은 초).
|
|
939
1048
|
* 없는 값을 「빈 값」으로 말하지 않는다 — 그 둘은 다른 뜻이다.
|
|
940
1049
|
*/
|
|
1050
|
+
/*
|
|
1051
|
+
* ── origin_cwd 는 「일한 곳」이지 「올린 곳」이 아니다 ──────────────────
|
|
1052
|
+
*
|
|
1053
|
+
* 예전에는 `process.cwd()` 였다. 그건 **올릴 때 이 프로세스가 서 있던 폴더**일 뿐이라,
|
|
1054
|
+
* 다른 자리에서 한 번만 올려도 출처가 통째로 바뀐다. 그리고 서버는 재업로드 때
|
|
1055
|
+
* `origin_cwd=VALUES(origin_cwd)` 로 **조건 없이 덮는다**(api.php:1008) — 되돌아오지 않는다.
|
|
1056
|
+
*
|
|
1057
|
+
* 실제로 났다(2026-08-21): 형제 세션이 검열을 시험하려고 `/private/tmp/scrubcheck` 에서
|
|
1058
|
+
* 올렸더니, 두뇌가 읽는 명부(`purpose.md`)의 「자리」 칸에 **`scrubcheck`** 가 실렸다.
|
|
1059
|
+
* 묶음 판정 점수기의 place 축도 같은 값을 먹는다 — 가장 강한 축이 오염된 것이다.
|
|
1060
|
+
*
|
|
1061
|
+
* 진짜 출처는 이미 손에 있었다: **본문 각 줄의 `cwd`**(scrubTranscript 가 센다).
|
|
1062
|
+
* 없을 때만 옛 방식으로 물러선다 — 그래야 퇴보가 없다.
|
|
1063
|
+
*/
|
|
941
1064
|
const body = {
|
|
942
1065
|
tenant, uuid,
|
|
943
|
-
origin_cwd:
|
|
1066
|
+
origin_cwd: sc.cwd || relHome(process.cwd()),
|
|
944
1067
|
origin_git: gitHere(), tools: toolsStamp(),
|
|
945
1068
|
jsonl: sc.jsonl,
|
|
946
1069
|
};
|
|
@@ -956,28 +1079,34 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
956
1079
|
*
|
|
957
1080
|
* ⚠ **줄 경계에서만 나눈다.** 검열도 비밀 재검사도 줄 단위라, 한 줄이 두 조각에 걸치면
|
|
958
1081
|
* 검열을 안 거친 반쪽이 올라간다. 서버도 같은 것을 확인하고 거절한다(두 겹).
|
|
1082
|
+
*
|
|
1083
|
+
* ★ **dry_run 도 같은 길을 간다.** 전에는 `|| dryRun` 으로 시험 실행만 통째로 보냈고, 그
|
|
1084
|
+
* 본문이 봉투 상한(16MB)에 걸려 413 — 긴 대화는 **시험 실행이 불가능**했다(2026-08-21 운영,
|
|
1085
|
+
* 검열 뒤 43.9MB). 「먼저 dry_run」이 권장 절차라 사실상 올릴 길이 없었다. 서버가 조각
|
|
1086
|
+
* dry_run 을 받는다(조각마다 세고 파일만 안 만든다). `body.dry_run` 이 조각마다 실린다.
|
|
959
1087
|
*/
|
|
960
1088
|
const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
|
|
961
|
-
if (parts.length === 1
|
|
1089
|
+
if (parts.length === 1) {
|
|
962
1090
|
const r = await api('PUT', '/api/sessions', body);
|
|
963
1091
|
if (!dryRun) indexNote(uuid, { tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
|
|
964
|
-
return { r, before };
|
|
1092
|
+
return { r, before, parts: 1 };
|
|
965
1093
|
}
|
|
966
1094
|
|
|
967
1095
|
const uploadId = crypto.randomBytes(12).toString('hex');
|
|
968
1096
|
let last = null;
|
|
969
1097
|
for (let i = 0; i < parts.length; i++) {
|
|
970
|
-
// 메타(
|
|
1098
|
+
// 메타(제목·경로·도구·dry_run)는 **조각마다** 싣는다. 작고, 마지막 조각에서만 쓰이지만
|
|
971
1099
|
// 어느 조각이 마지막인지 서버가 아는 것과 부르는 쪽이 아는 것이 어긋날 이유가 없다.
|
|
972
1100
|
last = await api('PUT', '/api/sessions', {
|
|
973
1101
|
...body, jsonl: parts[i],
|
|
974
1102
|
upload_id: uploadId, part: i + 1, parts: parts.length,
|
|
975
1103
|
});
|
|
976
|
-
|
|
977
|
-
|
|
1104
|
+
// 바이트로 적는다 — `.length` 는 UTF-16 단위라 한글이 3배로 어긋난다(splitAtLines 의 그 함정).
|
|
1105
|
+
process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} ${dryRun ? '시험' : '올림'} `
|
|
1106
|
+
+ `(${(Buffer.byteLength(parts[i], 'utf8') / 1048576).toFixed(1)}MB)\n`);
|
|
978
1107
|
}
|
|
979
|
-
indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms });
|
|
980
|
-
return { r: last, before };
|
|
1108
|
+
if (!dryRun) indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms });
|
|
1109
|
+
return { r: last, before, parts: parts.length };
|
|
981
1110
|
}
|
|
982
1111
|
|
|
983
1112
|
/* ── 코로케이션 심박 ───────────────────────────────────────────────────
|
|
@@ -1391,7 +1520,16 @@ const TOOLS = [
|
|
|
1391
1520
|
},
|
|
1392
1521
|
{
|
|
1393
1522
|
name: 'whoami',
|
|
1394
|
-
description: '지금 무엇으로 붙어 있는지 —
|
|
1523
|
+
description: '지금 무엇으로 붙어 있는지 — 사람 로그인인지 소속처 키인지, 사람이면 누구인지.',
|
|
1524
|
+
inputSchema: { type: 'object', properties: {} },
|
|
1525
|
+
},
|
|
1526
|
+
{
|
|
1527
|
+
name: 'list_my_tenants',
|
|
1528
|
+
description:
|
|
1529
|
+
'**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant` 이름을 여기서 얻는다.\n'
|
|
1530
|
+
+ '이름을 모르는 채로 `tenant` 를 **지어내지 말 것** — 이것부터 부르고, 여럿이면 사용자에게 고르게 한다.\n'
|
|
1531
|
+
+ '지금 폴더에서 올린 세션이 있는 곳이 위로 온다. 다만 그것은 **순서일 뿐 정답이 아니다** — '
|
|
1532
|
+
+ '같은 폴더에서 여러 곳의 일을 하기도 한다.',
|
|
1395
1533
|
inputSchema: { type: 'object', properties: {} },
|
|
1396
1534
|
},
|
|
1397
1535
|
{
|
|
@@ -1402,7 +1540,7 @@ const TOOLS = [
|
|
|
1402
1540
|
{
|
|
1403
1541
|
name: 'list_reports',
|
|
1404
1542
|
description:
|
|
1405
|
-
'
|
|
1543
|
+
'내가 닿을 수 있는 문서 목록(사람으로 붙으면 소속처 전부, 키면 그 한 곳). 각 줄의 대괄호가 **문서 상태**이고, ' +
|
|
1406
1544
|
'무엇으로 만들어졌는지·API 로 읽고 쓸 수 있는지를 서버가 판정해 준다. ' +
|
|
1407
1545
|
'편집기가 정본인 문서는 API 로 못 고친다 — 사람이 편집 화면에서 고쳐야 한다. ' +
|
|
1408
1546
|
'응답 끝의 다음 page 안내를 따라가면 100건 뒤의 문서도 볼 수 있다.',
|
|
@@ -1442,7 +1580,7 @@ const TOOLS = [
|
|
|
1442
1580
|
{
|
|
1443
1581
|
name: 'publish_report',
|
|
1444
1582
|
description:
|
|
1445
|
-
'새 보고서를 만든다.
|
|
1583
|
+
'새 보고서를 만든다. 키로 붙어 있으면 소속처는 키가 정한다. ' +
|
|
1446
1584
|
'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
|
|
1447
1585
|
'**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
|
|
1448
1586
|
'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
|
|
@@ -1450,7 +1588,7 @@ const TOOLS = [
|
|
|
1450
1588
|
inputSchema: {
|
|
1451
1589
|
type: 'object',
|
|
1452
1590
|
properties: {
|
|
1453
|
-
tenant: { type: 'string', description: '
|
|
1591
|
+
tenant: { type: 'string', description: '문서를 받을 소속처 이름. 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
|
|
1454
1592
|
slug: { type: 'string', description: '파일 이름이 될 식별자. 영문 소문자·숫자·하이픈 (예: fixes-20260812)' },
|
|
1455
1593
|
published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
|
|
1456
1594
|
publish: {
|
|
@@ -1474,7 +1612,7 @@ const TOOLS = [
|
|
|
1474
1612
|
inputSchema: {
|
|
1475
1613
|
type: 'object',
|
|
1476
1614
|
properties: {
|
|
1477
|
-
tenant: { type: 'string', description: '대상
|
|
1615
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
|
|
1478
1616
|
id: { type: 'integer' },
|
|
1479
1617
|
published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
|
|
1480
1618
|
},
|
|
@@ -1489,7 +1627,7 @@ const TOOLS = [
|
|
|
1489
1627
|
inputSchema: {
|
|
1490
1628
|
type: 'object',
|
|
1491
1629
|
properties: {
|
|
1492
|
-
tenant: { type: 'string', description: '대상
|
|
1630
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
|
|
1493
1631
|
id: { type: 'integer' },
|
|
1494
1632
|
reason: { type: 'string', description: '왜 내리는지. 서버 로그에 남아 다음 사람이 이유를 안다.' },
|
|
1495
1633
|
},
|
|
@@ -1518,7 +1656,7 @@ const TOOLS = [
|
|
|
1518
1656
|
inputSchema: {
|
|
1519
1657
|
type: 'object',
|
|
1520
1658
|
properties: {
|
|
1521
|
-
tenant: { type: 'string', description: '대상
|
|
1659
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다(오발행 방지).' },
|
|
1522
1660
|
id: { type: 'integer' },
|
|
1523
1661
|
dry_run: {
|
|
1524
1662
|
type: 'boolean',
|
|
@@ -1533,7 +1671,7 @@ const TOOLS = [
|
|
|
1533
1671
|
{
|
|
1534
1672
|
name: 'remember_case',
|
|
1535
1673
|
description:
|
|
1536
|
-
'**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그
|
|
1674
|
+
'**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그 소속처 지식의 `cases/` 에 적히고 '
|
|
1537
1675
|
+ '다음에 누가(어느 기계에서든) `search_cases` 로 찾아 쓴다. 같은 제목이면 **덮어쓴다** — '
|
|
1538
1676
|
+ '그래서 「갱신」·「업데이트」도 이 도구다.\n'
|
|
1539
1677
|
+ '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
|
|
@@ -1549,7 +1687,7 @@ const TOOLS = [
|
|
|
1549
1687
|
inputSchema: {
|
|
1550
1688
|
type: 'object',
|
|
1551
1689
|
properties: {
|
|
1552
|
-
tenant: { type: 'string', description: '대상
|
|
1690
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1553
1691
|
title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
|
|
1554
1692
|
symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
|
|
1555
1693
|
cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
|
|
@@ -1563,7 +1701,7 @@ const TOOLS = [
|
|
|
1563
1701
|
{
|
|
1564
1702
|
name: 'search_cases',
|
|
1565
1703
|
description:
|
|
1566
|
-
'**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그
|
|
1704
|
+
'**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그 소속처 지식(사례·개념·대상 전부)을 '
|
|
1567
1705
|
+ '증상 문장으로 훑어 점수순으로 돌려준다.\n'
|
|
1568
1706
|
+ '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
|
|
1569
1707
|
+ ' "전에도 이런 적 있었나" · "우리 이거 겪지 않았나" · "지식에 있나 찾아봐" · '
|
|
@@ -1575,7 +1713,7 @@ const TOOLS = [
|
|
|
1575
1713
|
inputSchema: {
|
|
1576
1714
|
type: 'object',
|
|
1577
1715
|
properties: {
|
|
1578
|
-
tenant: { type: 'string', description: '대상
|
|
1716
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1579
1717
|
q: { type: 'string', description: '증상 문장. 원인이 아니라 현상으로.' },
|
|
1580
1718
|
limit: { type: 'integer', minimum: 1, maximum: 20, description: '몇 건까지(기본 5).' },
|
|
1581
1719
|
fast: { type: 'boolean', description: '의미 확장을 건너뛴다(빠르지만 덜 찾는다). 기본은 확장 켬 — '
|
|
@@ -1587,12 +1725,12 @@ const TOOLS = [
|
|
|
1587
1725
|
{
|
|
1588
1726
|
name: 'list_sessions',
|
|
1589
1727
|
description:
|
|
1590
|
-
'이
|
|
1728
|
+
'이 소속처에 올라와 있는 **작업 세션 대화** 목록. 담당자가 부재일 때 무엇을 이어받을 수 있는지 본다. '
|
|
1591
1729
|
+ '응답 끝의 다음 page 안내를 따라가면 100건 뒤의 세션도 볼 수 있다.',
|
|
1592
1730
|
inputSchema: {
|
|
1593
1731
|
type: 'object',
|
|
1594
1732
|
properties: {
|
|
1595
|
-
tenant: { type: 'string', description: '대상
|
|
1733
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1596
1734
|
page: {
|
|
1597
1735
|
type: 'integer', minimum: 1, maximum: 1000,
|
|
1598
1736
|
description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
|
|
@@ -1612,11 +1750,12 @@ const TOOLS = [
|
|
|
1612
1750
|
'`claude --resume <uuid>` 로 이어 갈 수 있다.\n' +
|
|
1613
1751
|
'올라가지 않는 것: 도구 실행 결과·첨부·파일 이력·집 경로·비밀 모양(토큰·비밀번호·개인키). ' +
|
|
1614
1752
|
'**도구 호출의 짝과 인자는 남는다** — 그게 없으면 대화가 안 열린다.\n' +
|
|
1615
|
-
'
|
|
1753
|
+
'긴 대화는 **알아서 조각으로** 나눠 보내고 서버가 잇는다 — 크기 때문에 사람이 할 일은 없다.\n' +
|
|
1754
|
+
'쓰기 전에 무엇이 빠지는지만 보고 싶으면 dry_run: true(조각도 같은 길을 간다).',
|
|
1616
1755
|
inputSchema: {
|
|
1617
1756
|
type: 'object',
|
|
1618
1757
|
properties: {
|
|
1619
|
-
tenant: { type: 'string', description: '대상
|
|
1758
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1620
1759
|
uuid: { type: 'string', description: '세션 id. `--resume` 에 쓰는 그 값.' },
|
|
1621
1760
|
title: { type: 'string', description: '목록에서 알아볼 한 줄. 생략하면 빈 값.' },
|
|
1622
1761
|
dry_run: { type: 'boolean', description: '참이면 아무것도 저장하지 않고 검열 결과만 돌려준다.' },
|
|
@@ -1640,7 +1779,7 @@ const TOOLS = [
|
|
|
1640
1779
|
inputSchema: {
|
|
1641
1780
|
type: 'object',
|
|
1642
1781
|
properties: {
|
|
1643
|
-
tenant: { type: 'string', description: '대상
|
|
1782
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1644
1783
|
uuid: { type: 'string', description: '이어받을 세션 id.' },
|
|
1645
1784
|
into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
|
|
1646
1785
|
+ '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
|
|
@@ -1660,7 +1799,7 @@ const TOOLS = [
|
|
|
1660
1799
|
inputSchema: {
|
|
1661
1800
|
type: 'object',
|
|
1662
1801
|
properties: {
|
|
1663
|
-
tenant: { type: 'string', description: '대상
|
|
1802
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1664
1803
|
uuid: { type: 'string', description: '받을 세션 id.' },
|
|
1665
1804
|
into: { type: 'string', description: '어느 디렉터리에서 이어 갈 것인가(생략하면 지금 폴더). '
|
|
1666
1805
|
+ '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 이어 갈 자리다.' },
|
|
@@ -1737,7 +1876,7 @@ async function callTool(name, args = {}) {
|
|
|
1737
1876
|
base: BASE,
|
|
1738
1877
|
});
|
|
1739
1878
|
const warn = KEY
|
|
1740
|
-
? `\n\n⚠ 이 기계에는
|
|
1879
|
+
? `\n\n⚠ 이 기계에는 소속처 키도 있습니다. **발행은 키로 나갑니다**(좁은 쪽이 기본). `
|
|
1741
1880
|
+ `로그인 자격으로 쓰려면 ${ENV_FILE} 의 GENETICS_API_KEY 를 지우세요.`
|
|
1742
1881
|
: '';
|
|
1743
1882
|
return `로그인했습니다 — ${data.user.name}(${data.user.email}) · ${data.user.role}\n`
|
|
@@ -1765,6 +1904,31 @@ async function callTool(name, args = {}) {
|
|
|
1765
1904
|
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
|
|
1766
1905
|
}
|
|
1767
1906
|
|
|
1907
|
+
case 'list_my_tenants': {
|
|
1908
|
+
/*
|
|
1909
|
+
* **폴더를 보내되, 그것으로 고르지 않는다.** 서버는 이 값으로 순서만 매기고 `guess` 를
|
|
1910
|
+
* 안 준다(api.php 의 `/api/tenants` 주석). 여기서도 하나를 골라 주지 않는다 —
|
|
1911
|
+
* **후보가 하나뿐일 때만** 그것이 답이고, 그건 추론이 아니라 선택지가 없는 것이다(§6-4).
|
|
1912
|
+
*
|
|
1913
|
+
* 접는 규칙은 올릴 때와 **같은 함수**를 쓴다. 다르면 서버에 쌓인 값과 안 맞아 조용히 0건이 된다.
|
|
1914
|
+
*/
|
|
1915
|
+
const r = await api('GET', `/api/tenants?cwd=${encodeURIComponent(relHome(process.cwd()))}`);
|
|
1916
|
+
const list = Array.isArray(r.tenants) ? r.tenants : [];
|
|
1917
|
+
if (!list.length) return '속한 곳이 없습니다. 관리자에게 담당 지정을 요청하세요.';
|
|
1918
|
+
if (list.length === 1) {
|
|
1919
|
+
return `속한 곳이 하나입니다 — **${list[0].name}**\n`
|
|
1920
|
+
+ '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.';
|
|
1921
|
+
}
|
|
1922
|
+
const lines = list.map((t) => {
|
|
1923
|
+
const when = t.last_used_at ? String(t.last_used_at).slice(0, 10) : '기록 없음';
|
|
1924
|
+
// 「여기서 N건」은 **순서의 이유**이지 정답이 아니다 — 그렇게 읽히게 적는다.
|
|
1925
|
+
const here = t.sessions_from_here ? ` · 이 폴더에서 ${t.sessions_from_here}건` : '';
|
|
1926
|
+
return ` · ${t.name}${here} · 최근 ${when}`;
|
|
1927
|
+
});
|
|
1928
|
+
return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
|
|
1929
|
+
+ `${lines.join('\n')}\n`
|
|
1930
|
+
+ (r.cwd ? `(순서는 지금 폴더 ${r.cwd} 기준입니다. 순서일 뿐 정답이 아닙니다.)` : '');
|
|
1931
|
+
}
|
|
1768
1932
|
case 'logout': {
|
|
1769
1933
|
if (!credRead()) return '이 기계에는 심긴 로그인 자격이 없습니다.';
|
|
1770
1934
|
fs.rmSync(CRED_FILE, { force: true });
|
|
@@ -1803,7 +1967,7 @@ async function callTool(name, args = {}) {
|
|
|
1803
1967
|
return `#${x.id} ${tn}${x.title} (${x.published_at || '미발행'})${sl}${tag}\n ${x.url}`;
|
|
1804
1968
|
});
|
|
1805
1969
|
// 서버가 `tenant: null` 을 주면 「고객사 하나에 매인 자격이 아니다」는 뜻이다.
|
|
1806
|
-
const head = r.tenant === null ? '
|
|
1970
|
+
const head = r.tenant === null ? '내 소속처 전부' : `소속처: ${r.tenant}`;
|
|
1807
1971
|
/*
|
|
1808
1972
|
* pagination이 없는 옛 서버와도 물린다. 새 서버면 **전체 건수와 현재 범위**를 말하고,
|
|
1809
1973
|
* next를 그대로 다음 도구 호출로 보여 준다 — 첫 100건만 보인 채 끝나는 막다른 길을
|
|
@@ -1965,14 +2129,16 @@ async function callTool(name, args = {}) {
|
|
|
1965
2129
|
+ (follow.length ? `\n\n${follow.join('\n')}` : '');
|
|
1966
2130
|
}
|
|
1967
2131
|
case 'push_session': {
|
|
1968
|
-
const { r, before } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run);
|
|
2132
|
+
const { r, before, parts } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run);
|
|
1969
2133
|
const cut = `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
|
|
2134
|
+
// 조각으로 갔으면 말한다 — 크기 때문에 사람이 할 일이 없다는 것을 결과가 보여 준다.
|
|
2135
|
+
const how = parts > 1 ? ` · ${parts}조각으로` : '';
|
|
1970
2136
|
if (r.dry_run) {
|
|
1971
2137
|
return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
|
|
1972
|
-
+ `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}`;
|
|
2138
|
+
+ `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}`;
|
|
1973
2139
|
}
|
|
1974
2140
|
return `${r.replaced ? '갈아 끼웠습니다' : '올렸습니다'} — ${r.tenant} · ${args.uuid.slice(0, 8)}\n`
|
|
1975
|
-
+ `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}\n`
|
|
2141
|
+
+ `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}${how}\n`
|
|
1976
2142
|
+ '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`';
|
|
1977
2143
|
}
|
|
1978
2144
|
case 'resume_session': {
|
|
@@ -1983,10 +2149,22 @@ async function callTool(name, args = {}) {
|
|
|
1983
2149
|
* 그래서 서버가 구운 요지를 **응답 본문에 실어** 지금 창의 맥락에 그대로 들어가게 한다 —
|
|
1984
2150
|
* 붙여넣기+엔터 한 번으로 끝나야 「이어받기」이지, 창을 새로 열어야 하면 절차서다.
|
|
1985
2151
|
*/
|
|
2152
|
+
/*
|
|
2153
|
+
* **두 부름으로 나눈다.**
|
|
2154
|
+
*
|
|
2155
|
+
* 예전에는 한 번에 받았다 — 요지와 본문이 같은 JSON 봉투에 실려 왔고, 본문 10MB 가
|
|
2156
|
+
* 문자열로 힙에 올라온 뒤 `writeFileSync` 가 또 한 벌을 만들었다. 그런데 **본문은 이 창이
|
|
2157
|
+
* 안 읽는다**(한 줄이 197KB 까지 간다). 창이 읽는 것은 요지뿐이다.
|
|
2158
|
+
*
|
|
2159
|
+
* ① `?no_body=1&digest=1` → 요지와 메타만. 서버가 본문 파일을 **읽지도 않는다**.
|
|
2160
|
+
* ② `?raw=1` → ndjson 을 파일로 **흘린다**(apiToFile). 봉투 0벌.
|
|
2161
|
+
*
|
|
2162
|
+
* ②는 파일을 놓을 때만 부른다 — 이미 있으면 안 덮으므로 받을 이유도 없다.
|
|
2163
|
+
*/
|
|
1986
2164
|
const chars = Math.max(2000, Math.min(80000, args.chars || 40000));
|
|
2165
|
+
const q = `?tenant=${encodeURIComponent(args.tenant)}`;
|
|
1987
2166
|
const r = await api('GET',
|
|
1988
|
-
`/api/sessions/${encodeURIComponent(args.uuid)}
|
|
1989
|
-
+ `&digest=1&digest_chars=${chars}`);
|
|
2167
|
+
`/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
|
|
1990
2168
|
indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
|
|
1991
2169
|
|
|
1992
2170
|
// 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
|
|
@@ -1997,8 +2175,7 @@ async function callTool(name, args = {}) {
|
|
|
1997
2175
|
if (fs.existsSync(target)) {
|
|
1998
2176
|
placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
|
|
1999
2177
|
} else {
|
|
2000
|
-
|
|
2001
|
-
fs.writeFileSync(target, r.jsonl, { mode: 0o600 });
|
|
2178
|
+
await apiToFile(`/api/sessions/${encodeURIComponent(args.uuid)}${q}&raw=1`, target);
|
|
2002
2179
|
}
|
|
2003
2180
|
|
|
2004
2181
|
/*
|
|
@@ -2033,22 +2210,29 @@ async function callTool(name, args = {}) {
|
|
|
2033
2210
|
+ `\n──────── 이어받은 대화 ────────\n\n${r.digest || '(요지가 비어 있습니다)'}`;
|
|
2034
2211
|
}
|
|
2035
2212
|
case 'pull_session': {
|
|
2036
|
-
const r = await api('GET',
|
|
2037
|
-
`/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}`);
|
|
2038
2213
|
const into = args.into || process.cwd();
|
|
2039
2214
|
const dir = transcriptDir(into);
|
|
2040
2215
|
const target = path.join(dir, `${args.uuid}.jsonl`);
|
|
2041
|
-
|
|
2216
|
+
/*
|
|
2217
|
+
* **덮지 않는다.** 같은 uuid 가 이 기계에도 있으면 여기서 하던 대화가 사라진다.
|
|
2218
|
+
* ★ 그 검사를 **받기 전에** 한다 — 이 도구는 본문만 받으므로, 뒤에 두면 10MB 를
|
|
2219
|
+
* 다 받아 놓고 버리게 된다(옛 코드가 그랬다).
|
|
2220
|
+
*/
|
|
2042
2221
|
if (fs.existsSync(target)) {
|
|
2043
2222
|
throw new Error(`이미 있습니다: ${target}\n덮지 않았습니다 — 이 기계에서 하던 대화가 사라집니다.`);
|
|
2044
2223
|
}
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2224
|
+
// 봉투를 안 만든다 — 메타는 헤더로 온다(apiToFile 주석).
|
|
2225
|
+
const { headers, bytes } = await apiToFile(
|
|
2226
|
+
`/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
|
|
2227
|
+
target);
|
|
2228
|
+
const m = limMeta(headers);
|
|
2229
|
+
indexNote(args.uuid, { tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms });
|
|
2230
|
+
// 헤더를 안 주는 옛 서버면 **지어내지 않는다** — 받은 바이트만 말한다.
|
|
2231
|
+
const size = `${((m.bytes ?? bytes) / 1024).toFixed(0)}KB`;
|
|
2232
|
+
return `받았습니다 — ${args.uuid.slice(0, 8)} · ${m.lines === null ? '' : `${m.lines}줄 · `}${size}\n`
|
|
2049
2233
|
+ `놓은 자리: ${target}\n`
|
|
2050
2234
|
+ `이어서 말하려면: cd ${into} && claude --resume ${args.uuid}\n`
|
|
2051
|
-
+ (
|
|
2235
|
+
+ (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
|
|
2052
2236
|
}
|
|
2053
2237
|
default:
|
|
2054
2238
|
throw new Error(`알 수 없는 도구: ${name}`);
|
|
@@ -2131,7 +2315,7 @@ async function handle(line) {
|
|
|
2131
2315
|
* **도구 사이의 관계**만 적는다 — 중복하면 둘이 갈라진다.
|
|
2132
2316
|
*/
|
|
2133
2317
|
instructions: [
|
|
2134
|
-
'
|
|
2318
|
+
'genetics — 소속처 문서 발행, 작업 세션 이식, 사례 기억을 다룬다.',
|
|
2135
2319
|
'',
|
|
2136
2320
|
'무엇을 언제 쓰는가(관계만; 각 도구의 발동 조건은 그 도구 설명에 있다):',
|
|
2137
2321
|
'· 고객에게 나갈 문서를 만들면 publish_report → release_report. 이미 나간 문서를 고치면 update_report.',
|
|
@@ -2145,8 +2329,10 @@ async function handle(line) {
|
|
|
2145
2329
|
'· 고객에게 나갈 문서 → publish_report / update_report',
|
|
2146
2330
|
'무엇인지 모호하면 묻기 전에 search_cases 로 먼저 뒤져 본다 — 이미 있으면 갱신이 답이다.',
|
|
2147
2331
|
'',
|
|
2148
|
-
'자격은
|
|
2149
|
-
'
|
|
2332
|
+
'자격은 **`login` 으로 받는 SSO 하나면 된다** — 그것으로 내가 속한 소속처 전부에 닿는다.',
|
|
2333
|
+
'키(rpk_)도 쓸 수 있지만 **한 소속처에만** 매이고, 발행할 때만 그쪽이 먼저 쓰인다.',
|
|
2334
|
+
'어디로 갈지는 tenant 에 소속처 **이름**으로 적는다. 이름을 모르면 지어내지 말고 사용자에게 묻는다 —',
|
|
2335
|
+
'내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
|
|
2150
2336
|
].join('\n'),
|
|
2151
2337
|
};
|
|
2152
2338
|
} else if (req.method === 'tools/list') {
|