genetics-mcp 2.9.1 → 2.9.3
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/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/aarch64-unknown-linux-musl/gcell +0 -0
- package/bin/manifest.json +4 -4
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +658 -26
- package/package.json +1 -1
- package/skills/genetics-cell-id/SKILL.md +204 -0
- package/skills/genetics-report/SKILL.md +217 -93
- package/skills/genetics-report/assets/example/cases.json +19 -22
- package/skills/genetics-report/assets/example/report.json +198 -53
- package/skills/genetics-report/assets/render.py +21 -10
- package/skills/genetics-report/assets/subset_font.py +24 -8
- package/skills/genetics-report/references/content-pipeline.md +316 -733
- package/skills/genetics-report/references/design-system.md +20 -723
- package/skills/genetics-report/references/font-embedding.md +132 -108
- package/skills/genetics-report/references/illustrations.md +20 -462
- package/skills/genetics-report/references/lim-portal-format.md +756 -182
- package/skills/genetics-report/references/process.md +414 -403
- package/skills/genetics-report/references/qa.md +28 -712
- package/skills/genetics-report/assets/generate-illustrations.py +0 -159
- package/skills/genetics-report/assets/qa.py +0 -198
- package/skills/genetics-report/assets/visual-baseline.py +0 -459
- package/skills/genetics-report/references/textbook-mode.md +0 -111
- package/skills/genetics-report/references/wysiwyg.md +0 -751
package/genetics.mjs
CHANGED
|
@@ -328,6 +328,50 @@ function imageDims(b) {
|
|
|
328
328
|
return null;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/* ── 자산 원본 취득 ② 로컬 파일 ─────────────────────────────────────────
|
|
332
|
+
*
|
|
333
|
+
* `source` 는 **원격에서 받아오는** 입구라 SSRF 방어(허용 호스트·리다이렉트 금지·IP 리터럴
|
|
334
|
+
* 거절)가 걸려 있다. 그 방어는 「에이전트가 고른 주소로 이 프로세스가 어디까지 손을 뻗을 수
|
|
335
|
+
* 있는가」를 막는 것이다.
|
|
336
|
+
*
|
|
337
|
+
* `file` 은 **다른 기전**이다. 이 프로세스를 돌리는 사람의 디스크에서 파일 하나를 읽는 것이고,
|
|
338
|
+
* 그 사람은 이미 그 파일을 갖고 있다. 네트워크로 나가지 않으므로 SSRF 면이 없고, 새로 생기는
|
|
339
|
+
* 권한도 없다. 그래서 **허용 호스트 목록을 로컬로 넓히지 않고 입구를 따로 낸다** — 합치면
|
|
340
|
+
* 위 주석이 경고한 바로 그 사고(이미지 호스트를 넓히다 내부망이 열리는 것)가 난다.
|
|
341
|
+
*
|
|
342
|
+
* 계약 §7 의 본뜻인 「이미지 바이트를 도구 호출 인자로 실어 나르지 않는다」는 그대로다.
|
|
343
|
+
* 바이트는 여기서 처음 생긴다 — 경로만 건네받는다.
|
|
344
|
+
*
|
|
345
|
+
* 이 입구가 없으면 **직접 촬영한 화면을 문서에 실을 방법이 아예 없다.** 검수 보고에서
|
|
346
|
+
* 그것은 「보여 주는」 수단을 통째로 잃는 것과 같다.
|
|
347
|
+
*/
|
|
348
|
+
function readLocalImage(p) {
|
|
349
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(p)) {
|
|
350
|
+
throw new Error(`file 에는 주소가 아니라 파일 경로를 줍니다: ${p}\n`
|
|
351
|
+
+ '원격 주소는 source 로 주세요(허용 호스트만).');
|
|
352
|
+
}
|
|
353
|
+
if (!path.isAbsolute(p)) {
|
|
354
|
+
throw new Error(`file 은 절대경로여야 합니다: ${p}\n`
|
|
355
|
+
+ '이 프로세스의 작업 디렉터리는 호출자와 다를 수 있어 상대경로는 뜻이 달라집니다.');
|
|
356
|
+
}
|
|
357
|
+
let st;
|
|
358
|
+
try { st = fs.statSync(p); }
|
|
359
|
+
catch { throw new Error(`file 을 찾을 수 없습니다: ${p}`); }
|
|
360
|
+
if (!st.isFile()) throw new Error(`file 이 일반 파일이 아닙니다: ${p}`);
|
|
361
|
+
if (st.size > IMAGE_MAX_DOWNLOAD) {
|
|
362
|
+
throw new Error(`원본이 ${st.size.toLocaleString()}B 로 상한 `
|
|
363
|
+
+ `${IMAGE_MAX_DOWNLOAD.toLocaleString()}B 를 넘습니다: ${p}`);
|
|
364
|
+
}
|
|
365
|
+
const buf = fs.readFileSync(p);
|
|
366
|
+
// 확장자를 안 믿는다 — 매직바이트로 판정한다(서버도 같은 것을 다시 잰다).
|
|
367
|
+
const ok = isSvg(buf)
|
|
368
|
+
|| (buf[0] === 0x89 && buf.toString('ascii', 1, 4) === 'PNG')
|
|
369
|
+
|| (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP')
|
|
370
|
+
|| (buf[0] === 0xff && buf[1] === 0xd8);
|
|
371
|
+
if (!ok) throw new Error(`file 이 PNG·WebP·JPEG·SVG 가 아닙니다(매직바이트 기준): ${p}`);
|
|
372
|
+
return buf;
|
|
373
|
+
}
|
|
374
|
+
|
|
331
375
|
/** 원본을 **스트림으로** 받으며 상한을 넘는 순간 끊는다. */
|
|
332
376
|
async function fetchImage(url) {
|
|
333
377
|
if (!imageHostAllowed(url)) {
|
|
@@ -387,10 +431,51 @@ const isSvg = (b) => {
|
|
|
387
431
|
return head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'));
|
|
388
432
|
};
|
|
389
433
|
|
|
390
|
-
|
|
434
|
+
/**
|
|
435
|
+
* 실촬 화면(스크린샷 등)을 예산 안에 앉힌다.
|
|
436
|
+
*
|
|
437
|
+
* 무손실로 굳히면 화면 캡처는 대개 장당 상한을 몇 배로 넘는다. 그렇다고 「그림을 단순하게」
|
|
438
|
+
* 할 수는 없다 — 그것이 증거의 내용이기 때문이다. 그래서 **화질을 계단으로 내려** 예산에
|
|
439
|
+
* 맞춘다. 글자가 읽히는 것이 이 그림의 목적이라 `-sharp_yuv` 를 끝까지 유지한다.
|
|
440
|
+
*/
|
|
441
|
+
const SHOT_QUALITY_LADDER = [82, 72, 62, 52];
|
|
442
|
+
|
|
443
|
+
function encodeShot(buf) {
|
|
444
|
+
const d = imageDims(buf);
|
|
445
|
+
const resize = (d && Math.max(d.w, d.h) > ASSET_LONG_EDGE)
|
|
446
|
+
? (d.h > d.w ? ['-resize', '0', String(ASSET_LONG_EDGE)]
|
|
447
|
+
: ['-resize', String(ASSET_LONG_EDGE), '0'])
|
|
448
|
+
: [];
|
|
449
|
+
let last = null;
|
|
450
|
+
for (const q of SHOT_QUALITY_LADDER) {
|
|
451
|
+
const r = spawnSync('cwebp',
|
|
452
|
+
['-quiet', '-q', String(q), '-sharp_yuv', ...resize, '-o', '-', '--', '-'],
|
|
453
|
+
{ input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
|
|
454
|
+
if (r.error && r.error.code === 'ENOENT') throw cwebpMissing();
|
|
455
|
+
if (r.status !== 0) throw new Error(`자산 인코딩 실패: ${String(r.stderr).slice(0, 200)}`);
|
|
456
|
+
last = r.stdout;
|
|
457
|
+
if (r.stdout.length <= ASSET_MAX_ONE) return r.stdout;
|
|
458
|
+
}
|
|
459
|
+
throw new Error(`화면 캡처가 최저 화질에서도 ${last.length.toLocaleString()}B 로 장당 상한 `
|
|
460
|
+
+ `${ASSET_MAX_ONE.toLocaleString()}B 를 넘습니다. 화질이 아니라 **담은 화면 수**를 줄이세요 — `
|
|
461
|
+
+ '여러 장을 한 판에 몰아 넣으면 어차피 문서에서 글자가 안 읽힙니다.');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function cwebpMissing() {
|
|
465
|
+
return new Error('cwebp 가 없어 자산을 계약 규격(WebP)으로 굳힐 수 없습니다.\n'
|
|
466
|
+
+ 'libwebp 를 설치하세요(brew install webp · apt install webp). '
|
|
467
|
+
+ '다른 포맷으로 우회하지 않습니다 — 계약이 포맷을 규정으로 못박았습니다.');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function encodeAsset(buf, role, provider) {
|
|
391
471
|
// 벡터가 오면 먼저 래스터로 굳힌다. 그 뒤는 래스터와 완전히 같은 길을 간다.
|
|
392
472
|
if (isSvg(buf)) buf = rasterizeSvg(buf);
|
|
393
|
-
|
|
473
|
+
// 무손실은 **도형**의 몫이다 — 평면 색과 선이라 무손실이 작고 선이 안 뭉갠다.
|
|
474
|
+
// 실촬 화면(user-provided)은 사진에 가까워 무손실이 예산을 몇 배로 넘긴다.
|
|
475
|
+
// 같은 role 이라도 출처가 다르면 다른 길이다 — 그 갈림을 provider 가 이미 들고 있다.
|
|
476
|
+
const shot = role === 'figure' && provider === 'user-provided';
|
|
477
|
+
const lossless = role === 'eof' || (role === 'figure' && !shot);
|
|
478
|
+
if (shot) return encodeShot(buf);
|
|
394
479
|
let args;
|
|
395
480
|
if (lossless) {
|
|
396
481
|
args = ['-quiet', '-lossless', '-exact', '-o', '-', '--', '-'];
|
|
@@ -436,14 +521,15 @@ async function materializeAssets(data) {
|
|
|
436
521
|
if (!data || !Array.isArray(data.assets) || !data.assets.length) return data;
|
|
437
522
|
const out = [];
|
|
438
523
|
for (const a of data.assets) {
|
|
439
|
-
const { data: dropped, ...meta } = a ?? {};
|
|
440
|
-
if (!a?.source) {
|
|
441
|
-
//
|
|
524
|
+
const { data: dropped, file: fpath, ...meta } = a ?? {};
|
|
525
|
+
if (!a?.source && !fpath) {
|
|
526
|
+
// 둘 다 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
|
|
442
527
|
out.push(dropped === undefined ? meta : { ...meta, data: dropped });
|
|
443
528
|
continue;
|
|
444
529
|
}
|
|
445
|
-
|
|
446
|
-
const
|
|
530
|
+
// 원격보다 **로컬이 이긴다** — 손에 든 실물이 주소보다 확실하다.
|
|
531
|
+
const raw = fpath ? readLocalImage(String(fpath)) : await fetchImage(String(a.source));
|
|
532
|
+
const enc = encodeAsset(raw, String(a.role ?? ''), String(a.provider ?? ''));
|
|
447
533
|
const d = imageDims(enc);
|
|
448
534
|
out.push({
|
|
449
535
|
...meta,
|
|
@@ -643,6 +729,26 @@ const REPORT_DATA = {
|
|
|
643
729
|
},
|
|
644
730
|
kicker: { type: 'string', description: '표지 윗줄 (예: "회사명 · 프로젝트")' },
|
|
645
731
|
subtitle: { type: 'string', description: '표지 한 문단 요약 — 대제목 **아래**에 앉는다' },
|
|
732
|
+
intro: {
|
|
733
|
+
type: 'string',
|
|
734
|
+
description: '표지 한 문단 요약. `subtitle` 과 **같은 자리**이고 이쪽이 먼저 쓰인다 — '
|
|
735
|
+
+ '둘 다 주면 `subtitle` 은 안 나온다. 하나만 써라.',
|
|
736
|
+
},
|
|
737
|
+
doc_type: {
|
|
738
|
+
type: 'string',
|
|
739
|
+
description: '문서 종류(「검수 보고」·「사후 보고」 등). `issuer` 를 안 주면 '
|
|
740
|
+
+ '`doc_type` · `cover_date` 를 이어 표지 발행처 스트립을 세운다.',
|
|
741
|
+
},
|
|
742
|
+
cover_date: {
|
|
743
|
+
type: 'string',
|
|
744
|
+
description: '기준 날짜. 두 자리에 선다 — `issuer` 폴백 조각, 그리고 **요약면 제목 옆의 '
|
|
745
|
+
+ '「기준」 표기**. 안 주면 요약면에 기준일이 없어 「언제 기준의 수인가」가 사라진다.',
|
|
746
|
+
},
|
|
747
|
+
cta: {
|
|
748
|
+
type: 'array', items: { type: 'string' },
|
|
749
|
+
description: '`kind:"asks"` 절 머리의 강조문(문자열 목록). 요청 항목들 **위**에 서서 '
|
|
750
|
+
+ '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다.',
|
|
751
|
+
},
|
|
646
752
|
description: { type: 'string' },
|
|
647
753
|
doc_no: { type: 'string', description: '문서번호. 표지 머리 왼쪽 박스 (예: "LIM-2026-0824-01")' },
|
|
648
754
|
client: {
|
|
@@ -656,7 +762,13 @@ const REPORT_DATA = {
|
|
|
656
762
|
description: '표지 맨 아래 발행처 스트립 ["발행처", "대상", "2026. 8."] — 마지막 칸이 날짜다.',
|
|
657
763
|
},
|
|
658
764
|
meta: { type: 'array', description: '표지 메타 [[라벨, 값], ...] · 4칸 권장' },
|
|
659
|
-
stats: {
|
|
765
|
+
stats: {
|
|
766
|
+
type: 'array',
|
|
767
|
+
description: '요약면 수치 칸 [{value, unit, label, accent?, href?, go?}] · accent:true 는 '
|
|
768
|
+
+ '**독자 몫**인 첫 칸. `href` 를 주면 그 칸이 단추가 되고, `go` 는 그 단추의 '
|
|
769
|
+
+ '글자다(생략하면 「해당 절로」). 어디로 가는지를 `go` 에 적어 두면 '
|
|
770
|
+
+ '독자가 누르기 전에 안다 — 예: "02절로".',
|
|
771
|
+
},
|
|
660
772
|
history: {
|
|
661
773
|
type: 'array',
|
|
662
774
|
description: '발행 이력 [{no, date, note}] — 회차 문서임을 문서가 스스로 밝히는 자리.',
|
|
@@ -672,7 +784,9 @@ const REPORT_DATA = {
|
|
|
672
784
|
description: 'PART 묶음 [{no, title, lede?, tone?, questions?}] — 절만 늘어놓으면 '
|
|
673
785
|
+ '「먼저 읽을 것 / 경과 / 남은 것」이라는 독자의 읽기 순서가 사라진다. '
|
|
674
786
|
+ 'tone 은 you(독자 몫)·neutral(기록)·wait(대기)·hold(유보) 중 하나. '
|
|
675
|
-
+ 'questions 는 「이 PART 가 답하는 질문」.'
|
|
787
|
+
+ 'questions 는 「이 PART 가 답하는 질문」. '
|
|
788
|
+
+ '`roman` 은 간지·차례·머리띠에 서는 로마 숫자 — 생략하면 자리 순서대로 '
|
|
789
|
+
+ 'Ⅰ·Ⅱ·Ⅲ 이 매겨지므로 **보통 적지 않는다**(적으면 순서와 어긋날 수 있다).',
|
|
676
790
|
},
|
|
677
791
|
notice: {
|
|
678
792
|
type: 'object',
|
|
@@ -694,6 +808,25 @@ const REPORT_DATA = {
|
|
|
694
808
|
},
|
|
695
809
|
remain: { type: 'string', description: '머리띠 우측 판독부의 수치(요청 잔여 건수). 생략하면 open_items 개수.' },
|
|
696
810
|
running_footer: { type: 'string', description: '인쇄 러닝 푸터의 뒷말. 생략하면 client.' },
|
|
811
|
+
wide: {
|
|
812
|
+
type: 'string',
|
|
813
|
+
description: '문서 폭 CSS 값(`--wide`). **길이 단위여야 한다**(예: `1080px`) — '
|
|
814
|
+
+ '아니면 싣지 않고 ⚠ 로 센다. 시트 기본값으로 충분하니 손대지 마라.',
|
|
815
|
+
},
|
|
816
|
+
marg_width: {
|
|
817
|
+
type: 'string',
|
|
818
|
+
description: '좌측 여백 주석 단의 폭 CSS 값(`--marg`). 길이 단위여야 한다(예: `240px`). '
|
|
819
|
+
+ '관보 여백 단은 12~19자가 적정이라 기본값을 바꿀 이유가 거의 없다.',
|
|
820
|
+
},
|
|
821
|
+
labels: {
|
|
822
|
+
type: 'object',
|
|
823
|
+
description: '렌더러 기본 라벨 덮어쓰기 `{키: 글자}`. 문서가 「건」이 아닌 것을 다루거나 '
|
|
824
|
+
+ '다른 말을 써야 할 때만 쓴다.\n'
|
|
825
|
+
+ '덮을 수 있는 키: apx · as_of · ask · bar_alt · basis · client · col_grade · col_state · '
|
|
826
|
+
+ 'col_title · cta · doc_no · go · guide · hist · hist_date · hist_no · hist_note · marg · '
|
|
827
|
+
+ 'nograde · open · pt_ask · refs · remain · remain_unit · source · source_row · sum · toc · urgent.\n'
|
|
828
|
+
+ '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라 써라.',
|
|
829
|
+
},
|
|
697
830
|
grades: { type: 'object', description: '확인 등급 정의 {이름: [클래스, 설명]}' },
|
|
698
831
|
groups: { type: 'array', description: '건 묶음 [{key, no}]' },
|
|
699
832
|
sections: {
|
|
@@ -703,13 +836,36 @@ const REPORT_DATA = {
|
|
|
703
836
|
+ 'chapter 에 아래 chapters 의 id 를 적으면 그 절 앞에 챕터 오프닝이 펼쳐진다. '
|
|
704
837
|
+ 'name 은 목차에 쓰는 **짧은 이름**이고, 본문의 큰 제목은 챕터의 title_lines 가 낸다.\n'
|
|
705
838
|
+ '\n'
|
|
706
|
-
+ '
|
|
707
|
-
+ '
|
|
708
|
-
+ '
|
|
709
|
-
+ '· `
|
|
710
|
-
+ '
|
|
711
|
-
+ '
|
|
712
|
-
+ '
|
|
839
|
+
+ '⚠ `principles`·`logic`·`chapter` 는 **옛 서식 전용**이다. 관보(기본 서식)에서는 '
|
|
840
|
+
+ '자리가 없어 렌더러가 버리고 ⚠ 로 센다 — 관보로 낼 문서에는 넣지 말 것.\n'
|
|
841
|
+
+ '\n'
|
|
842
|
+
+ '· `flow`: {caption, heads[4], rows:[{no, cells[3]}]} — **관보의 의존 흐름도.** '
|
|
843
|
+
+ '「A 가 되면 B 가 되고 C 가 된다」를 글로 되풀이하지 않게 한다. kind 와 무관하게 쓴다. '
|
|
844
|
+
+ '위 `logic` 안의 flow 와는 **다른 것이다**(그쪽은 옛 서식). '
|
|
845
|
+
+ '열은 넷으로 고정 — heads 는 정확히 4개(첫째가 좌표 열 머리), 각 행 cells 는 정확히 3개. '
|
|
846
|
+
+ '칸 성격은 **자리가 정한다**: cells[0] 해야 할 것(강조 면) → cells[1] 이미 된 것(흐린 글자) '
|
|
847
|
+
+ '→ cells[2] 그러면(결과). 읽는 순서가 그와 안 맞으면 **열 제목을 그 순서로 바꿔 적는다**. '
|
|
848
|
+
+ 'cells[] 는 {text, note?, path?} — note 는 둘째 줄, path 는 경로·기술 문자열. '
|
|
849
|
+
+ 'rows[].no 가 `4.1`·`04-1` 모양이면 그 사례로 링크가 된다. '
|
|
850
|
+
+ '그림 번호(`그림 5-1`)는 적지 않는다 — 자리가 매기고 `plates` 와 한 셈을 쓴다.\n'
|
|
851
|
+
+ '· `plates`: [assets[].id, …] — 절 본문 도판.\n'
|
|
852
|
+
+ '· `tables`: [{caption, cols[], rows[[]], align?, hint?, note?}] — **관보의 자료표.** '
|
|
853
|
+
+ '여러 항목을 축으로 견주는 자리다. 불릿 목록에 수를 눌러 담고 있으면 그건 표여야 한다. '
|
|
854
|
+
+ 'kind 와 무관하게 쓴다(kind:status 의 `board_title` 대조표와는 다른 것 — 저건 건 전용이다). '
|
|
855
|
+
+ 'cols 는 열 제목, rows 는 행마다 cols 와 **같은 길이의 배열**(어긋나면 잘라 맞추고 ⚠ 로 센다). '
|
|
856
|
+
+ 'align 은 열마다 `l`·`r`·`c` — 수는 `r` 로 두어야 자릿수가 세로로 맞는다. '
|
|
857
|
+
+ 'note 는 표 아래 ※ 한정 조건, hint 는 그 아래 안내 한 줄. '
|
|
858
|
+
+ '표 번호(`표 5-1`)는 적지 않는다 — 자리가 매긴다. '
|
|
859
|
+
+ '⚠ caption 은 **라벨이지 산문이 아니다** — 20자 안쪽으로 두고 설명은 note 로 내려라 '
|
|
860
|
+
+ '(길면 캡션 바가 글줄 상한을 넘어 두 줄로 접힌다).\n'
|
|
861
|
+
+ '· `charts`: [{caption, unit?, rows:[{label, value|a+b, note?}], toggle?{a,b}, note?}] — '
|
|
862
|
+
+ '**분포 막대.** 수를 눈으로 견주게 한다. 열람 CSP 가 `script-src` 를 안 열어 캔버스를 쓸 수 없으므로 '
|
|
863
|
+
+ 'HTML 막대와 CSS 로 짓는다. 값은 **본문 글자로 남고** 막대는 그 옆에 서므로 막대가 안 보이는 '
|
|
864
|
+
+ '곳에서도 문서가 성립한다. value 는 **표기 그대로 적는다**(`"118,716"`) — 콤마는 셈에서 걷어내므로 '
|
|
865
|
+
+ '같은 문서의 표와 표기가 갈리지 않게 하려면 데이터가 표기를 정해야 한다. '
|
|
866
|
+
+ 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다 '
|
|
867
|
+
+ '(체크박스는 스크립트가 아니라 상태다). 그림 번호는 `flow`·`plates` 와 **한 셈**을 쓴다.\n'
|
|
868
|
+
+ '데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
|
|
713
869
|
+ '\n'
|
|
714
870
|
+ '관보 문법이 절마다 요구하는 칸:\n'
|
|
715
871
|
+ '· `part`: 이 절이 속한 PART 의 no. 안 주면 첫 묶음으로 떨어진다.\n'
|
|
@@ -720,6 +876,10 @@ const REPORT_DATA = {
|
|
|
720
876
|
+ '채우려고 넣으면 안 넣느니만 못하다.\n'
|
|
721
877
|
+ '· `lede`: 절 간지의 도입문 — 다루는 범위와 **다루지 않는 범위**.\n'
|
|
722
878
|
+ '· `sub`: 차례 행에 붙는 한 줄 설명. `short`: 측면 색인 레일용 짧은 이름.\n'
|
|
879
|
+
+ '· `count`·`count_unit`: 절 간지와 차례에 서는 배지(「9건」·「11항」). '
|
|
880
|
+
+ '`count` 가 비면 배지 자체가 안 선다. `count_unit` 은 생략하면 「건」이다 — '
|
|
881
|
+
+ '건이 아닌 것(항·축·구간)을 세는 절이면 반드시 적어라. '
|
|
882
|
+
+ '⚠ **손으로 적는 수라 본문과 어긋날 수 있다** — 절 내용을 고치면 같이 고쳐라.\n· `plates`: 이 절에 놓을 **도판**의 id 목록(`assets[].id`). 관보의 그림 문법으로 서고, 번호는 자리가 매긴다(그림 <절번호>-<순번>). 어느 절에도 안 걸린 도판은 싣지 않고 경고로 센다.\n'
|
|
723
883
|
+ '· `tone`: 장 색을 뜻으로 고른다 — you(독자 몫)·deep(요지)·neutral(허브)·'
|
|
724
884
|
+ 'done(끝난 것)·wait(대기)·hold(유보). 생략하면 kind 가 정한다.\n'
|
|
725
885
|
+ '· kind:status 전용 — `board_title`(대조표 제목) · `board_hint`(표 아래 안내).\n'
|
|
@@ -772,7 +932,14 @@ const REPORT_DATA = {
|
|
|
772
932
|
+ '최소 하나 밝혀야 한다(예: 본문에 "§3.1" 이라 쓰거나 refs:["3.1"]). 절 번호가 아니라 '
|
|
773
933
|
+ '사례 번호다. 근거가 없으면 서버가 발행을 거절한다.',
|
|
774
934
|
},
|
|
775
|
-
open_items: {
|
|
935
|
+
open_items: {
|
|
936
|
+
type: 'array',
|
|
937
|
+
description: 'asks 절 내용 [{title, body, refs?, priority?}] — 우리가 끝낼 수 없어 '
|
|
938
|
+
+ '상대의 판단이 필요한 것만. `priority: "must"` 인 항목은 「가장 시급한 요청」으로 '
|
|
939
|
+
+ '따로 서고 나머지와 괘선으로 갈린다 — **먼저 정해져야 하는 것에만** 붙여라. '
|
|
940
|
+
+ '전부에 붙이면 갈림이 사라져 안 붙인 것과 같아진다. '
|
|
941
|
+
+ '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다.',
|
|
942
|
+
},
|
|
776
943
|
footer: { type: 'array' },
|
|
777
944
|
assets: {
|
|
778
945
|
type: 'array',
|
|
@@ -780,7 +947,9 @@ const REPORT_DATA = {
|
|
|
780
947
|
+ '적으면 서버가 그려서 나중에 채운다(문서는 그 자리를 잡은 채로 즉시 발행된다). '
|
|
781
948
|
+ '이미 그린 그림이 있으면 `source` 에 원본 주소를 준다. '
|
|
782
949
|
+ '**바이트를 직접 싣지 말 것** — `source` 를 주면 이 도구가 '
|
|
783
|
-
+ '받아와 규격대로 굳혀 보낸다(hero·chapter 는 WebP q78 긴 변 1400px, eof
|
|
950
|
+
+ '받아와 규격대로 굳혀 보낸다(hero·chapter 는 WebP q78 긴 변 1400px, eof·도형 figure 는 무손실). '
|
|
951
|
+
+ '**직접 촬영한 화면은 `file` 에 로컬 절대경로를 준다** — role: figure · provider: user-provided '
|
|
952
|
+
+ '로 두면 화질을 계단으로 내려 예산 안에 앉힌다. 그 도판은 `sections[].plates` 에 id 를 적어야 자리가 생긴다. '
|
|
784
953
|
+ '크기와 형식은 인코딩 결과에서 읽으므로 선언하지 않는다. 전부 data: URI 로 문서에 박히며 '
|
|
785
954
|
+ '(열람 CSP 가 외부 이미지를 막는다), 서버가 출처·매직바이트·예산(장당 80,000B·합계 400,000B)을 '
|
|
786
955
|
+ '다시 검증한다.',
|
|
@@ -788,6 +957,24 @@ const REPORT_DATA = {
|
|
|
788
957
|
type: 'object',
|
|
789
958
|
properties: {
|
|
790
959
|
id: { type: 'string', description: '영소문자·숫자·하이픈 1~40자. 문서 안 참조 이름' },
|
|
960
|
+
file: {
|
|
961
|
+
type: 'string',
|
|
962
|
+
description: '**로컬 파일의 절대경로.** 직접 촬영한 화면처럼 인터넷에 없는 그림을 '
|
|
963
|
+
+ '실을 때 쓴다 — 이 도구가 읽어 규격대로 굳혀 보낸다(바이트를 인자로 싣지 않는 것은 '
|
|
964
|
+
+ '`source` 와 같다). `source` 와 함께 주면 **file 이 이긴다.** 주소를 넣지 말 것 — '
|
|
965
|
+
+ '원격은 `source` 의 몫이고 허용 호스트가 따로 있다.',
|
|
966
|
+
},
|
|
967
|
+
caption: {
|
|
968
|
+
type: 'string',
|
|
969
|
+
description: 'role: figure 의 **그림 캡션.** 「그림 4-1」 번호 뒤에 붙는다(번호는 자리가 '
|
|
970
|
+
+ '매기므로 적지 않는다). 무엇을 보이는 화면인지 한 문장으로 — 본문이 이미 말한 것을 '
|
|
971
|
+
+ '되풀이하지 말고 **이 화면에서 무엇을 보라는 것인지**를 적는다.',
|
|
972
|
+
},
|
|
973
|
+
source_text: {
|
|
974
|
+
type: 'string',
|
|
975
|
+
description: 'role: figure 의 출처 한 줄(캡션 아래). 언제·어디서 얻은 화면인가. '
|
|
976
|
+
+ '예: 「2026. 8. 24. Galaxy S22+ 실기기 홈 화면」.',
|
|
977
|
+
},
|
|
791
978
|
role: {
|
|
792
979
|
type: 'string', enum: ['hero', 'chapter', 'figure', 'eof', 'logo'],
|
|
793
980
|
description: '`logo` 는 **표지 우상단 고객사 마크**다 — provider 는 user-provided, '
|
|
@@ -804,6 +991,19 @@ const REPORT_DATA = {
|
|
|
804
991
|
},
|
|
805
992
|
alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
|
|
806
993
|
origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
|
|
994
|
+
caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지 않는다' },
|
|
995
|
+
source_text: { type: 'string', description: '캡션 아래 「출처」 줄 — 이 그림이 어디서 왔는지' },
|
|
996
|
+
verdict: {
|
|
997
|
+
type: 'string', enum: ['직접 확인', '조사 보고', '미확인'],
|
|
998
|
+
description: '이 근거를 **얼마나 믿어도 되는지**. 캡션 끝에 칩으로 선다.\n'
|
|
999
|
+
+ '· 직접 확인 — 원문을 직접 열어 그 수치를 확인함\n'
|
|
1000
|
+
+ '· 조사 보고 — 2차 출처를 경유해 확인함. 원문 대조는 안 함\n'
|
|
1001
|
+
+ '· 미확인 — 널리 쓰이나 1차 출처를 특정하지 못함\n'
|
|
1002
|
+
+ '`grades`(건의 확인 강도)와 **다른 축이다** — 저건 「우리가 무엇을 확인했나」이고 '
|
|
1003
|
+
+ '이건 「이 근거를 얼마나 믿어도 되나」다. 실사례(2026-08-24): 원판이 3단으로 나눠 둔 '
|
|
1004
|
+
+ '이 축을 포털본이 통째로 버려 2차 출처와 미확인 주장이 **사실로 단언돼 나갔다.** '
|
|
1005
|
+
+ '증거를 실으면서 등급을 안 붙이면 문서가 실제보다 확실해 보인다.',
|
|
1006
|
+
},
|
|
807
1007
|
brief: {
|
|
808
1008
|
type: 'string',
|
|
809
1009
|
description: '**무엇을 그릴지 — 장면 한두 문장.** 이것만 주면 서버가 그린다(그리는 동안 '
|
|
@@ -1760,6 +1960,22 @@ function startObserve() {
|
|
|
1760
1960
|
observeTimer.unref?.(); // 창을 닫으면 노드가 남지 않게
|
|
1761
1961
|
}
|
|
1762
1962
|
|
|
1963
|
+
/*
|
|
1964
|
+
* ── 메일 폴은 **심박과 따로** 돈다 (2026-08-25 운영 첫 왕복 실측) ─────────────────
|
|
1965
|
+
* 전에는 beatTick 꼬리에 얹었는데, beatTick 은 색인에 올린 세션이 없으면 **일찍 빠져나간다**
|
|
1966
|
+
* (`if (!recs.length) return`). cell/README 가 observeTick 에 대해 경고한 바로 그 무늬를
|
|
1967
|
+
* 밟았다 — 새로 뜬 세션은 색인 밖이라 메일을 영영 안 길었다. 자기 타이머로 뗀다.
|
|
1968
|
+
*/
|
|
1969
|
+
let mailTimer = null;
|
|
1970
|
+
function startMailPoll() {
|
|
1971
|
+
if (cfg.GENETICS_NO_BEAT) return;
|
|
1972
|
+
const cred = credential();
|
|
1973
|
+
if (!BASE || !cred || cred.kind !== 'user') return; // 서버가 사람 토큰만 받는다
|
|
1974
|
+
mailPull().catch(() => {});
|
|
1975
|
+
mailTimer = setInterval(() => { mailPull().catch(() => {}); }, MAIL_POLL_MS);
|
|
1976
|
+
mailTimer.unref?.();
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1763
1979
|
function startBeat() {
|
|
1764
1980
|
if (cfg.GENETICS_NO_BEAT) return; // 심박을 원치 않는 사람의 탈출구
|
|
1765
1981
|
const cred = credential();
|
|
@@ -1973,7 +2189,9 @@ const TOOLS = [
|
|
|
1973
2189
|
+ '**알아낸 것 하나**면 이 도구다.\n'
|
|
1974
2190
|
+ '무엇을 적는가: 증상은 **겪은 현상 그대로**(다음 사람은 원인을 모르는 채 이 낱말로 찾는다), '
|
|
1975
2191
|
+ '처방은 다시 하면 되는 동작, 근거는 실측치·커밋·로그. 원인과 근거는 비워도 되지만 '
|
|
1976
|
-
+ '비면 그렇게 적힌다 — 없는 확신을 지어내지 말
|
|
2192
|
+
+ '비면 그렇게 적힌다 — 없는 확신을 지어내지 말 것.\n'
|
|
2193
|
+
+ '근거에 **저장소 상대 경로**(예: www/portal/lib/x.php:12)를 적으면 그 파일을 읽은 채로 든 '
|
|
2194
|
+
+ '다른 세션에게 이 사례가 메일로 간다 — 검색하지 않아도 닿는다.',
|
|
1977
2195
|
inputSchema: {
|
|
1978
2196
|
type: 'object',
|
|
1979
2197
|
properties: {
|
|
@@ -1984,6 +2202,12 @@ const TOOLS = [
|
|
|
1984
2202
|
fix: { type: 'string', description: '처방. 다시 만나면 그대로 하면 되는 동작.' },
|
|
1985
2203
|
evidence: { type: 'string', description: '근거 — 실측치·커밋 해시·로그 한 줄. 비면 "안 적혔다"로 남는다.' },
|
|
1986
2204
|
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8).' },
|
|
2205
|
+
paths: { type: 'array', items: { type: 'string' },
|
|
2206
|
+
description: '이 사례가 걸린 **저장소 상대 경로**(예: www/portal/lib/x.php). 본문에 적힌 경로는 자동으로 들어간다.' },
|
|
2207
|
+
commands: { type: 'array', items: { type: 'string' },
|
|
2208
|
+
description: '이 사례가 걸린 **명령 문구**(예: "INSERT INTO rp_agent_presence"). 다른 세포가 그 문구가 든 명령을 치기 직전에 이 사례를 본다.' },
|
|
2209
|
+
errors: { type: 'array', items: { type: 'string' },
|
|
2210
|
+
description: '이 사례의 **오류 문자열**(예: "CONSTRAINT ck_tree_machine failed"). 지금은 적어 두기만 한다.' },
|
|
1987
2211
|
},
|
|
1988
2212
|
required: ['tenant', 'title', 'symptom', 'fix'],
|
|
1989
2213
|
},
|
|
@@ -2012,6 +2236,39 @@ const TOOLS = [
|
|
|
2012
2236
|
required: ['tenant', 'q'],
|
|
2013
2237
|
},
|
|
2014
2238
|
},
|
|
2239
|
+
{
|
|
2240
|
+
name: 'get_case',
|
|
2241
|
+
description:
|
|
2242
|
+
'**사례·지식 장의 전문을 읽는다** — 증상·원인·처방·근거, 결정문이면 `상태:`(뒤집힘·조건 충족)까지. '
|
|
2243
|
+
+ 'search_cases 가 준 path 를 그대로 넣는다. 검색 미리보기는 200자라 처방이 잘려 있다 — '
|
|
2244
|
+
+ '처방대로 하기 전에 이것으로 전문을 읽는다. 사례 메일의 「전문: get_case(…)」도 여기다.',
|
|
2245
|
+
inputSchema: {
|
|
2246
|
+
type: 'object',
|
|
2247
|
+
properties: {
|
|
2248
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
2249
|
+
path: { type: 'string', description: 'search_cases 결과의 path 그대로(예: cases/….md).' },
|
|
2250
|
+
},
|
|
2251
|
+
required: ['tenant', 'path'],
|
|
2252
|
+
},
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
name: 'case_verdict',
|
|
2256
|
+
description:
|
|
2257
|
+
'**사람이 말한 판정을 사례에 적는다** — 「그 처방대로 했더니 맞았다(applied) / 안 맞았다(refuted) / '
|
|
2258
|
+
+ '이 자리와 무관했다(irrelevant)」. 사람이 그렇게 **말했을 때만** 부른다 — 스스로 판단해 적지 않는다'
|
|
2259
|
+
+ '(이것은 세션이 중계한 판정으로 기록·표시되고, 학습에 세는 것은 사람이 지식 화면에서 누른 판정뿐이다). '
|
|
2260
|
+
+ 'path 는 search_cases·get_case 의 path 그대로.',
|
|
2261
|
+
inputSchema: {
|
|
2262
|
+
type: 'object',
|
|
2263
|
+
properties: {
|
|
2264
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
2265
|
+
path: { type: 'string', description: '사례 경로(cases/….md).' },
|
|
2266
|
+
verdict: { type: 'string', enum: ['applied', 'refuted', 'irrelevant'], description: '사람이 말한 판정.' },
|
|
2267
|
+
note: { type: 'string', description: '한 줄 메모(선택, 200자).' },
|
|
2268
|
+
},
|
|
2269
|
+
required: ['tenant', 'path', 'verdict'],
|
|
2270
|
+
},
|
|
2271
|
+
},
|
|
2015
2272
|
{
|
|
2016
2273
|
name: 'list_sessions',
|
|
2017
2274
|
description:
|
|
@@ -2097,6 +2354,43 @@ const TOOLS = [
|
|
|
2097
2354
|
required: ['tenant', 'uuid'],
|
|
2098
2355
|
},
|
|
2099
2356
|
},
|
|
2357
|
+
/*
|
|
2358
|
+
* ★ **보내는 쪽만 도구다.** 받는 쪽은 도구가 아니라 도구 응답 꼬리로 온다 —
|
|
2359
|
+
* `README.md:26-34` 의 실측 때문이다: `search_cases` 가 실시간으로 있었는데 **0번 불렀다.**
|
|
2360
|
+
* 부를 생각이 나려면 이미 「내가 뭘 놓쳤나」를 알아야 하는데, 놓친 순간에는 그것을 모른다.
|
|
2361
|
+
*/
|
|
2362
|
+
{
|
|
2363
|
+
name: 'mail',
|
|
2364
|
+
description:
|
|
2365
|
+
'**다른 세션이나 두뇌에게 말을 건다.** 이 기계의 옆 창뿐 아니라 **다른 기계·Codex 세션에도 닿는다** '
|
|
2366
|
+
+ '— 서버를 지나기 때문이다(`SendMessage` 는 이 기계의 Claude Code 만 본다).\n'
|
|
2367
|
+
+ '무엇을 해 달라고 **부탁해도 된다** — 이 창구는 받는 쪽 눈앞에 「누가 보냈는지」를 서버가 '
|
|
2368
|
+
+ '반드시 붙여 주므로, 세계 장부와 달리 지시형이 막히지 않는다.\n'
|
|
2369
|
+
+ '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로 실린다.\n'
|
|
2370
|
+
+ '**사람에게는 이메일로** 보낸다(그 소속처 담당만) — 포털 벨이 울린다.\n'
|
|
2371
|
+
+ '⚠ 상대 이름은 **지어내지 말 것.** `world` 나 `gcell agents` 가 아는 이름, 세션 uuid, '
|
|
2372
|
+
+ '또는 `brain` 중 하나다. 못 찾으면 사용자에게 묻는다.\n'
|
|
2373
|
+
+ '⚠ 왕복에는 상한이 있다(사람이 낀 실 5회, 기계끼리 3회) — 끝나면 사람에게 올라간다.',
|
|
2374
|
+
inputSchema: {
|
|
2375
|
+
type: 'object',
|
|
2376
|
+
properties: {
|
|
2377
|
+
tenant: { type: 'string', description: '소속처 이름. 모르면 list_my_tenants 로 먼저 확인한다' },
|
|
2378
|
+
to: { type: 'string', description: '받는 쪽 — 세포 이름 · 세션 uuid · 또는 brain' },
|
|
2379
|
+
body: { type: 'string', description: '할 말' },
|
|
2380
|
+
subject: { type: 'string', description: '한 줄 제목(선택)' },
|
|
2381
|
+
artifact: { type: 'string', description: '무엇에 대한 이야기인가 — 파일·문서 경로(선택)' },
|
|
2382
|
+
done_when: { type: 'string', description: '무엇이 되면 끝인가(선택)' },
|
|
2383
|
+
thread: { type: 'string', description: '답장이면 받은 통의 thread. 새 이야기면 넣지 않는다' },
|
|
2384
|
+
},
|
|
2385
|
+
/*
|
|
2386
|
+
* ⚠⚠ **발신자를 여기 안 적는다.** `DESIGN-session-as-vcs.md` §5.1 —
|
|
2387
|
+
* 「노출하면 그 값은 모델이 **지어낸다**」. `session` 은 아래 배선이 로컬 환경에서
|
|
2388
|
+
* 채우고, 서버는 그것이 이 자격으로 등록된 주소인지 다시 확인한다.
|
|
2389
|
+
*/
|
|
2390
|
+
required: ['tenant', 'to', 'body'],
|
|
2391
|
+
additionalProperties: false,
|
|
2392
|
+
},
|
|
2393
|
+
},
|
|
2100
2394
|
{
|
|
2101
2395
|
name: 'install_cell',
|
|
2102
2396
|
description:
|
|
@@ -2465,12 +2759,238 @@ function brainSays() {
|
|
|
2465
2759
|
+ ' 「⚠ 상태:」가 붙은 장이 어느 것인지 보인다.';
|
|
2466
2760
|
}
|
|
2467
2761
|
|
|
2762
|
+
/* ── ★★ 메일 레인 — 두뇌·다른 세션·사람이 **나를 지목해** 보낸 것 ──────────
|
|
2763
|
+
*
|
|
2764
|
+
* 설계는 `DESIGN-mail.md`, 규칙은 서버의 `lib/mail.php`(순수), 표는 051·052.
|
|
2765
|
+
*
|
|
2766
|
+
* ── 왜 백신과 따로인가 ────────────────────────────────────────────────────
|
|
2767
|
+
* 백신은 **주소가 없다** — 두뇌가 이 폴더에 대고 말하는 방송이고, 아무도 안 들어도
|
|
2768
|
+
* 아무 일도 안 난다. 메일은 반대다: 발신자와 수신자가 있고, 못 받으면 만료되어
|
|
2769
|
+
* **발신자에게 되돌아간다.** 두 가지를 한 큐에 담으면 그 차이가 사라진다.
|
|
2770
|
+
*
|
|
2771
|
+
* ⚠ **첫 바퀴 나이 검사를 안 한다.** 백신은 커서가 0일 때 지난 것이 통째로 내려와
|
|
2772
|
+
* 「끝난 이야기를 지금 하는 일로」 읽을 위험이 있어 10분 안쪽만 말한다. 메일은 다르다 —
|
|
2773
|
+
* 사람이 보낸 것일 수 있고, 오래됐다고 버리면 그것이 곧 조용한 손실이다.
|
|
2774
|
+
* 대신 **서버의 `expires_at` 이 판정한다**(만료된 것은 애초에 안 내려온다).
|
|
2775
|
+
*
|
|
2776
|
+
* ⚠ **받는 도구를 안 만든다.** `README.md:26-34` 의 실측 그대로다 — `search_cases` 가
|
|
2777
|
+
* 실시간으로 있었는데 **0번 불렀다.** 부를 생각이 나려면 이미 「내가 뭘 놓쳤나」를
|
|
2778
|
+
* 알아야 하는데, 놓친 순간에는 그것을 모른다. 그래서 받는 쪽은 **도구가 아니라
|
|
2779
|
+
* 도구 응답 꼬리**다(`worldAppend`). 보내는 쪽만 도구로 둔다.
|
|
2780
|
+
*/
|
|
2781
|
+
const MAIL_POLL_MS = 60_000; // 사람이 기다린다 — 백신(120초)보다 자주 본다
|
|
2782
|
+
const MAIL_PAGE = 20;
|
|
2783
|
+
let mailAt = 0;
|
|
2784
|
+
let mailCursor = null; // null = 아직 색인에서 안 읽었다
|
|
2785
|
+
let mailAddrAt = 0; // 마지막으로 주소를 댄 때
|
|
2786
|
+
const mailQueue = []; // 아직 안 말한 통(서버가 렌더한 글자를 그대로 싣는다)
|
|
2787
|
+
const mailBack = []; // 내가 부쳤다 만료된 것
|
|
2788
|
+
const mailBackSeen = new Set();
|
|
2789
|
+
|
|
2790
|
+
/**
|
|
2791
|
+
* 이 세션의 **말 걸 수 있는 이름.** 전사에서 마지막 자기소개를 캔다.
|
|
2792
|
+
*
|
|
2793
|
+
* ★ 왜 전사인가: `ListAgents` 를 부르면 하네스가 「This session is <이름> [<id>]」를
|
|
2794
|
+
* **전사에 적는다.** 그것은 모델이 지어낸 값이 아니라 하네스가 쓴 사실이고,
|
|
2795
|
+
* 전사 파일 이름이 곧 세션 uuid 라 두 체계가 거기서 만난다.
|
|
2796
|
+
* 세포의 `agent_name_in`(`cell/src/world.rs:443`)과 **같은 규칙**을 쓴다 —
|
|
2797
|
+
* 두 자로 재면 같은 세션을 다르게 부르게 된다.
|
|
2798
|
+
*
|
|
2799
|
+
* ⚠ **마지막 것**을 쓴다. 이름은 세션 도중에 바뀌고, 옛 이름은 죽은 주소다.
|
|
2800
|
+
* ⚠ **늘어난 부분만 다시 읽는다.** 전사가 7MB 까지 가므로(실측) 매번 통독하면
|
|
2801
|
+
* 그 자체가 부하다. 이름은 앞으로만 생기니 뒤만 보면 된다.
|
|
2802
|
+
* ⚠ 못 찾으면 `null` — **지어내지 않는다.** 이름 없는 주소는 받을 수는 있어도 못 보낸다.
|
|
2803
|
+
*/
|
|
2804
|
+
/*
|
|
2805
|
+
* ⚠⚠ **줄 안의 글자가 아니라 레코드의 모양으로 읽는다** (2026-08-25 실측).
|
|
2806
|
+
* 처음엔 전사 바이트 전체에 정규식을 대고 마지막 일치를 이름으로 삼았다. 그런데 이
|
|
2807
|
+
* 세션이 smoke 픽스처로 `This session is report-portal-17 [e51bae]` 를 **자기 전사에
|
|
2808
|
+
* 썼고**(assistant 레코드), 세포가 같은 규칙으로 그 이름을 이 세션에 달았다. 이 함수가
|
|
2809
|
+
* 그대로였다면 MCP 가 그 이름으로 자기 등록해 **이름 최신-승(052)으로 진짜 17 의 주소를
|
|
2810
|
+
* 뺏었을 것**이다. 세포 명의 오귀속의 다섯 번째 무늬 — 이번엔 이름 쪽이다.
|
|
2811
|
+
*
|
|
2812
|
+
* 진짜 자기소개는 하네스(ListAgents)만 낸다: `type:"user"` 레코드의 `content[0]` 이
|
|
2813
|
+
* `tool_result` 이고 본문이 **「This session is …」로 시작**하며 꼬리에 INTRO_TAIL 이 있다.
|
|
2814
|
+
* 세포의 `agent_name_in_record`(cell/src/world.rs)와 **같은 규칙** — 두 자로 읽으면
|
|
2815
|
+
* 같은 세션을 다르게 부른다.
|
|
2816
|
+
* ⚠ 남는 구멍: Bash 로 남의 전사 줄을 통째로 찍어 tool_result 가 정확히 그 꼴로 시작하면
|
|
2817
|
+
* 속는다 — 그건 사람이 일부러 한 것이고, 여기 적어 둔다.
|
|
2818
|
+
*/
|
|
2819
|
+
const INTRO_HEAD = 'This session is ';
|
|
2820
|
+
const INTRO_TAIL = 'the name other sessions use';
|
|
2821
|
+
const AGENT_NAME_RE = /^This session is ([A-Za-z0-9._-]{1,60}) \[([0-9a-fA-F]+)\]/;
|
|
2822
|
+
function introNameOf(line) {
|
|
2823
|
+
if (!line.includes(INTRO_HEAD)) return null; // 값싼 검사로 먼저 거른다
|
|
2824
|
+
let d;
|
|
2825
|
+
try { d = JSON.parse(line); } catch { return null; }
|
|
2826
|
+
if (d?.type !== 'user') return null; // ★ 모델이 쓴 글(assistant)은 이름이 아니다
|
|
2827
|
+
const c = d?.message?.content;
|
|
2828
|
+
const first = Array.isArray(c) ? c[0] : null;
|
|
2829
|
+
if (!first || first.type !== 'tool_result') return null;
|
|
2830
|
+
let body = first.content;
|
|
2831
|
+
if (Array.isArray(body)) body = body.map((x) => (x && x.type === 'text' ? String(x.text || '') : '')).join('');
|
|
2832
|
+
if (typeof body !== 'string' || !body.startsWith(INTRO_HEAD) || !body.includes(INTRO_TAIL)) return null;
|
|
2833
|
+
const m = AGENT_NAME_RE.exec(body);
|
|
2834
|
+
return m ? m[1] : null;
|
|
2835
|
+
}
|
|
2836
|
+
let nameCache = null; // { file, size, name }
|
|
2837
|
+
function myAgentName() {
|
|
2838
|
+
const uuid = process.env.CLAUDE_CODE_SESSION_ID || '';
|
|
2839
|
+
if (!/^[0-9a-f-]{36}$/i.test(uuid)) return null;
|
|
2840
|
+
const f = transcriptOf(uuid);
|
|
2841
|
+
if (!f) return null;
|
|
2842
|
+
let st;
|
|
2843
|
+
try { st = fs.statSync(f); } catch { return null; }
|
|
2844
|
+
if (nameCache?.file === f && nameCache.size === st.size) return nameCache.name;
|
|
2845
|
+
const from = (nameCache?.file === f && st.size > nameCache.size) ? nameCache.size : 0;
|
|
2846
|
+
let found = (from > 0 ? nameCache.name : null);
|
|
2847
|
+
try {
|
|
2848
|
+
const fd = fs.openSync(f, 'r');
|
|
2849
|
+
try {
|
|
2850
|
+
const len = st.size - from;
|
|
2851
|
+
if (len > 0 && len < 64 * 1024 * 1024) {
|
|
2852
|
+
const buf = Buffer.allocUnsafe(len);
|
|
2853
|
+
fs.readSync(fd, buf, 0, len, from);
|
|
2854
|
+
const txt = buf.toString('utf8');
|
|
2855
|
+
// 줄 단위 — 마지막 것이 이긴다(옛 이름은 죽은 주소다).
|
|
2856
|
+
// ⚠ 쓰는 중인 마지막 줄(개행 없음)은 이번에 안 읽고 다음에 그 자리부터 다시 읽는다 —
|
|
2857
|
+
// 캐시 크기를 마지막 개행까지만 전진시킨다. 안 그러면 잘린 줄이 영영 사라진다.
|
|
2858
|
+
const cut = txt.lastIndexOf('\n');
|
|
2859
|
+
const whole = cut >= 0 ? txt.slice(0, cut + 1) : '';
|
|
2860
|
+
for (const line of whole.split('\n')) {
|
|
2861
|
+
const n = introNameOf(line);
|
|
2862
|
+
if (n) found = n;
|
|
2863
|
+
}
|
|
2864
|
+
nameCache = { file: f, size: from + Buffer.byteLength(whole, 'utf8'), name: found };
|
|
2865
|
+
return found;
|
|
2866
|
+
}
|
|
2867
|
+
} finally { fs.closeSync(fd); }
|
|
2868
|
+
} catch { /* 못 읽으면 이름이 없는 것이지 고장이 아니다 */ }
|
|
2869
|
+
nameCache = { file: f, size: st.size, name: found };
|
|
2870
|
+
return found;
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
/** 이 세션이 메일을 다룰 조건을 갖췄나. 못 갖췄으면 `null` — 조용히 안 한다. */
|
|
2874
|
+
/*
|
|
2875
|
+
* ⚠ **받기는 색인에 매이면 안 된다** (2026-08-25 운영 첫 왕복 실측). 이 세션은 push/list_sessions 를
|
|
2876
|
+
* 한 번도 안 해 색인에 없었고, 그래서 mail 도구로 **보낼 수는 있는데 받지는 못했다** —
|
|
2877
|
+
* 0f 의 답장이 꼬리에 영영 안 실렸다. 색인이 없으면 mail 도구가 마지막으로 쓴 소속처를
|
|
2878
|
+
* 힌트로 쓴다(그 소속처에 주소를 댔으니 받을 곳도 거기다).
|
|
2879
|
+
*/
|
|
2880
|
+
let mailTenantHint = null; // { tenant, tenant_id } — mail 도구가 성공할 때 적는다
|
|
2881
|
+
function mailReady() {
|
|
2882
|
+
const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
|
|
2883
|
+
if (!mine) return null;
|
|
2884
|
+
let e = indexRead()[mine];
|
|
2885
|
+
if (!e?.tenant && mailTenantHint) e = { ...(e || {}), ...mailTenantHint };
|
|
2886
|
+
if (!e?.tenant) return null; // 아직 어느 소속처인지 모른다 — 받기엔 이름이면 족하다
|
|
2887
|
+
const cred = credential();
|
|
2888
|
+
// 세계 장부와 같다 — 서버가 사람 토큰만 받는다.
|
|
2889
|
+
if (!cred || cred.kind !== 'user') return null;
|
|
2890
|
+
return { mine, e };
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
/**
|
|
2894
|
+
* 주소를 **스스로 댄다.** 관측 추정이 아니다(052 머리말의 세 실측).
|
|
2895
|
+
*
|
|
2896
|
+
* 이름이 없어도 등록한다 — 받을 수는 있어야 하기 때문이다. 이름이 생기면 다시 댄다.
|
|
2897
|
+
*/
|
|
2898
|
+
async function mailRegister(tenant, mine, force = false) {
|
|
2899
|
+
if (!force && Date.now() - mailAddrAt < MAIL_POLL_MS) return;
|
|
2900
|
+
mailAddrAt = Date.now();
|
|
2901
|
+
const name = myAgentName();
|
|
2902
|
+
await api('POST', '/api/mail/register', {
|
|
2903
|
+
tenant,
|
|
2904
|
+
session: mine,
|
|
2905
|
+
name: name || '',
|
|
2906
|
+
client: 'claude-code',
|
|
2907
|
+
cwd: relHome(process.cwd()),
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/**
|
|
2912
|
+
* 내 앞으로 온 것을 긷고, **받았다고 도장을 찍는다.**
|
|
2913
|
+
*
|
|
2914
|
+
* ⚠ 도장을 큐에 넣기 **전에** 찍는다. 뒤에 찍으면 프로세스가 그 사이 죽었을 때 같은 통을
|
|
2915
|
+
* 다음 판에서 또 말한다 — 그리고 그건 조용하다. 반대로 먼저 찍고 죽으면 한 통을 잃는데,
|
|
2916
|
+
* 그쪽은 **발신자가 만료로 알게 된다.** 두 손실 중 알 수 있는 쪽을 고른다.
|
|
2917
|
+
*/
|
|
2918
|
+
async function mailPull() {
|
|
2919
|
+
const r0 = mailReady();
|
|
2920
|
+
if (!r0) return;
|
|
2921
|
+
const { mine, e } = r0;
|
|
2922
|
+
if (Date.now() - mailAt < MAIL_POLL_MS) return;
|
|
2923
|
+
mailAt = Date.now();
|
|
2924
|
+
|
|
2925
|
+
try { await mailRegister(e.tenant, mine); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
|
|
2926
|
+
|
|
2927
|
+
if (mailCursor === null) mailCursor = Number(e.mail_cursor || 0);
|
|
2928
|
+
/*
|
|
2929
|
+
* ⚠ **자리(`cwd`)를 안 보낸다.** 창구가 그것으로 거르던 동안 주소 있는 메일이 조용히
|
|
2930
|
+
* 사라졌다(실측 2026-08-25: cwd 없이 1통 · cwd 있게 0통). 메일은 폴더가 아니라
|
|
2931
|
+
* 세션에 부친 것이라 **주소가 이미 필터**다.
|
|
2932
|
+
*/
|
|
2933
|
+
const q = '?tenant=' + encodeURIComponent(e.tenant)
|
|
2934
|
+
+ '&since=' + mailCursor
|
|
2935
|
+
+ '&session=' + encodeURIComponent(mine)
|
|
2936
|
+
+ '&limit=' + MAIL_PAGE;
|
|
2937
|
+
const r = await api('GET', '/api/mail' + q);
|
|
2938
|
+
|
|
2939
|
+
const rows = Array.isArray(r?.mail) ? r.mail : [];
|
|
2940
|
+
if (typeof r?.cursor === 'number' && r.cursor > mailCursor) mailCursor = r.cursor;
|
|
2941
|
+
indexNote(mine, { tenant: e.tenant, tenant_id: e.tenant_id, mail_cursor: mailCursor });
|
|
2942
|
+
|
|
2943
|
+
if (rows.length) {
|
|
2944
|
+
const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
|
|
2945
|
+
if (ids.length) {
|
|
2946
|
+
try { await api('POST', '/api/mail/ack', { tenant: e.tenant, ids }); }
|
|
2947
|
+
catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
|
|
2948
|
+
}
|
|
2949
|
+
/*
|
|
2950
|
+
* ★ **서버가 쓴 글자를 그대로 싣는다.** 발신자 표시(`[누구 → 너]`)를 붙이는 자리는
|
|
2951
|
+
* 서버의 `mail_render()` 하나뿐이고, 여기서 다시 지으면 두 자가 갈린다.
|
|
2952
|
+
* 그리고 갈리는 순간 「누가 말하는지」가 보증이 아니라 장식이 된다 —
|
|
2953
|
+
* 이 레일이 명령형을 허용하는 근거가 바로 그 보증이다.
|
|
2954
|
+
*/
|
|
2955
|
+
if (typeof r?.says === 'string' && r.says) mailQueue.push(r.says);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
for (const b of (Array.isArray(r?.returned) ? r.returned : [])) {
|
|
2959
|
+
const id = Number(b?.id) || 0;
|
|
2960
|
+
if (!id || mailBackSeen.has(id)) continue; // 서버가 1시간 동안 되풀이해 준다 — 접는 것은 우리 몫
|
|
2961
|
+
mailBackSeen.add(id);
|
|
2962
|
+
mailBack.push(` · ${cellClean(b.to_name || b.to_session || '')} 앞 «${cellClean(b.subject || b.body || '')}»`);
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
/**
|
|
2967
|
+
* 쌓인 것을 **한 번씩만** 말한다. 없으면 빈 문자열 — 조용한 구간은 조용해야 한다.
|
|
2968
|
+
*
|
|
2969
|
+
* ⚠ 여기서 지시를 붙이지 않는다. 붙일 필요도 없다 — 본문 자체가 사람이나 다른 세션이
|
|
2970
|
+
* 쓴 말이고, 그 앞에 **누가 말하는지가 이미 서버 손으로 박혀 있다.**
|
|
2971
|
+
*/
|
|
2972
|
+
function mailSays() {
|
|
2973
|
+
const out = [];
|
|
2974
|
+
if (mailQueue.length) out.push(...mailQueue.splice(0, mailQueue.length));
|
|
2975
|
+
if (mailBack.length) {
|
|
2976
|
+
out.push('내가 부친 것이 못 닿고 만료됐다:\n' + mailBack.splice(0, mailBack.length).join('\n'));
|
|
2977
|
+
}
|
|
2978
|
+
return out.join('\n\n');
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2468
2981
|
function worldAppend(result) {
|
|
2469
2982
|
/*
|
|
2470
2983
|
* ★ 백신이 **먼저**다. 세계 델타는 「누가 무엇을 썼다」는 배경이고, 모순은 지금 하는 일을
|
|
2471
2984
|
* 바꿀 수 있는 사실이다. 그리고 델타가 비어도(조용한 구간) 백신은 나가야 한다 —
|
|
2472
2985
|
* 아래 `if (!t) return;` 뒤에 두면 **조용한 날엔 영영 안 간다.**
|
|
2473
2986
|
*/
|
|
2987
|
+
/*
|
|
2988
|
+
* ★★ 메일이 **가장 앞**이다. 백신은 주소 없는 방송이고 델타는 배경인데, 메일은
|
|
2989
|
+
* 누군가가 **나를 지목해** 보낸 것이다. 그리고 만료가 있어 늦으면 되돌아간다 —
|
|
2990
|
+
* 셋 중 유일하게 **시간이 걸려 있는** 것이라 순서에서 앞선다.
|
|
2991
|
+
*/
|
|
2992
|
+
const mm = mailSays();
|
|
2993
|
+
if (mm) result.content.push({ type: 'text', text: mm });
|
|
2474
2994
|
const v = brainSays();
|
|
2475
2995
|
if (v) result.content.push({ type: 'text', text: v });
|
|
2476
2996
|
const t = worldDelta();
|
|
@@ -2633,15 +3153,23 @@ async function callTool(name, args = {}) {
|
|
|
2633
3153
|
const d = Number(p.attested_days) > 0 ? ` ${p.attested_days}일` : '';
|
|
2634
3154
|
return ` · ${rel}${d}`;
|
|
2635
3155
|
};
|
|
3156
|
+
/*
|
|
3157
|
+
* 표지 마크가 **없는** 곳만 말한다. 있는 곳은 서버가 알아서 끼우므로 할 말이 없다.
|
|
3158
|
+
*
|
|
3159
|
+
* ⚠ `=== false` 여야 한다. `!t.has_logo` 로 쓰면 이 칸이 없는 **옛 서버**에서
|
|
3160
|
+
* undefined 가 falsy 라 **모든 고객사에 「로고 없음」이 붙는다** — 「모르는 것을
|
|
3161
|
+
* 지어내지 않는다」는 이 도구의 규약을 정면으로 깬다(smoke 가 그것을 잡는다).
|
|
3162
|
+
*/
|
|
3163
|
+
const mark = (t) => (t.has_logo === false ? ' · 로고 없음' : '');
|
|
2636
3164
|
|
|
2637
3165
|
if (list.length === 1) {
|
|
2638
|
-
return `속한 곳이 하나입니다 — **${list[0].name}
|
|
3166
|
+
return `속한 곳이 하나입니다 — **${list[0].name}**${mark(list[0])}\n`
|
|
2639
3167
|
+ '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.'
|
|
2640
3168
|
+ (says ? `\n\n${says}` : '');
|
|
2641
3169
|
}
|
|
2642
3170
|
const lines = list.map((t) => {
|
|
2643
3171
|
const when = t.last_used_at ? String(t.last_used_at).slice(0, 10) : '기록 없음';
|
|
2644
|
-
return ` · ${t.name}${seen(t)} · 최근 ${when}`;
|
|
3172
|
+
return ` · ${t.name}${seen(t)}${mark(t)} · 최근 ${when}`;
|
|
2645
3173
|
});
|
|
2646
3174
|
return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
|
|
2647
3175
|
+ `${lines.join('\n')}\n`
|
|
@@ -2720,7 +3248,8 @@ async function callTool(name, args = {}) {
|
|
|
2720
3248
|
const lines = (o.blocks || []).map((b) =>
|
|
2721
3249
|
` ${b.tag}.${b.class}${b.side ? ` [${b.side}]` : ''}${b.text ? ` — ${b.text}` : ''}`);
|
|
2722
3250
|
return `발행물 구조 — 절 ${c.sections ?? '?'} · 건 ${c.cases ?? '?'} · 장 ${c.chapters ?? '?'}`
|
|
2723
|
-
+ ` · 그림 ${c.images ?? '?'}
|
|
3251
|
+
+ ` · 그림 ${c.images ?? '?'}(표지마크 ${c.cover_mark ?? 0})`
|
|
3252
|
+
+ ` · 좌우 ${c.side_l ?? 0}/${c.side_r ?? 0}`
|
|
2724
3253
|
+ ` · 도해 ${c.logic ?? 0} · 원칙카드 ${c.principles ?? 0}\n`
|
|
2725
3254
|
+ lines.join('\n')
|
|
2726
3255
|
+ (o.empty?.length ? `\n\n⚠ 빈 블록 ${o.empty.length}건: ${o.empty.join(', ')}` : '\n\n빈 블록 없음');
|
|
@@ -2794,12 +3323,32 @@ async function callTool(name, args = {}) {
|
|
|
2794
3323
|
const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
|
|
2795
3324
|
title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
|
|
2796
3325
|
evidence: args.evidence || '', tags: args.tags || [],
|
|
3326
|
+
paths: args.paths || [], commands: args.commands || [], errors: args.errors || [],
|
|
2797
3327
|
// ★ **출처는 우리가 채운다 — 모델에게 안 묻는다.** 스키마에 노출하면 지어낸다
|
|
2798
3328
|
// (DESIGN-session-as-vcs.md:136-141). 모르면 안 보내고, 서버는 안 적는다.
|
|
2799
3329
|
...myCitation(),
|
|
2800
3330
|
});
|
|
2801
|
-
|
|
2802
|
-
|
|
3331
|
+
// 밀기 결과도 그대로 옮긴다 — 몇에게 갔고 누가 왜 못 받았는지(구서버는 필드가 없어 침묵).
|
|
3332
|
+
const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
|
|
3333
|
+
if (Array.isArray(r.matched_paths)) {
|
|
3334
|
+
if (!r.matched_paths.length) {
|
|
3335
|
+
lines.push('경로가 없어 아무에게도 안 갔다 — 근거에 저장소 상대 경로(예: www/portal/lib/x.php:12)를 적으면 그 파일을 읽은 채로 든 세션에게 간다.');
|
|
3336
|
+
} else if (r.pushed > 0) {
|
|
3337
|
+
lines.push(`→ ${r.matched_paths.join(' · ')} 을(를) 들고 있던 세션 ${r.pushed}곳에 보냈다.`);
|
|
3338
|
+
} else {
|
|
3339
|
+
lines.push(`→ ${r.matched_paths.join(' · ')} — 지금 그 파일을 든 살아 있는 세션이 없어 아무에게도 안 갔다.`);
|
|
3340
|
+
}
|
|
3341
|
+
if (Array.isArray(r.skipped) && r.skipped.length) lines.push('못 보낸 곳: ' + r.skipped.join(' / '));
|
|
3342
|
+
}
|
|
3343
|
+
if (r.triggers && typeof r.triggers === 'object') {
|
|
3344
|
+
const t = r.triggers;
|
|
3345
|
+
const n = (t.paths?.length || 0) + (t.commands?.length || 0) + (t.errors?.length || 0);
|
|
3346
|
+
lines.push(n
|
|
3347
|
+
? `트리거 ${n}개 — 경로 ${t.paths?.length || 0} · 명령 ${t.commands?.length || 0} · 오류 ${t.errors?.length || 0}: 다른 세포가 그 자리를 만지기 직전에 이 사례를 본다.`
|
|
3348
|
+
: '트리거 0 — 경로·명령 문구가 없어 도구 직전 매칭은 안 된다(commands 에 명령 문구를 적으면 걸린다).');
|
|
3349
|
+
}
|
|
3350
|
+
lines.push('다음에 같은 증상을 만나면 search_cases 로 이 장을 먼저 찾는다.');
|
|
3351
|
+
return lines.join('\n');
|
|
2803
3352
|
}
|
|
2804
3353
|
case 'search_cases': {
|
|
2805
3354
|
/*
|
|
@@ -2824,7 +3373,24 @@ async function callTool(name, args = {}) {
|
|
|
2824
3373
|
*/
|
|
2825
3374
|
+ r.hits.map((h) => `· ${h.path} [${h.score}점]${h.status ? `\n ⚠ 상태: ${h.status}` : ''}`
|
|
2826
3375
|
+ `\n ${h.excerpt}`).join('\n')
|
|
2827
|
-
+ '\n전문은
|
|
3376
|
+
+ '\n전문은 get_case(tenant, path) — 처방·상태까지 그대로 읽는다.'
|
|
3377
|
+
+ (Number.isInteger(r.tenant_id) ? ` 사람 화면: wiki?t=${r.tenant_id}&p=<path>` : '');
|
|
3378
|
+
}
|
|
3379
|
+
case 'get_case': {
|
|
3380
|
+
// 서버 창구(/api/wiki/page)는 이미 있었다 — MCP 가 안 부르고 있었을 뿐이다(2026-08-25).
|
|
3381
|
+
// 세션을 실어 보낸다(myCitation 과 같은 규율 — 모델에게 안 묻는다) → 서버가 「읽혔다」를 남긴다(062).
|
|
3382
|
+
const mine = myCitation().session_uuid || '';
|
|
3383
|
+
const r = await api('GET', '/api/wiki/page?tenant=' + encodeURIComponent(args.tenant)
|
|
3384
|
+
+ '&path=' + encodeURIComponent(args.path) + (mine ? '&session=' + mine : ''));
|
|
3385
|
+
return `${r.path}${r.signals ? '\n' + r.signals : ''}\n\n${r.markdown}`;
|
|
3386
|
+
}
|
|
3387
|
+
case 'case_verdict': {
|
|
3388
|
+
const r = await api('POST', '/api/wiki/verdict?tenant=' + encodeURIComponent(args.tenant), {
|
|
3389
|
+
path: args.path, verdict: args.verdict, note: args.note || '',
|
|
3390
|
+
...myCitation(), // 세션 uuid 는 우리가 채운다 — 없으면 서버가 안 적는다
|
|
3391
|
+
});
|
|
3392
|
+
return `${r.says || '판정을 적었다.'}\n`
|
|
3393
|
+
+ '(세션이 중계한 판정이다 — 사람이 지식 화면에서 누른 판정만 도구 직전 알림·검색에 반영된다.)';
|
|
2828
3394
|
}
|
|
2829
3395
|
case 'list_sessions': {
|
|
2830
3396
|
const query = new URLSearchParams({ tenant: String(args.tenant) });
|
|
@@ -2833,10 +3399,25 @@ async function callTool(name, args = {}) {
|
|
|
2833
3399
|
const r = await api('GET', `/api/sessions?${query.toString()}`);
|
|
2834
3400
|
const sessions = Array.isArray(r.sessions) ? r.sessions : [];
|
|
2835
3401
|
if (!sessions.length) return `${r.tenant} — 올라온 세션이 없습니다.`;
|
|
3402
|
+
// 생사는 서버가 판정해 실어 준다(`beat` — rp_agent_presence · 화면 맥박과 같은 문턱).
|
|
3403
|
+
// 구판 서버는 이 필드가 없다 — 그때는 아무 말도 덧붙이지 않는다(모르면 침묵).
|
|
3404
|
+
const beatMark = (s) => {
|
|
3405
|
+
const b = s.beat;
|
|
3406
|
+
if (!b || typeof b !== 'object') return '';
|
|
3407
|
+
if (b.state === 'alive') return ` ● 살아 있음(기기 ${b.devices})`;
|
|
3408
|
+
if (b.state === 'dead') {
|
|
3409
|
+
const at = String(b.last_beat || '').slice(5, 16); // 'MM-DD HH:MM'
|
|
3410
|
+
// 좌초(5단계) — 마지막 심박이 서버보다 앞선 채 죽었다: 그 줄들은 그 기계에만 있다.
|
|
3411
|
+
const stranded = Number.isInteger(b.stranded) && b.stranded > 0
|
|
3412
|
+
? ` · 미전송 ${b.stranded}줄 — 이어받기 후보(그 기계에서 push_session 하면 산다)` : '';
|
|
3413
|
+
return (b.closed ? ` ○ 닫힘 ${at}` : ` ○ 심박 끊김 ${at}`) + stranded;
|
|
3414
|
+
}
|
|
3415
|
+
return ' · 심박 기록 없음';
|
|
3416
|
+
};
|
|
2836
3417
|
const rows = sessions.map((s) => {
|
|
2837
3418
|
const mark = s.deleted ? ' (가려짐)' : '';
|
|
2838
3419
|
return ` ${s.uuid.slice(0, 8)} ${s.updated} ${s.lines}줄 ${(s.bytes / 1024).toFixed(0)}KB`
|
|
2839
|
-
+ ` ${s.title || '(제목 없음)'}${mark}`;
|
|
3420
|
+
+ ` ${s.title || '(제목 없음)'}${mark}${beatMark(s)}`;
|
|
2840
3421
|
});
|
|
2841
3422
|
// **본 것을 적어 둔다.** 이 짝(uuid → 고객사 id)이 없으면 심박을 보낼 수가 없다 —
|
|
2842
3423
|
// 프레임에는 고객사 이름이 아니라 id 가 실린다.
|
|
@@ -2849,6 +3430,14 @@ async function callTool(name, args = {}) {
|
|
|
2849
3430
|
&& Number.isInteger(p.range.last)) {
|
|
2850
3431
|
count = `세션 ${p.total}건 중 ${p.range.first}–${p.range.last}`;
|
|
2851
3432
|
}
|
|
3433
|
+
// 요약 맥박 — 목록을 다 읽기 전에 「몇이 살아 있나」부터 답한다. 이 page 에 보이는 만큼만 센다.
|
|
3434
|
+
if (sessions.some((s) => s.beat && typeof s.beat === 'object')) {
|
|
3435
|
+
const alive = sessions.filter((s) => s.beat?.state === 'alive').length;
|
|
3436
|
+
const dead = sessions.filter((s) => s.beat?.state === 'dead').length;
|
|
3437
|
+
const dark = sessions.length - alive - dead;
|
|
3438
|
+
const stranded = sessions.filter((s) => Number.isInteger(s.beat?.stranded) && s.beat.stranded > 0).length;
|
|
3439
|
+
count += ` · ● ${alive} ○ ${dead}${dark ? ` · 기록없음 ${dark}` : ''}${stranded ? ` · ⚠ 좌초 ${stranded}` : ''}`;
|
|
3440
|
+
}
|
|
2852
3441
|
const follow = [];
|
|
2853
3442
|
if (p && Number.isInteger(p.next)) {
|
|
2854
3443
|
follow.push(`다음 목록: list_sessions(tenant: ${JSON.stringify(args.tenant)}, `
|
|
@@ -2968,6 +3557,48 @@ async function callTool(name, args = {}) {
|
|
|
2968
3557
|
+ `이어서 말하려면: cd ${into} && claude --resume ${args.uuid}\n`
|
|
2969
3558
|
+ (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
|
|
2970
3559
|
}
|
|
3560
|
+
case 'mail': {
|
|
3561
|
+
const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
|
|
3562
|
+
if (!mine) {
|
|
3563
|
+
return '이 세션의 번호를 모른다 — 주소를 댈 수 없어 보낼 수 없다'
|
|
3564
|
+
+ '(CLAUDE_CODE_SESSION_ID 가 없는 환경이다).';
|
|
3565
|
+
}
|
|
3566
|
+
/*
|
|
3567
|
+
* 주소가 없으면 먼저 댄다. **이름이 없으면 서버가 409 로 거절**하므로,
|
|
3568
|
+
* 그 경우를 여기서 미리 사람 말로 바꿔 준다 — 서버 오류문만 보면 무엇을 해야
|
|
3569
|
+
* 하는지 알 수 없다.
|
|
3570
|
+
*/
|
|
3571
|
+
if (!myAgentName()) {
|
|
3572
|
+
return '이 세션에는 아직 말 걸 수 있는 이름이 없어 **보낼 수가 없다**'
|
|
3573
|
+
+ '(받는 쪽이 누가 말하는지 못 보면 그것은 메시지가 아니라 주입이다).\n'
|
|
3574
|
+
+ '이름은 `ListAgents` 를 한 번 부르면 생긴다 — 하네스가 「This session is …」를 '
|
|
3575
|
+
+ '전사에 적고, 이 도구가 그것을 읽는다. 부른 뒤 다시 보내면 된다.';
|
|
3576
|
+
}
|
|
3577
|
+
try { await mailRegister(args.tenant, mine, true); }
|
|
3578
|
+
catch (e) { return `주소를 대지 못했다 — ${e.message}`; }
|
|
3579
|
+
|
|
3580
|
+
const r = await api('POST', '/api/mail', {
|
|
3581
|
+
tenant: args.tenant,
|
|
3582
|
+
to: args.to,
|
|
3583
|
+
body: args.body,
|
|
3584
|
+
subject: args.subject || '',
|
|
3585
|
+
artifact: args.artifact || '',
|
|
3586
|
+
done_when: args.done_when || '',
|
|
3587
|
+
cwd: relHome(process.cwd()),
|
|
3588
|
+
...(args.thread ? { thread: args.thread } : {}),
|
|
3589
|
+
// ★ **출처는 우리가 채운다 — 모델에게 안 묻는다**(remember_case 의 myCitation 과 같은 규율).
|
|
3590
|
+
session: mine,
|
|
3591
|
+
});
|
|
3592
|
+
// ★ 받을 곳을 기억한다 — 색인 밖 세션도 이 소속처에서 받는다(mailReady 힌트 + 색인).
|
|
3593
|
+
// ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
|
|
3594
|
+
// GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
|
|
3595
|
+
mailTenantHint = { tenant: args.tenant, tenant_id: r?.tenant_id || 0 };
|
|
3596
|
+
if (r?.tenant_id) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id });
|
|
3597
|
+
return `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}번째.\n`
|
|
3598
|
+
+ `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
|
|
3599
|
+
+ (r.says ? `${r.says}\n` : '')
|
|
3600
|
+
+ '답이 오면 다른 도구를 부를 때 응답 꼬리에 저절로 실린다 — 따로 확인할 필요 없다.';
|
|
3601
|
+
}
|
|
2971
3602
|
case 'install_cell': {
|
|
2972
3603
|
const { ok, out } = cellInstall({ hooks: args.hooks !== false });
|
|
2973
3604
|
const now = cellCheck();
|
|
@@ -3283,6 +3914,7 @@ if (IS_MAIN) {
|
|
|
3283
3914
|
* 꺼진 것이지 고장이 아니다(그림 자격이 없을 때와 같은 태도).
|
|
3284
3915
|
*/
|
|
3285
3916
|
startBeat();
|
|
3917
|
+
startMailPoll(); // 메일 폴은 심박의 조기 반환에 매이지 않는다
|
|
3286
3918
|
startObserve(); // 훅이 안 걸리는 도구(Codex)도 관측을 올린다 — 위 머리말 참조
|
|
3287
3919
|
process.stdin.setEncoding('utf8');
|
|
3288
3920
|
process.stdin.on('data', (chunk) => { buf += chunk; pump(); });
|