genetics-mcp 2.2.1 → 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 +168 -44
- 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
|
// 포털 자체는 문서 종류를 가리지 않지만, 데이터로 발행하는 길은 아직 이 서식뿐이다.
|
|
@@ -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,16 @@ 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
|
+
+ '같은 폴더에서 여러 곳의 일을 하기도 한다.',
|
|
1457
1533
|
inputSchema: { type: 'object', properties: {} },
|
|
1458
1534
|
},
|
|
1459
1535
|
{
|
|
@@ -1464,7 +1540,7 @@ const TOOLS = [
|
|
|
1464
1540
|
{
|
|
1465
1541
|
name: 'list_reports',
|
|
1466
1542
|
description:
|
|
1467
|
-
'
|
|
1543
|
+
'내가 닿을 수 있는 문서 목록(사람으로 붙으면 소속처 전부, 키면 그 한 곳). 각 줄의 대괄호가 **문서 상태**이고, ' +
|
|
1468
1544
|
'무엇으로 만들어졌는지·API 로 읽고 쓸 수 있는지를 서버가 판정해 준다. ' +
|
|
1469
1545
|
'편집기가 정본인 문서는 API 로 못 고친다 — 사람이 편집 화면에서 고쳐야 한다. ' +
|
|
1470
1546
|
'응답 끝의 다음 page 안내를 따라가면 100건 뒤의 문서도 볼 수 있다.',
|
|
@@ -1504,7 +1580,7 @@ const TOOLS = [
|
|
|
1504
1580
|
{
|
|
1505
1581
|
name: 'publish_report',
|
|
1506
1582
|
description:
|
|
1507
|
-
'새 보고서를 만든다.
|
|
1583
|
+
'새 보고서를 만든다. 키로 붙어 있으면 소속처는 키가 정한다. ' +
|
|
1508
1584
|
'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
|
|
1509
1585
|
'**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
|
|
1510
1586
|
'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
|
|
@@ -1512,7 +1588,7 @@ const TOOLS = [
|
|
|
1512
1588
|
inputSchema: {
|
|
1513
1589
|
type: 'object',
|
|
1514
1590
|
properties: {
|
|
1515
|
-
tenant: { type: 'string', description: '
|
|
1591
|
+
tenant: { type: 'string', description: '문서를 받을 소속처 이름. 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
|
|
1516
1592
|
slug: { type: 'string', description: '파일 이름이 될 식별자. 영문 소문자·숫자·하이픈 (예: fixes-20260812)' },
|
|
1517
1593
|
published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
|
|
1518
1594
|
publish: {
|
|
@@ -1536,7 +1612,7 @@ const TOOLS = [
|
|
|
1536
1612
|
inputSchema: {
|
|
1537
1613
|
type: 'object',
|
|
1538
1614
|
properties: {
|
|
1539
|
-
tenant: { type: 'string', description: '대상
|
|
1615
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
|
|
1540
1616
|
id: { type: 'integer' },
|
|
1541
1617
|
published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
|
|
1542
1618
|
},
|
|
@@ -1551,7 +1627,7 @@ const TOOLS = [
|
|
|
1551
1627
|
inputSchema: {
|
|
1552
1628
|
type: 'object',
|
|
1553
1629
|
properties: {
|
|
1554
|
-
tenant: { type: 'string', description: '대상
|
|
1630
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
|
|
1555
1631
|
id: { type: 'integer' },
|
|
1556
1632
|
reason: { type: 'string', description: '왜 내리는지. 서버 로그에 남아 다음 사람이 이유를 안다.' },
|
|
1557
1633
|
},
|
|
@@ -1580,7 +1656,7 @@ const TOOLS = [
|
|
|
1580
1656
|
inputSchema: {
|
|
1581
1657
|
type: 'object',
|
|
1582
1658
|
properties: {
|
|
1583
|
-
tenant: { type: 'string', description: '대상
|
|
1659
|
+
tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다(오발행 방지).' },
|
|
1584
1660
|
id: { type: 'integer' },
|
|
1585
1661
|
dry_run: {
|
|
1586
1662
|
type: 'boolean',
|
|
@@ -1595,7 +1671,7 @@ const TOOLS = [
|
|
|
1595
1671
|
{
|
|
1596
1672
|
name: 'remember_case',
|
|
1597
1673
|
description:
|
|
1598
|
-
'**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그
|
|
1674
|
+
'**알아낸 것을 팀의 기억에 적는다** — 증상·원인·처방·근거. 그 소속처 지식의 `cases/` 에 적히고 '
|
|
1599
1675
|
+ '다음에 누가(어느 기계에서든) `search_cases` 로 찾아 쓴다. 같은 제목이면 **덮어쓴다** — '
|
|
1600
1676
|
+ '그래서 「갱신」·「업데이트」도 이 도구다.\n'
|
|
1601
1677
|
+ '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
|
|
@@ -1611,7 +1687,7 @@ const TOOLS = [
|
|
|
1611
1687
|
inputSchema: {
|
|
1612
1688
|
type: 'object',
|
|
1613
1689
|
properties: {
|
|
1614
|
-
tenant: { type: 'string', description: '대상
|
|
1690
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1615
1691
|
title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
|
|
1616
1692
|
symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
|
|
1617
1693
|
cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
|
|
@@ -1625,7 +1701,7 @@ const TOOLS = [
|
|
|
1625
1701
|
{
|
|
1626
1702
|
name: 'search_cases',
|
|
1627
1703
|
description:
|
|
1628
|
-
'**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그
|
|
1704
|
+
'**전에 겪은 적이 있는지 팀의 기억을 뒤진다.** 그 소속처 지식(사례·개념·대상 전부)을 '
|
|
1629
1705
|
+ '증상 문장으로 훑어 점수순으로 돌려준다.\n'
|
|
1630
1706
|
+ '언제 부르는가 — 사람이 이런 뜻으로 말할 때 전부 이것이다:\n'
|
|
1631
1707
|
+ ' "전에도 이런 적 있었나" · "우리 이거 겪지 않았나" · "지식에 있나 찾아봐" · '
|
|
@@ -1637,7 +1713,7 @@ const TOOLS = [
|
|
|
1637
1713
|
inputSchema: {
|
|
1638
1714
|
type: 'object',
|
|
1639
1715
|
properties: {
|
|
1640
|
-
tenant: { type: 'string', description: '대상
|
|
1716
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1641
1717
|
q: { type: 'string', description: '증상 문장. 원인이 아니라 현상으로.' },
|
|
1642
1718
|
limit: { type: 'integer', minimum: 1, maximum: 20, description: '몇 건까지(기본 5).' },
|
|
1643
1719
|
fast: { type: 'boolean', description: '의미 확장을 건너뛴다(빠르지만 덜 찾는다). 기본은 확장 켬 — '
|
|
@@ -1649,12 +1725,12 @@ const TOOLS = [
|
|
|
1649
1725
|
{
|
|
1650
1726
|
name: 'list_sessions',
|
|
1651
1727
|
description:
|
|
1652
|
-
'이
|
|
1728
|
+
'이 소속처에 올라와 있는 **작업 세션 대화** 목록. 담당자가 부재일 때 무엇을 이어받을 수 있는지 본다. '
|
|
1653
1729
|
+ '응답 끝의 다음 page 안내를 따라가면 100건 뒤의 세션도 볼 수 있다.',
|
|
1654
1730
|
inputSchema: {
|
|
1655
1731
|
type: 'object',
|
|
1656
1732
|
properties: {
|
|
1657
|
-
tenant: { type: 'string', description: '대상
|
|
1733
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1658
1734
|
page: {
|
|
1659
1735
|
type: 'integer', minimum: 1, maximum: 1000,
|
|
1660
1736
|
description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
|
|
@@ -1674,11 +1750,12 @@ const TOOLS = [
|
|
|
1674
1750
|
'`claude --resume <uuid>` 로 이어 갈 수 있다.\n' +
|
|
1675
1751
|
'올라가지 않는 것: 도구 실행 결과·첨부·파일 이력·집 경로·비밀 모양(토큰·비밀번호·개인키). ' +
|
|
1676
1752
|
'**도구 호출의 짝과 인자는 남는다** — 그게 없으면 대화가 안 열린다.\n' +
|
|
1677
|
-
'
|
|
1753
|
+
'긴 대화는 **알아서 조각으로** 나눠 보내고 서버가 잇는다 — 크기 때문에 사람이 할 일은 없다.\n' +
|
|
1754
|
+
'쓰기 전에 무엇이 빠지는지만 보고 싶으면 dry_run: true(조각도 같은 길을 간다).',
|
|
1678
1755
|
inputSchema: {
|
|
1679
1756
|
type: 'object',
|
|
1680
1757
|
properties: {
|
|
1681
|
-
tenant: { type: 'string', description: '대상
|
|
1758
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1682
1759
|
uuid: { type: 'string', description: '세션 id. `--resume` 에 쓰는 그 값.' },
|
|
1683
1760
|
title: { type: 'string', description: '목록에서 알아볼 한 줄. 생략하면 빈 값.' },
|
|
1684
1761
|
dry_run: { type: 'boolean', description: '참이면 아무것도 저장하지 않고 검열 결과만 돌려준다.' },
|
|
@@ -1702,7 +1779,7 @@ const TOOLS = [
|
|
|
1702
1779
|
inputSchema: {
|
|
1703
1780
|
type: 'object',
|
|
1704
1781
|
properties: {
|
|
1705
|
-
tenant: { type: 'string', description: '대상
|
|
1782
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1706
1783
|
uuid: { type: 'string', description: '이어받을 세션 id.' },
|
|
1707
1784
|
into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
|
|
1708
1785
|
+ '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
|
|
@@ -1722,7 +1799,7 @@ const TOOLS = [
|
|
|
1722
1799
|
inputSchema: {
|
|
1723
1800
|
type: 'object',
|
|
1724
1801
|
properties: {
|
|
1725
|
-
tenant: { type: 'string', description: '대상
|
|
1802
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
1726
1803
|
uuid: { type: 'string', description: '받을 세션 id.' },
|
|
1727
1804
|
into: { type: 'string', description: '어느 디렉터리에서 이어 갈 것인가(생략하면 지금 폴더). '
|
|
1728
1805
|
+ '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 이어 갈 자리다.' },
|
|
@@ -1799,7 +1876,7 @@ async function callTool(name, args = {}) {
|
|
|
1799
1876
|
base: BASE,
|
|
1800
1877
|
});
|
|
1801
1878
|
const warn = KEY
|
|
1802
|
-
? `\n\n⚠ 이 기계에는
|
|
1879
|
+
? `\n\n⚠ 이 기계에는 소속처 키도 있습니다. **발행은 키로 나갑니다**(좁은 쪽이 기본). `
|
|
1803
1880
|
+ `로그인 자격으로 쓰려면 ${ENV_FILE} 의 GENETICS_API_KEY 를 지우세요.`
|
|
1804
1881
|
: '';
|
|
1805
1882
|
return `로그인했습니다 — ${data.user.name}(${data.user.email}) · ${data.user.role}\n`
|
|
@@ -1827,6 +1904,31 @@ async function callTool(name, args = {}) {
|
|
|
1827
1904
|
+ `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}`;
|
|
1828
1905
|
}
|
|
1829
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
|
+
}
|
|
1830
1932
|
case 'logout': {
|
|
1831
1933
|
if (!credRead()) return '이 기계에는 심긴 로그인 자격이 없습니다.';
|
|
1832
1934
|
fs.rmSync(CRED_FILE, { force: true });
|
|
@@ -1865,7 +1967,7 @@ async function callTool(name, args = {}) {
|
|
|
1865
1967
|
return `#${x.id} ${tn}${x.title} (${x.published_at || '미발행'})${sl}${tag}\n ${x.url}`;
|
|
1866
1968
|
});
|
|
1867
1969
|
// 서버가 `tenant: null` 을 주면 「고객사 하나에 매인 자격이 아니다」는 뜻이다.
|
|
1868
|
-
const head = r.tenant === null ? '
|
|
1970
|
+
const head = r.tenant === null ? '내 소속처 전부' : `소속처: ${r.tenant}`;
|
|
1869
1971
|
/*
|
|
1870
1972
|
* pagination이 없는 옛 서버와도 물린다. 새 서버면 **전체 건수와 현재 범위**를 말하고,
|
|
1871
1973
|
* next를 그대로 다음 도구 호출로 보여 준다 — 첫 100건만 보인 채 끝나는 막다른 길을
|
|
@@ -2027,14 +2129,16 @@ async function callTool(name, args = {}) {
|
|
|
2027
2129
|
+ (follow.length ? `\n\n${follow.join('\n')}` : '');
|
|
2028
2130
|
}
|
|
2029
2131
|
case 'push_session': {
|
|
2030
|
-
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);
|
|
2031
2133
|
const cut = `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
|
|
2134
|
+
// 조각으로 갔으면 말한다 — 크기 때문에 사람이 할 일이 없다는 것을 결과가 보여 준다.
|
|
2135
|
+
const how = parts > 1 ? ` · ${parts}조각으로` : '';
|
|
2032
2136
|
if (r.dry_run) {
|
|
2033
2137
|
return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
|
|
2034
|
-
+ `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}`;
|
|
2138
|
+
+ `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}`;
|
|
2035
2139
|
}
|
|
2036
2140
|
return `${r.replaced ? '갈아 끼웠습니다' : '올렸습니다'} — ${r.tenant} · ${args.uuid.slice(0, 8)}\n`
|
|
2037
|
-
+ `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}\n`
|
|
2141
|
+
+ `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}${how}\n`
|
|
2038
2142
|
+ '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`';
|
|
2039
2143
|
}
|
|
2040
2144
|
case 'resume_session': {
|
|
@@ -2045,10 +2149,22 @@ async function callTool(name, args = {}) {
|
|
|
2045
2149
|
* 그래서 서버가 구운 요지를 **응답 본문에 실어** 지금 창의 맥락에 그대로 들어가게 한다 —
|
|
2046
2150
|
* 붙여넣기+엔터 한 번으로 끝나야 「이어받기」이지, 창을 새로 열어야 하면 절차서다.
|
|
2047
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
|
+
*/
|
|
2048
2164
|
const chars = Math.max(2000, Math.min(80000, args.chars || 40000));
|
|
2165
|
+
const q = `?tenant=${encodeURIComponent(args.tenant)}`;
|
|
2049
2166
|
const r = await api('GET',
|
|
2050
|
-
`/api/sessions/${encodeURIComponent(args.uuid)}
|
|
2051
|
-
+ `&digest=1&digest_chars=${chars}`);
|
|
2167
|
+
`/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
|
|
2052
2168
|
indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms });
|
|
2053
2169
|
|
|
2054
2170
|
// 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
|
|
@@ -2059,8 +2175,7 @@ async function callTool(name, args = {}) {
|
|
|
2059
2175
|
if (fs.existsSync(target)) {
|
|
2060
2176
|
placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
|
|
2061
2177
|
} else {
|
|
2062
|
-
|
|
2063
|
-
fs.writeFileSync(target, r.jsonl, { mode: 0o600 });
|
|
2178
|
+
await apiToFile(`/api/sessions/${encodeURIComponent(args.uuid)}${q}&raw=1`, target);
|
|
2064
2179
|
}
|
|
2065
2180
|
|
|
2066
2181
|
/*
|
|
@@ -2095,22 +2210,29 @@ async function callTool(name, args = {}) {
|
|
|
2095
2210
|
+ `\n──────── 이어받은 대화 ────────\n\n${r.digest || '(요지가 비어 있습니다)'}`;
|
|
2096
2211
|
}
|
|
2097
2212
|
case 'pull_session': {
|
|
2098
|
-
const r = await api('GET',
|
|
2099
|
-
`/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}`);
|
|
2100
2213
|
const into = args.into || process.cwd();
|
|
2101
2214
|
const dir = transcriptDir(into);
|
|
2102
2215
|
const target = path.join(dir, `${args.uuid}.jsonl`);
|
|
2103
|
-
|
|
2216
|
+
/*
|
|
2217
|
+
* **덮지 않는다.** 같은 uuid 가 이 기계에도 있으면 여기서 하던 대화가 사라진다.
|
|
2218
|
+
* ★ 그 검사를 **받기 전에** 한다 — 이 도구는 본문만 받으므로, 뒤에 두면 10MB 를
|
|
2219
|
+
* 다 받아 놓고 버리게 된다(옛 코드가 그랬다).
|
|
2220
|
+
*/
|
|
2104
2221
|
if (fs.existsSync(target)) {
|
|
2105
2222
|
throw new Error(`이미 있습니다: ${target}\n덮지 않았습니다 — 이 기계에서 하던 대화가 사라집니다.`);
|
|
2106
2223
|
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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`
|
|
2111
2233
|
+ `놓은 자리: ${target}\n`
|
|
2112
2234
|
+ `이어서 말하려면: cd ${into} && claude --resume ${args.uuid}\n`
|
|
2113
|
-
+ (
|
|
2235
|
+
+ (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
|
|
2114
2236
|
}
|
|
2115
2237
|
default:
|
|
2116
2238
|
throw new Error(`알 수 없는 도구: ${name}`);
|
|
@@ -2193,7 +2315,7 @@ async function handle(line) {
|
|
|
2193
2315
|
* **도구 사이의 관계**만 적는다 — 중복하면 둘이 갈라진다.
|
|
2194
2316
|
*/
|
|
2195
2317
|
instructions: [
|
|
2196
|
-
'
|
|
2318
|
+
'genetics — 소속처 문서 발행, 작업 세션 이식, 사례 기억을 다룬다.',
|
|
2197
2319
|
'',
|
|
2198
2320
|
'무엇을 언제 쓰는가(관계만; 각 도구의 발동 조건은 그 도구 설명에 있다):',
|
|
2199
2321
|
'· 고객에게 나갈 문서를 만들면 publish_report → release_report. 이미 나간 문서를 고치면 update_report.',
|
|
@@ -2207,8 +2329,10 @@ async function handle(line) {
|
|
|
2207
2329
|
'· 고객에게 나갈 문서 → publish_report / update_report',
|
|
2208
2330
|
'무엇인지 모호하면 묻기 전에 search_cases 로 먼저 뒤져 본다 — 이미 있으면 갱신이 답이다.',
|
|
2209
2331
|
'',
|
|
2210
|
-
'자격은
|
|
2211
|
-
'
|
|
2332
|
+
'자격은 **`login` 으로 받는 SSO 하나면 된다** — 그것으로 내가 속한 소속처 전부에 닿는다.',
|
|
2333
|
+
'키(rpk_)도 쓸 수 있지만 **한 소속처에만** 매이고, 발행할 때만 그쪽이 먼저 쓰인다.',
|
|
2334
|
+
'어디로 갈지는 tenant 에 소속처 **이름**으로 적는다. 이름을 모르면 지어내지 말고 사용자에게 묻는다 —',
|
|
2335
|
+
'내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
|
|
2212
2336
|
].join('\n'),
|
|
2213
2337
|
};
|
|
2214
2338
|
} else if (req.method === 'tools/list') {
|