genetics-mcp 2.2.1 → 2.4.1
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
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
|
// 포털 자체는 문서 종류를 가리지 않지만, 데이터로 발행하는 길은 아직 이 서식뿐이다.
|
|
@@ -1018,28 +1079,34 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
1018
1079
|
*
|
|
1019
1080
|
* ⚠ **줄 경계에서만 나눈다.** 검열도 비밀 재검사도 줄 단위라, 한 줄이 두 조각에 걸치면
|
|
1020
1081
|
* 검열을 안 거친 반쪽이 올라간다. 서버도 같은 것을 확인하고 거절한다(두 겹).
|
|
1082
|
+
*
|
|
1083
|
+
* ★ **dry_run 도 같은 길을 간다.** 전에는 `|| dryRun` 으로 시험 실행만 통째로 보냈고, 그
|
|
1084
|
+
* 본문이 봉투 상한(16MB)에 걸려 413 — 긴 대화는 **시험 실행이 불가능**했다(2026-08-21 운영,
|
|
1085
|
+
* 검열 뒤 43.9MB). 「먼저 dry_run」이 권장 절차라 사실상 올릴 길이 없었다. 서버가 조각
|
|
1086
|
+
* dry_run 을 받는다(조각마다 세고 파일만 안 만든다). `body.dry_run` 이 조각마다 실린다.
|
|
1021
1087
|
*/
|
|
1022
1088
|
const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
|
|
1023
|
-
if (parts.length === 1
|
|
1089
|
+
if (parts.length === 1) {
|
|
1024
1090
|
const r = await api('PUT', '/api/sessions', body);
|
|
1025
1091
|
if (!dryRun) indexNote(uuid, { tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
|
|
1026
|
-
return { r, before };
|
|
1092
|
+
return { r, before, parts: 1 };
|
|
1027
1093
|
}
|
|
1028
1094
|
|
|
1029
1095
|
const uploadId = crypto.randomBytes(12).toString('hex');
|
|
1030
1096
|
let last = null;
|
|
1031
1097
|
for (let i = 0; i < parts.length; i++) {
|
|
1032
|
-
// 메타(
|
|
1098
|
+
// 메타(제목·경로·도구·dry_run)는 **조각마다** 싣는다. 작고, 마지막 조각에서만 쓰이지만
|
|
1033
1099
|
// 어느 조각이 마지막인지 서버가 아는 것과 부르는 쪽이 아는 것이 어긋날 이유가 없다.
|
|
1034
1100
|
last = await api('PUT', '/api/sessions', {
|
|
1035
1101
|
...body, jsonl: parts[i],
|
|
1036
1102
|
upload_id: uploadId, part: i + 1, parts: parts.length,
|
|
1037
1103
|
});
|
|
1038
|
-
|
|
1039
|
-
|
|
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`);
|
|
1040
1107
|
}
|
|
1041
|
-
indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms });
|
|
1042
|
-
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 };
|
|
1043
1110
|
}
|
|
1044
1111
|
|
|
1045
1112
|
/* ── 코로케이션 심박 ───────────────────────────────────────────────────
|
|
@@ -1453,7 +1520,17 @@ const TOOLS = [
|
|
|
1453
1520
|
},
|
|
1454
1521
|
{
|
|
1455
1522
|
name: 'whoami',
|
|
1456
|
-
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
|
+
+ '그것은 **기록이지 근거가 아니다**: 서버가 한 곳으로 좁히지 못하거나 자리를 못 읽으면 '
|
|
1533
|
+
+ '그렇게 말한다. 좁혀지지 않으면 **사용자에게 물어라.**',
|
|
1457
1534
|
inputSchema: { type: 'object', properties: {} },
|
|
1458
1535
|
},
|
|
1459
1536
|
{
|
|
@@ -1464,7 +1541,7 @@ const TOOLS = [
|
|
|
1464
1541
|
{
|
|
1465
1542
|
name: 'list_reports',
|
|
1466
1543
|
description:
|
|
1467
|
-
'
|
|
1544
|
+
'내가 닿을 수 있는 문서 목록(사람으로 붙으면 소속처 전부, 키면 그 한 곳). 각 줄의 대괄호가 **문서 상태**이고, ' +
|
|
1468
1545
|
'무엇으로 만들어졌는지·API 로 읽고 쓸 수 있는지를 서버가 판정해 준다. ' +
|
|
1469
1546
|
'편집기가 정본인 문서는 API 로 못 고친다 — 사람이 편집 화면에서 고쳐야 한다. ' +
|
|
1470
1547
|
'응답 끝의 다음 page 안내를 따라가면 100건 뒤의 문서도 볼 수 있다.',
|
|
@@ -1504,7 +1581,7 @@ const TOOLS = [
|
|
|
1504
1581
|
{
|
|
1505
1582
|
name: 'publish_report',
|
|
1506
1583
|
description:
|
|
1507
|
-
'새 보고서를 만든다.
|
|
1584
|
+
'새 보고서를 만든다. 키로 붙어 있으면 소속처는 키가 정한다. ' +
|
|
1508
1585
|
'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
|
|
1509
1586
|
'**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
|
|
1510
1587
|
'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
|
|
@@ -1512,7 +1589,7 @@ const TOOLS = [
|
|
|
1512
1589
|
inputSchema: {
|
|
1513
1590
|
type: 'object',
|
|
1514
1591
|
properties: {
|
|
1515
|
-
tenant: { type: 'string', description: '
|
|
1592
|
+
tenant: { type: 'string', description: '문서를 받을 소속처 이름. 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
|
|
1516
1593
|
slug: { type: 'string', description: '파일 이름이 될 식별자. 영문 소문자·숫자·하이픈 (예: fixes-20260812)' },
|
|
1517
1594
|
published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
|
|
1518
1595
|
publish: {
|
|
@@ -1536,7 +1613,7 @@ const TOOLS = [
|
|
|
1536
1613
|
inputSchema: {
|
|
1537
1614
|
type: 'object',
|
|
1538
1615
|
properties: {
|
|
1539
|
-
tenant: { type: 'string', description: '대상
|
|
1616
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
|
|
1540
1617
|
id: { type: 'integer' },
|
|
1541
1618
|
published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
|
|
1542
1619
|
},
|
|
@@ -1551,7 +1628,7 @@ const TOOLS = [
|
|
|
1551
1628
|
inputSchema: {
|
|
1552
1629
|
type: 'object',
|
|
1553
1630
|
properties: {
|
|
1554
|
-
tenant: { type: 'string', description: '대상
|
|
1631
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
|
|
1555
1632
|
id: { type: 'integer' },
|
|
1556
1633
|
reason: { type: 'string', description: '왜 내리는지. 서버 로그에 남아 다음 사람이 이유를 안다.' },
|
|
1557
1634
|
},
|
|
@@ -1580,7 +1657,7 @@ const TOOLS = [
|
|
|
1580
1657
|
inputSchema: {
|
|
1581
1658
|
type: 'object',
|
|
1582
1659
|
properties: {
|
|
1583
|
-
tenant: { type: 'string', description: '대상
|
|
1660
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다(오발행 방지).' },
|
|
1584
1661
|
id: { type: 'integer' },
|
|
1585
1662
|
dry_run: {
|
|
1586
1663
|
type: 'boolean',
|
|
@@ -1595,7 +1672,7 @@ const TOOLS = [
|
|
|
1595
1672
|
{
|
|
1596
1673
|
name: 'remember_case',
|
|
1597
1674
|
description:
|
|
1598
|
-
'**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그
|
|
1675
|
+
'**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그 소속처 지식의 `cases/` 에 적히고 '
|
|
1599
1676
|
+ '다음에 누가(어느 기계에서든) `search_cases` 로 찾아 쓴다. 같은 제목이면 **덮어쓴다** — '
|
|
1600
1677
|
+ '그래서 「갱신」·「업데이트」도 이 도구다.\n'
|
|
1601
1678
|
+ '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
|
|
@@ -1611,7 +1688,7 @@ const TOOLS = [
|
|
|
1611
1688
|
inputSchema: {
|
|
1612
1689
|
type: 'object',
|
|
1613
1690
|
properties: {
|
|
1614
|
-
tenant: { type: 'string', description: '대상
|
|
1691
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1615
1692
|
title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
|
|
1616
1693
|
symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
|
|
1617
1694
|
cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
|
|
@@ -1625,7 +1702,7 @@ const TOOLS = [
|
|
|
1625
1702
|
{
|
|
1626
1703
|
name: 'search_cases',
|
|
1627
1704
|
description:
|
|
1628
|
-
'**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그
|
|
1705
|
+
'**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그 소속처 지식(사례·개념·대상 전부)을 '
|
|
1629
1706
|
+ '증상 문장으로 훑어 점수순으로 돌려준다.\n'
|
|
1630
1707
|
+ '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
|
|
1631
1708
|
+ ' "전에도 이런 적 있었나" · "우리 이거 겪지 않았나" · "지식에 있나 찾아봐" · '
|
|
@@ -1637,7 +1714,7 @@ const TOOLS = [
|
|
|
1637
1714
|
inputSchema: {
|
|
1638
1715
|
type: 'object',
|
|
1639
1716
|
properties: {
|
|
1640
|
-
tenant: { type: 'string', description: '대상
|
|
1717
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1641
1718
|
q: { type: 'string', description: '증상 문장. 원인이 아니라 현상으로.' },
|
|
1642
1719
|
limit: { type: 'integer', minimum: 1, maximum: 20, description: '몇 건까지(기본 5).' },
|
|
1643
1720
|
fast: { type: 'boolean', description: '의미 확장을 건너뛴다(빠르지만 덜 찾는다). 기본은 확장 켬 — '
|
|
@@ -1649,12 +1726,12 @@ const TOOLS = [
|
|
|
1649
1726
|
{
|
|
1650
1727
|
name: 'list_sessions',
|
|
1651
1728
|
description:
|
|
1652
|
-
'이
|
|
1729
|
+
'이 소속처에 올라와 있는 **작업 세션 대화** 목록. 담당자가 부재일 때 무엇을 이어받을 수 있는지 본다. '
|
|
1653
1730
|
+ '응답 끝의 다음 page 안내를 따라가면 100건 뒤의 세션도 볼 수 있다.',
|
|
1654
1731
|
inputSchema: {
|
|
1655
1732
|
type: 'object',
|
|
1656
1733
|
properties: {
|
|
1657
|
-
tenant: { type: 'string', description: '대상
|
|
1734
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1658
1735
|
page: {
|
|
1659
1736
|
type: 'integer', minimum: 1, maximum: 1000,
|
|
1660
1737
|
description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
|
|
@@ -1674,11 +1751,12 @@ const TOOLS = [
|
|
|
1674
1751
|
'`claude --resume <uuid>` 로 이어 갈 수 있다.\n' +
|
|
1675
1752
|
'올라가지 않는 것: 도구 실행 결과·첨부·파일 이력·집 경로·비밀 모양(토큰·비밀번호·개인키). ' +
|
|
1676
1753
|
'**도구 호출의 짝과 인자는 남는다** — 그게 없으면 대화가 안 열린다.\n' +
|
|
1677
|
-
'
|
|
1754
|
+
'긴 대화는 **알아서 조각으로** 나눠 보내고 서버가 잇는다 — 크기 때문에 사람이 할 일은 없다.\n' +
|
|
1755
|
+
'쓰기 전에 무엇이 빠지는지만 보고 싶으면 dry_run: true(조각도 같은 길을 간다).',
|
|
1678
1756
|
inputSchema: {
|
|
1679
1757
|
type: 'object',
|
|
1680
1758
|
properties: {
|
|
1681
|
-
tenant: { type: 'string', description: '대상
|
|
1759
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1682
1760
|
uuid: { type: 'string', description: '세션 id. `--resume` 에 쓰는 그 값.' },
|
|
1683
1761
|
title: { type: 'string', description: '목록에서 알아볼 한 줄. 생략하면 빈 값.' },
|
|
1684
1762
|
dry_run: { type: 'boolean', description: '참이면 아무것도 저장하지 않고 검열 결과만 돌려준다.' },
|
|
@@ -1702,7 +1780,7 @@ const TOOLS = [
|
|
|
1702
1780
|
inputSchema: {
|
|
1703
1781
|
type: 'object',
|
|
1704
1782
|
properties: {
|
|
1705
|
-
tenant: { type: 'string', description: '대상
|
|
1783
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1706
1784
|
uuid: { type: 'string', description: '이어받을 세션 id.' },
|
|
1707
1785
|
into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
|
|
1708
1786
|
+ '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
|
|
@@ -1722,7 +1800,7 @@ const TOOLS = [
|
|
|
1722
1800
|
inputSchema: {
|
|
1723
1801
|
type: 'object',
|
|
1724
1802
|
properties: {
|
|
1725
|
-
tenant: { type: 'string', description: '대상
|
|
1803
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1726
1804
|
uuid: { type: 'string', description: '받을 세션 id.' },
|
|
1727
1805
|
into: { type: 'string', description: '어느 디렉터리에서 이어 갈 것인가(생략하면 지금 폴더). '
|
|
1728
1806
|
+ '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 이어 갈 자리다.' },
|
|
@@ -1799,7 +1877,7 @@ async function callTool(name, args = {}) {
|
|
|
1799
1877
|
base: BASE,
|
|
1800
1878
|
});
|
|
1801
1879
|
const warn = KEY
|
|
1802
|
-
? `\n\n⚠ 이 기계에는
|
|
1880
|
+
? `\n\n⚠ 이 기계에는 소속처 키도 있습니다. **발행은 키로 나갑니다**(좁은 쪽이 기본). `
|
|
1803
1881
|
+ `로그인 자격으로 쓰려면 ${ENV_FILE} 의 GENETICS_API_KEY 를 지우세요.`
|
|
1804
1882
|
: '';
|
|
1805
1883
|
return `로그인했습니다 — ${data.user.name}(${data.user.email}) · ${data.user.role}\n`
|
|
@@ -1827,6 +1905,51 @@ async function callTool(name, args = {}) {
|
|
|
1827
1905
|
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
|
|
1828
1906
|
}
|
|
1829
1907
|
|
|
1908
|
+
case 'list_my_tenants': {
|
|
1909
|
+
/*
|
|
1910
|
+
* **폴더를 보내되, 그것으로 고르지 않는다.** 서버는 이 값으로 순서만 매기고 `guess` 를
|
|
1911
|
+
* 안 준다(api.php 의 `/api/tenants` 주석). 여기서도 하나를 골라 주지 않는다 —
|
|
1912
|
+
* **후보가 하나뿐일 때만** 그것이 답이고, 그건 추론이 아니라 선택지가 없는 것이다(§6-4).
|
|
1913
|
+
*
|
|
1914
|
+
* 접는 규칙은 올릴 때와 **같은 함수**를 쓴다. 다르면 서버에 쌓인 값과 안 맞아 조용히 0건이 된다.
|
|
1915
|
+
*/
|
|
1916
|
+
const r = await api('GET', `/api/tenants?cwd=${encodeURIComponent(relHome(process.cwd()))}`);
|
|
1917
|
+
const list = Array.isArray(r.tenants) ? r.tenants : [];
|
|
1918
|
+
if (!list.length) return '속한 곳이 없습니다. 관리자에게 담당 지정을 요청하세요.';
|
|
1919
|
+
|
|
1920
|
+
/*
|
|
1921
|
+
* ⚠ **서버가 쓴 문장을 그대로 싣는다.** 여기서 다시 말을 지어내면 두 벌이 되고,
|
|
1922
|
+
* 그 두 벌은 반드시 갈린다. 서버는 4분법(한 곳/여러 곳/못 봤다/못 읽었다)으로만
|
|
1923
|
+
* 답하고 **점수를 안 준다** — 우도비는 결정에 아무것도 안 더하면서 오용에는
|
|
1924
|
+
* 전부를 더하기 때문이다(api.php 의 /api/tenants 머리말).
|
|
1925
|
+
*
|
|
1926
|
+
* ⚠ 옛 서버는 `place_reading` 을 안 준다 — 그때는 **아무 말도 안 한다**(빈 문자열).
|
|
1927
|
+
* 없는 값을 「0건」이나 「모름」으로 옮기면 그것이 또 근거로 읽힌다.
|
|
1928
|
+
*/
|
|
1929
|
+
const says = (r.place_reading && typeof r.place_reading.says === 'string')
|
|
1930
|
+
? r.place_reading.says : '';
|
|
1931
|
+
const seen = (t) => {
|
|
1932
|
+
const p = t.place;
|
|
1933
|
+
if (!p) return '';
|
|
1934
|
+
const rel = p.match === 'exact' ? '같은 자리' : '위아래 자리';
|
|
1935
|
+
// **개수가 아니라 날 수다** — 같은 날 백 번 올려도 1이다.
|
|
1936
|
+
const d = Number(p.attested_days) > 0 ? ` ${p.attested_days}일` : '';
|
|
1937
|
+
return ` · ${rel}${d}`;
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
if (list.length === 1) {
|
|
1941
|
+
return `속한 곳이 하나입니다 — **${list[0].name}**\n`
|
|
1942
|
+
+ '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.'
|
|
1943
|
+
+ (says ? `\n\n${says}` : '');
|
|
1944
|
+
}
|
|
1945
|
+
const lines = list.map((t) => {
|
|
1946
|
+
const when = t.last_used_at ? String(t.last_used_at).slice(0, 10) : '기록 없음';
|
|
1947
|
+
return ` · ${t.name}${seen(t)} · 최근 ${when}`;
|
|
1948
|
+
});
|
|
1949
|
+
return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
|
|
1950
|
+
+ `${lines.join('\n')}\n`
|
|
1951
|
+
+ (says ? `\n${says}` : '');
|
|
1952
|
+
}
|
|
1830
1953
|
case 'logout': {
|
|
1831
1954
|
if (!credRead()) return '이 기계에는 심긴 로그인 자격이 없습니다.';
|
|
1832
1955
|
fs.rmSync(CRED_FILE, { force: true });
|
|
@@ -1865,7 +1988,7 @@ async function callTool(name, args = {}) {
|
|
|
1865
1988
|
return `#${x.id} ${tn}${x.title} (${x.published_at || '미발행'})${sl}${tag}\n ${x.url}`;
|
|
1866
1989
|
});
|
|
1867
1990
|
// 서버가 `tenant: null` 을 주면 「고객사 하나에 매인 자격이 아니다」는 뜻이다.
|
|
1868
|
-
const head = r.tenant === null ? '
|
|
1991
|
+
const head = r.tenant === null ? '내 소속처 전부' : `소속처: ${r.tenant}`;
|
|
1869
1992
|
/*
|
|
1870
1993
|
* pagination이 없는 옛 서버와도 물린다. 새 서버면 **전체 건수와 현재 범위**를 말하고,
|
|
1871
1994
|
* next를 그대로 다음 도구 호출로 보여 준다 — 첫 100건만 보인 채 끝나는 막다른 길을
|
|
@@ -2027,14 +2150,16 @@ async function callTool(name, args = {}) {
|
|
|
2027
2150
|
+ (follow.length ? `\n\n${follow.join('\n')}` : '');
|
|
2028
2151
|
}
|
|
2029
2152
|
case 'push_session': {
|
|
2030
|
-
const { r, before } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run);
|
|
2153
|
+
const { r, before, parts } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run);
|
|
2031
2154
|
const cut = `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
|
|
2155
|
+
// 조각으로 갔으면 말한다 — 크기 때문에 사람이 할 일이 없다는 것을 결과가 보여 준다.
|
|
2156
|
+
const how = parts > 1 ? ` · ${parts}조각으로` : '';
|
|
2032
2157
|
if (r.dry_run) {
|
|
2033
2158
|
return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
|
|
2034
|
-
+ `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}`;
|
|
2159
|
+
+ `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}`;
|
|
2035
2160
|
}
|
|
2036
2161
|
return `${r.replaced ? '갈아 끼웠습니다' : '올렸습니다'} — ${r.tenant} · ${args.uuid.slice(0, 8)}\n`
|
|
2037
|
-
+ `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}\n`
|
|
2162
|
+
+ `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}${how}\n`
|
|
2038
2163
|
+ '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`';
|
|
2039
2164
|
}
|
|
2040
2165
|
case 'resume_session': {
|
|
@@ -2045,10 +2170,22 @@ async function callTool(name, args = {}) {
|
|
|
2045
2170
|
* 그래서 서버가 구운 요지를 **응답 본문에 실어** 지금 창의 맥락에 그대로 들어가게 한다 —
|
|
2046
2171
|
* 붙여넣기+엔터 한 번으로 끝나야 「이어받기」이지, 창을 새로 열어야 하면 절차서다.
|
|
2047
2172
|
*/
|
|
2173
|
+
/*
|
|
2174
|
+
* **두 부름으로 나눈다.**
|
|
2175
|
+
*
|
|
2176
|
+
* 예전에는 한 번에 받았다 — 요지와 본문이 같은 JSON 봉투에 실려 왔고, 본문 10MB 가
|
|
2177
|
+
* 문자열로 힙에 올라온 뒤 `writeFileSync` 가 또 한 벌을 만들었다. 그런데 **본문은 이 창이
|
|
2178
|
+
* 안 읽는다**(한 줄이 197KB 까지 간다). 창이 읽는 것은 요지뿐이다.
|
|
2179
|
+
*
|
|
2180
|
+
* ① `?no_body=1&digest=1` → 요지와 메타만. 서버가 본문 파일을 **읽지도 않는다**.
|
|
2181
|
+
* ② `?raw=1` → ndjson 을 파일로 **흘린다**(apiToFile). 봉투 0벌.
|
|
2182
|
+
*
|
|
2183
|
+
* ②는 파일을 놓을 때만 부른다 — 이미 있으면 안 덮으므로 받을 이유도 없다.
|
|
2184
|
+
*/
|
|
2048
2185
|
const chars = Math.max(2000, Math.min(80000, args.chars || 40000));
|
|
2186
|
+
const q = `?tenant=${encodeURIComponent(args.tenant)}`;
|
|
2049
2187
|
const r = await api('GET',
|
|
2050
|
-
`/api/sessions/${encodeURIComponent(args.uuid)}
|
|
2051
|
-
+ `&digest=1&digest_chars=${chars}`);
|
|
2188
|
+
`/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
|
|
2052
2189
|
indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
|
|
2053
2190
|
|
|
2054
2191
|
// 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
|
|
@@ -2059,8 +2196,7 @@ async function callTool(name, args = {}) {
|
|
|
2059
2196
|
if (fs.existsSync(target)) {
|
|
2060
2197
|
placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
|
|
2061
2198
|
} else {
|
|
2062
|
-
|
|
2063
|
-
fs.writeFileSync(target, r.jsonl, { mode: 0o600 });
|
|
2199
|
+
await apiToFile(`/api/sessions/${encodeURIComponent(args.uuid)}${q}&raw=1`, target);
|
|
2064
2200
|
}
|
|
2065
2201
|
|
|
2066
2202
|
/*
|
|
@@ -2095,22 +2231,29 @@ async function callTool(name, args = {}) {
|
|
|
2095
2231
|
+ `\n──────── 이어받은 대화 ────────\n\n${r.digest || '(요지가 비어 있습니다)'}`;
|
|
2096
2232
|
}
|
|
2097
2233
|
case 'pull_session': {
|
|
2098
|
-
const r = await api('GET',
|
|
2099
|
-
`/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}`);
|
|
2100
2234
|
const into = args.into || process.cwd();
|
|
2101
2235
|
const dir = transcriptDir(into);
|
|
2102
2236
|
const target = path.join(dir, `${args.uuid}.jsonl`);
|
|
2103
|
-
|
|
2237
|
+
/*
|
|
2238
|
+
* **덮지 않는다.** 같은 uuid 가 이 기계에도 있으면 여기서 하던 대화가 사라진다.
|
|
2239
|
+
* ★ 그 검사를 **받기 전에** 한다 — 이 도구는 본문만 받으므로, 뒤에 두면 10MB 를
|
|
2240
|
+
* 다 받아 놓고 버리게 된다(옛 코드가 그랬다).
|
|
2241
|
+
*/
|
|
2104
2242
|
if (fs.existsSync(target)) {
|
|
2105
2243
|
throw new Error(`이미 있습니다: ${target}\n덮지 않았습니다 — 이 기계에서 하던 대화가 사라집니다.`);
|
|
2106
2244
|
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2245
|
+
// 봉투를 안 만든다 — 메타는 헤더로 온다(apiToFile 주석).
|
|
2246
|
+
const { headers, bytes } = await apiToFile(
|
|
2247
|
+
`/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
|
|
2248
|
+
target);
|
|
2249
|
+
const m = limMeta(headers);
|
|
2250
|
+
indexNote(args.uuid, { tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms });
|
|
2251
|
+
// 헤더를 안 주는 옛 서버면 **지어내지 않는다** — 받은 바이트만 말한다.
|
|
2252
|
+
const size = `${((m.bytes ?? bytes) / 1024).toFixed(0)}KB`;
|
|
2253
|
+
return `받았습니다 — ${args.uuid.slice(0, 8)} · ${m.lines === null ? '' : `${m.lines}줄 · `}${size}\n`
|
|
2111
2254
|
+ `놓은 자리: ${target}\n`
|
|
2112
2255
|
+ `이어서 말하려면: cd ${into} && claude --resume ${args.uuid}\n`
|
|
2113
|
-
+ (
|
|
2256
|
+
+ (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
|
|
2114
2257
|
}
|
|
2115
2258
|
default:
|
|
2116
2259
|
throw new Error(`알 수 없는 도구: ${name}`);
|
|
@@ -2193,7 +2336,7 @@ async function handle(line) {
|
|
|
2193
2336
|
* **도구 사이의 관계**만 적는다 — 중복하면 둘이 갈라진다.
|
|
2194
2337
|
*/
|
|
2195
2338
|
instructions: [
|
|
2196
|
-
'
|
|
2339
|
+
'genetics — 소속처 문서 발행, 작업 세션 이식, 사례 기억을 다룬다.',
|
|
2197
2340
|
'',
|
|
2198
2341
|
'무엇을 언제 쓰는가(관계만; 각 도구의 발동 조건은 그 도구 설명에 있다):',
|
|
2199
2342
|
'· 고객에게 나갈 문서를 만들면 publish_report → release_report. 이미 나간 문서를 고치면 update_report.',
|
|
@@ -2207,8 +2350,10 @@ async function handle(line) {
|
|
|
2207
2350
|
'· 고객에게 나갈 문서 → publish_report / update_report',
|
|
2208
2351
|
'무엇인지 모호하면 묻기 전에 search_cases 로 먼저 뒤져 본다 — 이미 있으면 갱신이 답이다.',
|
|
2209
2352
|
'',
|
|
2210
|
-
'자격은
|
|
2211
|
-
'
|
|
2353
|
+
'자격은 **`login` 으로 받는 SSO 하나면 된다** — 그것으로 내가 속한 소속처 전부에 닿는다.',
|
|
2354
|
+
'키(rpk_)도 쓸 수 있지만 **한 소속처에만** 매이고, 발행할 때만 그쪽이 먼저 쓰인다.',
|
|
2355
|
+
'어디로 갈지는 tenant 에 소속처 **이름**으로 적는다. 이름을 모르면 지어내지 말고 사용자에게 묻는다 —',
|
|
2356
|
+
'내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
|
|
2212
2357
|
].join('\n'),
|
|
2213
2358
|
};
|
|
2214
2359
|
} else if (req.method === 'tools/list') {
|
package/package.json
CHANGED
|
@@ -16,7 +16,22 @@ references/qa.md 의 시각 검수 목록을 따로 돌 것.
|
|
|
16
16
|
import json, pathlib, re, sys
|
|
17
17
|
|
|
18
18
|
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
# ── palette 는 **레포에서** 가져온다 ────────────────────────────────
|
|
21
|
+
#
|
|
22
|
+
# 예전에는 `assets/` 에 `palette.py` 사본이 있어서 위 한 줄이면 됐다. 2026-08-18 에 그 사본을
|
|
23
|
+
# 지웠고(렌더러의 원본은 레포 하나 — render.py 의 refuse_fork 가 `palette.py` 를 금지 목록에
|
|
24
|
+
# 넣어 두었다), **render.py 는 새 방식으로 갔는데 qa.py 는 안 갔다.** 그래서 이 파일은 그날부터
|
|
25
|
+
# `ModuleNotFoundError: No module named 'palette'` 로 죽어 있었다 — 이 저장소의 어느 커밋에도
|
|
26
|
+
# `assets/palette.py` 가 없다(전 역사 0개, 2026-08-22 확인).
|
|
27
|
+
#
|
|
28
|
+
# 찾는 규칙을 **여기서 새로 쓰지 않는다.** render.py 의 것을 그대로 부른다 —
|
|
29
|
+
# 두 벌로 두면 한쪽만 고쳐져 조용히 갈라진다(LIM_PORTAL 로 덮는 길도 함께 따라온다).
|
|
30
|
+
from render import find_builder, refuse_fork # noqa: E402 (경로를 넣은 뒤여야 한다)
|
|
31
|
+
|
|
32
|
+
refuse_fork() # 사본이 다시 생겼으면 여기서 멈춘다
|
|
33
|
+
sys.path.insert(0, str(find_builder().parent)) # <레포>/private/builder
|
|
34
|
+
import palette as P # noqa: E402
|
|
20
35
|
|
|
21
36
|
FONT_ALLOW = {'Pretendard', 'Pretendard Variable', 'Inter', 'Lora', 'Poppins', 'Georgia',
|
|
22
37
|
'맑은 고딕', 'Malgun Gothic', 'JetBrains Mono', '구름 산스 코드',
|
|
@@ -26,7 +26,9 @@ SKILL.md 는 "무엇을 만드는가"를 정하고, 이 문서는 **각 단계
|
|
|
26
26
|
| 8 | 전달 | 고객본 `.html` | **발송 직전 사람 승인** | 내보내기 `confirm` |
|
|
27
27
|
|
|
28
28
|
새 프로젝트에서 손대는 파일은 `report.json` 과 `cases.json` **둘뿐**이다.
|
|
29
|
-
`build.py`·`palette.py`·`report.css`·`editor.js
|
|
29
|
+
`build.py`·`palette.py`·`report.css`·`editor.js` 는 **레포**(`private/builder/`)에 있고,
|
|
30
|
+
`qa.py` 만 이 스킬 자산이다 — 넷은 렌더러라 원본이 레포 하나이고, `qa.py` 는 저작 도구라 여기 남는다.
|
|
31
|
+
다섯 모두 프로젝트 고유 데이터를 하나도 갖고 있지 않다.
|
|
30
32
|
|
|
31
33
|
명령은 두 줄이다.
|
|
32
34
|
|
|
@@ -25,7 +25,10 @@ python3 ~/.claude/skills/genetics-report/assets/qa.py report.json # 그다
|
|
|
25
25
|
`forbid`(금지 정규식), `lang`, `allow_external`, `font.embed` 를 읽는다.
|
|
26
26
|
같은 규칙을 빌드와 검수가 **한 파일에서** 읽으므로 두 벌이 갈라질 수 없다.
|
|
27
27
|
- `out` 경로는 `report.json` 이 있는 디렉터리 기준으로 푼다.
|
|
28
|
-
- `qa.py` 는
|
|
28
|
+
- `qa.py` 는 `palette` 을 **레포에서** 가져온다(`<레포>/private/builder/palette.py`).
|
|
29
|
+
찾는 규칙은 `render.py` 의 `find_builder()` 하나뿐이고 `LIM_PORTAL` 로 덮을 수 있다.
|
|
30
|
+
**`assets/` 에 사본을 두면 안 된다** — `refuse_fork()` 가 그것을 금지 목록으로 막는다
|
|
31
|
+
(2026-08-18 에 사본을 지운 이유: 나흘 만에 887줄이 갈라져 고객 문서가 브랜드 서식 없이 나갔다).
|
|
29
32
|
- 종료 코드: 실패 1건이라도 있으면 `1`, 없으면 `0`. **경고는 종료 코드를 바꾸지 않는다.**
|
|
30
33
|
|
|
31
34
|
실사례 출력(18건, 523,464B):
|