로컬 PC의 STL 파일을 채팅 없이 K2 앱에서 바로 업로드→슬라이싱→K2 업로드까지
할 수 있는 모달 추가. print3d_model 채팅도구와 로직을 공유하도록
k2-slicer-core.ts로 공통 부분(프린터 프로파일, PrusaSlicer 실행, Moonraker
업로드, 시간 파싱)을 분리했다.
- k2-slicer-core.ts: print3d.ts에서 추출한 공용 슬라이싱 로직 + 오늘 실측한
FAST_SPEED_ARGS(퍼리미터/인필/트래블 속도업, 브릿지는 유지)
- routes-k2.ts: POST /api/k2/slice(STL 원본 바이트 바디+쿼리스트링 설정,
--duplicate로 동일 파츠 복제 지원 — 서로 다른 파츠 합치기(--merge)는 이
헤드리스 환경에서 세그폴트 나서 미지원), POST /api/k2/print-start(Moonraker
프록시, mixed-content 회피)
- k2-app.html: 헤더에 "🔪 슬라이싱" 버튼 → 모달(파일업로드/수량/소재/레이어
높이/인필/속도/서포트) → 결과+출력시작 버튼. 실제 파일 업로드로
슬라이싱→K2업로드까지 브라우저에서 확인함(출력 시작은 실제 프린트 중이라
테스트 안 함)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
행마다 항상 다 펼쳐져 있던 표 대신, 이름/상태뱃지/소계만 보이는 헤더를
클릭하면 수량·단가·상태·메모 편집 필드가 펼쳐지는 카드로 교체. 펼침
상태는 화면 전용(서버 저장 안 함)이라 다시 그려도 유지됨.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
부품 리스트를 보면서 바로 옆에서 로봇 프로젝트 얘기를 할 수 있게 리사이즈
가능한 채팅 패널을 대시보드에 붙임. 세션ID를 "rb_"로 시작하게 해서
tool-scope.ts가 이 세션 안에서는 키워드 매칭 없이 robot_project 도구를
항상 열어주도록 예외 처리(doctor_app의 'dr_'/lawyer_app의 'lw_'와 동일한
패턴) — "이 부품 지워줘"처럼 짧게 말해도 바로 동작한다.
채팅 응답이 끝나면 대시보드(부품표/예산/작업)를 자동으로 다시 불러와서
채팅으로 바꾼 내용이 즉시 화면에 반영되게 함.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
로봇 프로젝트("메카넘휠" 이동로봇) 대화에서 부품 상태 변경/추가/삭제, 작업
완료 체크, 새 단계/작업 추가, 메모 추가를 채팅으로 바로 할 수 있게 하는
도구. robot-app.html 대시보드와 같은 저장소(robot-storage.ts로 분리)를
쓰므로 채팅에서 바꾸면 대시보드에도, 대시보드에서 바꾸면 다음 채팅
조회에도 즉시 반영된다.
- robot-storage.ts: routes-robot.ts에서 인라인으로 있던 저장 로직을
분리(case-storage.ts와 같은 목적) — 라우트와 도구가 공유
- robot-project.ts: get/add_part/update_part/delete_part/add_task/
set_task_done/add_phase/add_note 8개 액션, 이름 일부일치 검색이라
내부 id를 몰라도 채팅으로 지칭 가능. 물리적 동작이 없어 confirm 불필요
- registry.ts/build-tools.ts/tool-scope.ts 3곳 등록. 게이트 키워드는
"로봇청소기"(기존 Valetudo 대화)와 안 겹치게 프로젝트 고유 어휘로만 매칭
- 실제 채팅으로 "예산 얼마야?+메카넘휠 주문완료로 바꿔줘" 동시 요청 테스트,
정확한 응답+저장 확인
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
로봇 프로젝트 대시보드에 실제 공작 장비(K2 3D프린터, CNC 밀링 프린터실
PTZ카메라) 상태를 보는 "장비" 탭을 추가. 우선 가벼운 주기적 스냅샷 방식으로
구현(실시간 WebRTC 상시연결은 자원 부담 커서 보류, 추후 업그레이드 검토).
- routes-k2.ts: GET /api/k2/status — Moonraker print_stats/virtual_sdcard를
요약해서 반환(온라인여부/상태/파일명/진행률/경과시간)
- nvr.ts: GET /api/nvr/frame/:channelId — go2rtc(127.0.0.1:1985)의
frame.jpeg를 프록시(밀링 PTZ카메라, channelId=1000 "프린터실"), go2rtc
포트를 브라우저에 직접 노출하지 않음
- robot-app.html: "장비" 탭 — K2는 20초 간격으로 짧게 WebRTC 연결→ontrack에서
캔버스 캡처→즉시 종료(상시 연결 안 함), 밀링은 위 프록시로 img 새로고침.
"장비" 탭을 보고 있을 때만 폴링, 다른 탭으로 나가면 타이머 정지
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
메카넘휠 이동로봇 프로젝트(파이5+RPLidar, 추후 팔 추가)의 부품리스트/
단계별 작업/예산을 관리하는 웹앱. doctor-app의 GET/PUT 단일문서 패턴을
재사용하되 케이스 목록 없이 단일 프로젝트 문서로 단순화했고, 첫 로드시
project_robot_dog.md 메모 기준 실제 진행상황을 기본 시드로 반환한다.
- src/gateway/routes/routes-robot.ts: GET/PUT /api/robot/project,
사용자별 workspace/.smallclaw/robot-project.json에 원자적 저장
- web-ui/html/robot-app.html: 부품/작업/메모 3탭, 실시간 예산 요약,
디바운스 자동저장
- index.html 🏠 홈 드롭다운에 🤖 로봇 링크 추가
장비연동/전용챗봇/외부데이터연동/CAD플러그인은 다음 단계로 보류.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CuraEngine CLI는 구조적으로 예상 출력시간 헤더가 항상 더미값(6666)만 남는
한계가 있어(33b5a25 참고) PrusaSlicer(apt, gcode-flavor=klipper 네이티브
지원)로 교체. DISPLAY 없이도 --export-gcode가 완전 헤드리스로 동작함을
확인했고, 슬라이싱 후 파일에 실제 "estimated printing time"이 직접
기록되므로 stderr 파싱 없이 파일에서 바로 읽으면 됨. K2 프로파일/시작-매크로
값은 그대로 유지. 더 이상 쓰지 않는 resources/cura/*.def.json 제거.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CuraEngine 5.0.0 소스 확인 결과, CommandLine 백엔드는 isSequential()이 항상
true라 슬라이싱 "시작 전"에 G-code 헤더를 미리 써버리고(processStartingCode
-> getFileHeader, print_time 인자 없음) 끝난 뒤 되돌아가 실제값으로 덮어쓰는
코드가 어디에도 없다. 그래서 ;TIME: 헤더는 항상 gcodeExport.cpp에 하드코딩된
더미값(6666)만 남는다 — 확률적 버그가 아니라 CLI 모드의 구조적 한계였음.
실제 값은 슬라이싱 완료 후 stderr(verbose 로그)에 "Print time (s): N"으로만
찍히므로, G-code 파일 헤더 대신 stderr를 파싱하도록 변경.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CuraEngine(apt cura-engine, 클로서버)로 STL을 슬라이싱해 K2 콤보(Moonraker)에
업로드하는 도구. action="prepare"(다운로드+슬라이싱+업로드, 출력은 안 함)와
action="start"(confirm=true 필수, 실제 출력 시작)로 분리 — 재료/시간 소모되는
물리적 동작이라 사용자 확인 없이는 출력 시작 안 되게 함.
프린터 프로파일(빌드볼륨/노즐/리트랙션/온도/START_PRINT·END_PRINT 매크로 호출)은
추측이 아니라 K2에 이미 있던 Creality Print 슬라이싱 샘플 gcode 헤더에서 실측값
그대로 추출. fdmprinter.def.json/fdmextruder.def.json은 설치된 CuraEngine
5.0.0과 버전 맞춰서 받아야 함(main 브랜치 최신판 쓰면 설정 스키마 불일치로
슬라이싱이 즉시 실패함 — resources/cura/에 버전 고정해서 커밋).
tool-scope.ts에 3D프린트 관련 키워드 게이트 추가(다른 니치 도구들과 동일 패턴).
end-to-end 테스트 완료(3DBenchy STL로 다운로드→슬라이싱→K2 업로드 확인, 테스트
파일은 삭제함).
onicecandidate가 ev.candidate===null(전체 gathering 종료)까지 기다린 뒤에만 오퍼를
보내고 있었는데, 이 네트워크에서 그 신호가 20~30초씩 걸림(가상 인터페이스들 순회+
타임아웃으로 추정). 실제 필요한 STUN srflx 후보는 1~2초면 잡히므로, 완전종료를
기다리지 않고 1.5초 타임아웃 뒤 그때까지 모인 후보로 바로 오퍼를 보내도록 변경.
브라우저 실측: 연결까지 20~30초 → 3초 이내로 단축.
- nvr-app.html: go2rtc WS URL을 페이지 오리진 기준 상대경로로 (하드코딩된 ws://LAN IP가
HTTPS 페이지에서 mixed-content로 막히던 문제)
- k2-app.html: 카메라 재연결 시 오래된 연결의 실패 콜백이 새 연결 상태를 덮어쓰던 레이스 수정
- routes-k2.ts: K2 시그널링 fetch에 타임아웃 추가(프린터 응답없음 시 무한대기 방지)
- nvr.ts/routes-comfyui.ts/routes-android.ts에 중복돼있던 raw WebSocket 업그레이드
프록시(connect→pipe→cleanup)를 ws-proxy-util.ts의 공통 헬퍼로 통합
Xiongmai NVR이 3rd파티 카메라(Mercusys)를 "Resolution too big" +
online=false로 계속 보고하는데, 실제로는 RTSP 패스스루가 정상 동작함
(/ch5_0.264 프레임 정상 수신 확인). 그런데 quadChannels 필터가 c.online만
보고 있어서 마당이 6분할에서 빈 슬롯으로 렌더됐음.
chnStreamable() = online || "resolution too big" 상태 → 이런 채널도
6분할·라이브 버튼에 포함. 진짜 죽은 채널은 img.onerror가 "영상 없음" 처리.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
카메라 메인 스트림을 1920×1080으로 낮춰(ONVIF) NVR이 채널 5로
직접 물 수 있게 됨 → NVR 녹화 포함. standalone id 1000 불필요해져 삭제.
- nvr.channelLabels["5"] = "마당"
- nvr.channelStream에서 "1000" 제거 (extra camera 없어짐)
- nvr.extraCameras = []
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
routes/nvr.ts의 extra 카메라 매핑이 preferredStream을 null로 하드코딩해서
nvr.channelStream(채널별 서브스트림 고정)이 독립 카메라엔 적용되지 않았음.
Mercusys 마당은 메인 스트림(/stream1) 동시 RTSP 세션을 1개만 허용해서,
4분할 "메인(고화질)" 체크 시 다른 세션과 겹치면 Operation not permitted로
거부 → 타일이 "영상 없음". (확대 모달은 4분할을 먼저 정지해서 됐음)
- getChannelStreamPrefs()[id]를 독립 카메라 preferredStream에도 반영
- config: channelStream["1000"]=1 (마당 서브 720p 고정) — 채널3과 동일 방식
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
실사고(papa): 대화 초반 "2번이 옥상 카메라"를 모델이 외웠는데, 이후 채널 라벨을
붙이면서 채널 2 = 대문이 됨. "옥상 확인"에 모델이 camera:"2" 전달 → 대문 프레임을
받고도 "옥상 카메라 확인했습니다"라고 답함.
- 도구 설명/스키마: 채널 번호 말고 위치 이름(선룸/옥상/대문/뒷산/거실/마당)을 쓰라고 명시
- 번호로 호출 시 결과에 "[확인] 채널 번호 '2' = 대문 카메라입니다" 명시 —
모델이 스테일 매핑을 자각하고 이름으로 재호출하게
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
/netsdk/Channel/IPCamInfo + Stat/IPC로 실제 연결된 채널만 나열(nvrEnabledChannelIds).
같은 라벨의 독립카메라(Mercusys)가 있으면 NVR쪽 항목 대신 직접 RTSP 쪽을 쓴다
(NVR ch5 = Mercusys인데 NVR쪽 연결은 Connect Failed).
독립카메라 라벨 변경 엔드포인트 POST /api/nvr/extra-cameras/:id/label 추가.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
실사용(papa, 2026-09-07)에서 드러난 2건:
- "cctv확인해봐"로 도구가 돌았는데 다음 "아니다 cam2"/"사진 보여줘" 후속턴엔
hasCctvKeyword가 현재 메시지만 보느라 도구가 빠졌고, 모델이 "저는 그런 툴이
없습니다"를 3턴 반복 → topicText(직전 2턴 포함)로 검사, cam N·스냅샷·캡처 등 추가
- 캡처된 프레임을 모델이 프로즈로만 설명하고 이미지 마크다운을 안 뽑아 사용자가
사진을 못 봄 → satellite_snapshot처럼 execute-tool에서 SSE로 이미지 직접 push
- 도구 결과 지시문을 "이전 대화 짐작 말고 이 프레임에 보이는 것만 구체적으로"로 강화
(gemma4가 첫 응답에서 맥락에 기대 뭉뚱그린 사례)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
"마당에 누구 있어?", "택배 왔나 봐줘" 같은 요청에 NVR/독립카메라의 현재 프레임을
ffmpeg로 한 장 캡처해 비전 모델에 임베드한다.
- src/tools/nvr.ts: RTSP → ffmpeg 단일 프레임 → workspace/nvr-snapshots/ → .
카메라는 이름("현관")·채널번호("2")·자유입력 모두 해석, 못 찾으면 목록 반환.
main 실패 시 sub 스트림으로 1회 재시도. 파일명은 ASCII(썸네일 execSync 한글경로 회피).
- routes/nvr.ts: getNvrConfig/getExtraCameras/getChannelLabels export + buildRtspUrl 추출
(라이브 스트림 라우트와 공유, 중복 제거).
- tool-scope.ts: hasCctvKeyword 게이트 — CCTV/카메라/마당/현관/택배/"누구 있" 등에만.
- handle-chat.ts: 비전 판정을 _modelSupportsVision 헬퍼로 통일 + **gemma 추가**
(gemma4:31b-cloud는 멀티모달인데 4곳의 정규식에서 다 빠져 있어 weather_map_screenshot
등에서도 "이미지 못 봄" 취급받고 있었음 — 2026-09-07 실측으로 비전 정상 확인).
_imagingTools 3곳에 nvr_snapshot 추가.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
모델이 "60~80cm", "25°C" 같은 걸 습관적으로 `$...$`로 감싸는데, KaTeX로 렌더하면
수식 이탤릭체라 한글 문장에서 어색하고 TTS·복사도 깨진다. \text{}/\mathrm{} 단위가
있고 나머지가 숫자·범위기호·간단한 연산자뿐이면 유니코드 평문으로 치환한다
(\sim→~, \times→×, ^2→² 등). 치환 뒤 `\ ^ _ { } =`가 남으면 진짜 수식이라 그대로 둔다.
chat-render.js(메인챗)·katex-inline.js(앱페이지) 둘 다.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
`$` 다음에 숫자가 오면 무조건 통화로 보고 여는 `$`를 감췄는데, 모델이
`$60\sim80\text{cm}$`처럼 숫자로 시작하는 LaTeX를 내면 수식 전체가 날것으로
남아 `\sim`·`\text{cm}`가 그대로 노출됐다("단위가 이상하게" 신고).
다음 `$`까지의 구간에 백슬래시가 있으면 수식으로 판단해 그 `$`는 건드리지 않는다.
기존 통화 케이스(`$100 절감 … $\rightarrow$`)는 그대로 통화로 처리됨.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
a1033f6에서 "USER.md도 SOUL.md도 4명 전 사용자 누구에게도 없어서 memory_browse/read는
'not found' 에러만 낼 수 있다"고 적었는데 **틀렸다**. 감사 때 `ls workspace/*.md`로만 확인했고,
파일은 `workspace/prompts/` 아래에 있다 — resolvePromptPath가 PROMPT_FILES에 대해 그쪽을
**먼저** 본다(server.ts:1199). papa의 USER.md는 3.2KB에 카테고리 5개, 최종 수정 2026-08-06으로
homeclaw_memory 컬렉션의 최신 문서 날짜와 정확히 일치한다. 도구 셋 다 정상 동작한다.
게이팅 결정 자체는 유지한다 — 근거가 사용량이기 때문이다(1,011턴에서 browse 0 / read 2 /
write 1회인데 매 턴 357토큰). 다만 코드·테스트 주석에 박힌 거짓 근거를 걷어낸다. "죽은
레거시라 빼도 된다"와 "멀쩡하지만 거의 안 쓰여 게이팅한다"는 다음 사람이 내릴 판단이 달라진다.
memory_write는 파일이 없으면 만들지 않고 에러를 낸다는 점도 함께 정정했다(execute-tool.ts:1220).
앞선 커밋 메시지의 "없으면 새로 만든다"는 서술이 잘못이었다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
memory_browse / memory_write / memory_read는 지금까지 **매 턴 무조건** 실려 357토큰을 냈다.
실사용 1,011턴에서 호출은 browse 0회 / read 2회 / write 1회.
파고 보니 단순한 저사용이 아니었다: **USER.md도 SOUL.md도 4명 전 사용자 누구에게도 없다.**
resolvePromptPath에 템플릿 폴백이 없으므로(교차 오염 위험은 없다), memory_browse와 memory_read는
파일이 없어 "USER.md not found. Create it first." 에러만 돌려줄 수 있는 상태로 매 턴 실려 있었다.
실제 장기기억은 일별 로그 + Chroma 벡터 추출(자동)이 담당한다 — USER.md 경로는 그 시스템이
대체한 레거시다([[project_legacy_skill_system]]과 같은 유형: 죽은 하위시스템이 스키마 비용만 계속 냄).
제거가 아니라 게이팅인 이유: 사용자가 "기억해둬"라고 명시하는 턴에는 있어야 하고, memory_write는
파일이 없으면 새로 만들므로 그 경로 자체는 지금도 유효하다. topicText로 판정해 "그것도 기억해둬"
같은 후속 발화도 잡는다. memory_stats는 벡터 스토어를 보므로 게이트 대상이 아니다(13회 호출됨).
실측(고유 782턴): 도구 토큰 중앙값 1,221 → 864 (-357/턴), 누적 278,460tok 절감.
수동 메모리 도구가 실리는 턴은 0.3%로 떨어진다.
기존 계약 테스트 2개가 "memory_read는 핵심 도구라 항상 남는다"를 고정하고 있어 갱신했다 —
핵심 도구 목록에서 memory_read를 memory_stats로 바꾸고, 기억 의도가 있으면 다시 나타나는지를
새로 고정했다. 551개 통과.
⚠ 별건: 이번에 사용자가 "기억해둬"라고 5턴에 걸쳐 명시한 세션(0c367bd7 10925~10940)에서
memory_write가 한 번도 불리지 않았음을 확인했다. 모델은 매번 "기억해 두겠습니다"라고만 했다.
정보 자체는 일별 로그에 남아 벡터 추출이 받지만, 명시적 지시가 도구 호출로 이어지지 않는 것은
별도 문제다(MESSAGING 가드와 같은 유형의 도달 실패). 이 커밋은 그 문제를 고치지 않는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
효율 감사 실측(사용자 턴 1,011건 / LLM 호출 3,613건 / prompt 누적 29.1M tok):
· 고정비 2,494tok/턴 = 전체 prompt의 31%
· 출력 60토큰 미만 호출이 47% — 도구 라운드·가드 재프롬프트라 고정비가 라운드 수만큼 곱해진다
· **59%의 턴이 grounding 도구를 한 번도 부르지 않는다**
searchReadingRule(481tok)에는 성격이 다른 두 가지가 한 덩어리로 섞여 있었다:
① 검색어를 어떻게 쓸 것인가(세대 추측 금지, 154tok) — 검색 **전에** 필요
② 돌아온 결과를 어떻게 읽을 것인가(스테일 타임스탬프·표 행 정렬, 333tok) — 결과가 **생긴 뒤**
에만 쓸모가 있다
②를 groundingResultReadingRule()로 분리해, handle-chat이 첫 grounding 결과가 들어온 시점에
대화 뒤에 한 번 덧붙인다. 읽을 결과가 없는 59%의 턴은 이제 이 값을 아예 내지 않고, 부르는
턴도 검색어를 쓰는 1라운드에는 내지 않는다. 인사 턴 시스템 프롬프트 1,273 → 904tok(-29%).
**시스템 메시지를 갈아끼우지 않고 뒤에 덧붙인 이유는 KV 캐시다.** 시스템 프롬프트는 라운드 루프
앞에서 한 번 만들어지므로, 라운드마다 다시 만들면 프리픽스가 달라져 캐시가 통째로 무효화된다 —
라운드마다 7K 토큰을 다시 계산하게 되어 아낀 것보다 잃는 게 크다. 뒤에 붙이면 프리픽스는 그대로다.
부수 효과로 지침이 대상(검색 결과) 바로 옆에 놓인다. 제약이 겹치면 먼 것부터 버리는 모델에게는
이쪽이 유리하다([[feedback_local_model_needs_code_backstop]]).
2026-09-02 감사가 만든 hasGroundingTools 게이트는 사실상 죽어 있었다는 점도 이번에 드러났다 —
tool-scope에 web_search를 거르는 분기가 없어 그 조건은 항상 참이다. 이번 분리는 그 게이트에
의존하지 않는다.
기존 회귀 테스트가 이동을 정확히 잡아냈다(2026-08-10 캐시된 기상표 오독 사고의 예시 문자열).
테스트를 새 위치로 옮겨 내용이 사라지지 않았음을 계속 고정한다. 546개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
첫 버전은 "몇 번째 줄 이후"로 기준선을 잡았는데, 로그 회전 설정을 확인해 보니 그 방식이
하루도 못 간다: logrotate가 크기 1M 기준 + `copytruncate`라, 회전 시 내용이 .1로 복사되고
원본은 0바이트로 잘린다. 패치 당일 gateway.log가 이미 1.05M이라 그날 밤 바로 잘릴 참이었다.
대신 gateway.log* 전 파일(.gz 포함)을 합쳐 패턴별 누적 횟수를 세고 기준선 값을 뺀다. 회전해도
내용은 .1/.2.gz로 옮겨갈 뿐 사라지지 않으므로 이 방식은 회전에 견딘다. rotate 14라 파일이
실제로 없어지려면 현재 증가 속도로 반년 이상 걸리고, 그때는 차이가 음수로 나와 경고한다.
패치 직전 기준(누적 1,011턴, 턴당): NUMERIC 재프롬프트 0.051 / AUTO-RECOVER 0.080 /
SEARCH-ABANDONMENT 0.010 / 중복 SKIP 0.035 / 합성 Answer: 0.009 / standing down 0(미존재).
2026-09-09 10:07 KST 1회 실행되도록 systemd user timer(guard-audit.timer) 등록. 이 박스는
매일 02시에 자므로 Persistent=true로 기상 후 따라잡게 했다. 결과는
.smallclaw/logs/audit/report-<날짜>.txt 로 남는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 가드 패치(db6a863 / 10e54d3 / b2075c6)가 실사용에서 어떻게 도는지 며칠 뒤 재려고
만들었다. 기준선은 .smallclaw/logs/audit/guard-baseline.json(머신 로컬, gitignore 대상)에
줄 번호 + 꼬리 30줄 지문으로 박혀 있다 — 로그에 타임스탬프가 없어서다. 로테이션되면 지문이
안 맞고, 그때는 경고를 내고 전체를 잰다.
패치 직전 기준값(전체 340턴): NUMERIC 재프롬프트 24(0.07/턴), AUTO-RECOVER 37(0.11/턴),
SEARCH-ABANDONMENT 7(0.02/턴), 합성 Answer: 줄 1건, standing down 0(미존재).
판독 기준은 스크립트 말미에 적어 뒀다 — 관련성 관문이 헐거우면 NUMERIC이 계속 맞는 답을
지우고, 빡빡하면 standing down이 과해져 진짜 날조가 통과한다. 양방향 모두 봐야 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
이 함수가 만드는 줄은 검색 결과 stdout 맨 앞에 붙는다. 시스템 프롬프트의 ANTI-HALLUCINATION은
"도구가 돌려준 것을 정확히 보고하라, 절대 무시하지 말라"고 지시하므로, 여기서 틀리면 모델에게
틀린 값을 신뢰하라고 시키는 셈이다. 게다가 합성된 숫자는 도구 출력 텍스트의 일부가 되므로,
모델이 그대로 옮기면 NUMERIC-GROUNDING이 "소스에 있다"며 통과시킨다 — 우리가 만든 숫자가
'근거 있음' 도장을 받아 나간다.
실제 사고(프로덕션 로그):
USER: 컨테이너선 급유비가 얼마나 될까?
TOOL: web_search("average fuel cost for 20,000 TEU container ship per voyage")
TOOL OK: Answer: The current price is approximately $38.00 USD.
$38은 스니펫에 있던 "신조선 7일 항해 시 TEU 1개당 추가 연료비"였고, 실제 답은 항해당 수백만
달러다. 그 턴은 모델이 결과 [1]을 직접 읽어 스스로 바로잡았지만 그건 운이다.
* isPriceQuery에 단어 경계가 없어 통화·가격 낱말이 다른 단어 **안에서** 걸렸다. "eur"가
"Europe"·"Neuralink"에, "cost"가 "costume"에, "value"가 "valuable"에 들어맞는다. 실사용
고유 검색어 195건 중 19건이 통과했고 그중 8건이 가격과 무관했다("Europe drought status
August 2026", "Neuralink Blindsight resolution pixels" 등). 경계를 넣어 19 → 11건.
* generic 자산 경로를 제거했다. 금·은·비트코인은 타당성 범위(온스당 300~10,000 등)와 자산명
대조가 성립해 판정이 의미가 있다. generic은 둘 다 없다 — 허용 범위가 $0.5~$5,000,000이라
사실상 모든 달러 표기를 받고, 문구도 무엇의 가격인지 말하지 못한 채 "The current price"라고만
쓴다. 이 로그 표본에서 generic 경로가 실제로 답을 낸 유일한 사례가 위 컨테이너선 건이다.
* 자산명 대조를 가점(+2)에서 **필수 조건**으로 올렸다. 가점일 때는 자산을 한 번도 언급하지
않은 스니펫의 숫자가 score 0으로 통과했다 — "금 시세" 질문에 다른 상품 가격이 답으로 나갈
수 있다는 뜻이다. 티커(XAU/XAG/BTC)도 함께 본다.
순효과(실사용 고유 검색어 195건): 발동 19 → 11건, 그중 실제로 "Answer:" 줄이 생길 수 있는
것 0건. 이 표본의 가격 질문은 전부 GPU·옵션·펌프 같은 generic 대상이라, 원래도 이 기능이
맞는 답을 낸 적이 없다.
테스트 544개 통과(+8).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
실사용 로그의 사용자 메시지 1,011건(고유 782건)을 모든 게이트에 통과시켜 오탐/미탐을
실측했다([[feedback_calibrate_on_real_data]]).
가장 큰 건: rankResults의 토큰화가 `q.replace(/[^a-z0-9\s]/g, ' ')`였다. 이 문자 클래스는
한글을 전부 지운다. **모든 한국어 쿼리**에서 관련성 토큰이 빈 배열이 되고 rel 점수가 항상
0이 되어, 결과가 오로지 domainTrustScore로만 정렬됐다 — 질문에 답하는 페이지인지와 무관하게.
시어엔진 라이브 실측("영리의료법인 비영리 병원 비율"): 수정 전에는 "Chee Lai"라는 사람의
LinkedIn 프로필 4건이 1~4위였고 유일한 관련 기사가 아래로 밀려 있었다. 수정 후 그 기사가
1위가 된다. rankResults는 7개 provider 전부가 쓰므로 영향 범위가 검색 전체다.
유니코드 인식 토큰화로 교체(한글·한자 2자 / 영문은 기존 4자 유지). 시점 표현도 영어만
있어서 한국어 "지금/현재/최신"은 배수를 받은 적이 없었다.
나머지:
* isCancelIntent 오탐 — "하지마 유적지는 바다와 떨어져 있는데?"에서 지명 하지마(波島)가
`하지\s*마`에 걸렸다. 참이면 진행 중이던 작업이 일시정지되고 지리 질문이 작업제어 응답으로
하이재킹된다(server.ts 두 호출부 모두). 취소는 문장 끝 명령이므로 종결 위치로 앵커를 걸었다.
영어 쪽은 표본에 사례가 없어 건드리지 않았다.
* isExecutionLikeRequest 오탐 — 맨 "패치"가 목록에 있어 "인슐린 패치는 혈당 측정 기능이
없나?", "패치는 많이 비싼가?"가 실행형으로 분류됐다. 이 함수는 검증 강제에서 면제시키는
쪽이라, 실존 의료기기의 기능·가격을 검색 없이 기억으로 답해도 아무도 막지 않았다.
"구속"을 체포 맥락으로 좁힌 것과 같은 처리 — 소프트웨어 패치 활용형일 때만 걸린다.
* isFactualInfoRequest 미탐 — "얼마" 계열 수량·가격 질문. 표본에서 이 표현으로 물었는데
어떤 검증 게이트에도 안 걸린 5건이 있었고 전부 정당한 사실 질문이었다(오탐 0):
"tesla v100 32gb 메모리 대역폭은 얼마지?", "미국 올 해 예산은 전 부 얼마지?"(실제로
기억에서 $7조 5,400억이 나갔다), "이자 비용은 연간 얼마지?" 등. 맨 "얼마나"는 비-사실
용법이 흔해 넣지 않았다.
* tool-scope climate 오탐 — "메모리 가격 추세"가 기후 재분석 아카이브 4종(~820토큰)을
통째로 실었다. "추세"/"장기"는 기후·기상 명사와 같이 나올 때만 걸리게 좁혔다.
* tool-scope kakao 오탐 — `` 첨부 파일명이 kakao_send_message를 실었다.
executeTool은 스키마 소속을 확인하지 않으므로([[project_tool_schema_leak]]) 이름만 언급돼도
호출될 수 있는 종류의 도구다. 첨부 파일명을 판정에서 빼고 카카오맵도 제외했다.
순효과 측정(고유 782건): 검색 강제 리마인더 주입률 23.8% → 25.4%(+13건). 새로 잡힌 13건은
전부 정당한 사실 질문이고, 잃은 건 0건이다.
감사에서 이상 없음으로 확인한 것: EMPTY-GROUNDING·MESSAGING(전제를 직접 검증),
decideAutoRecover·correctNewsCategoryForBreadth(도구 출력 대조 안 함), link-validator(URL 직접
프로브), satellite 게이트("도달 > 비용" 원칙상 유지), 0건 발동 게이트 6종(전용 앱 탭 경로).
테스트 536개 통과(+15).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 메인챗 세션(0c367bd7) 감사. 사용자가 "옵션 프리미엄이 실물의 몇 %냐"를
다섯 턴에 걸쳐 물었고, 검색 6회에 산출물 0건으로 끝났다. 로그를 보니 원인이 모델이
아니라 우리 가드였다:
TOOL: web_search("typical option premium as percentage of underlying asset price")
TOOL OK: [1] TYPICAL Definition & Meaning - Merriam-Webster
NUMERIC-GROUNDING POST-CHECK (1/1): 3 figure(s) not found in tool output [10%, 1%, 2%]
모델은 매 턴 답을 냈다(10%/1%/2%, 다음 턴엔 3~10%/0.1~1%). 검색엔진이 "typical"의
사전 뜻풀이를 물어왔고, 그 뭉치에 숫자가 없다는 이유로 답이 날조 판정을 받아 지워졌다.
이 가드의 전제는 "검색이 그 주제를 다뤘는데 숫자가 없다 → 지어냈다"인데, 앞부분을
한 번도 확인한 적이 없었다. 무관한 말뭉치에서의 부재는 날조의 증거가 아니다.
* 관련성 관문(sourceCoversQuery): 검색어의 내용어 중 결과에 등장하는 비율이 0.34
미만이면 판정을 포기한다. 실측 — 사전 뭉치 0.14 / 정상 결과 0.86. 수치·날짜·부재주장
세 갈래가 같은 전제 위에 있어 한꺼번에 게이트했다. 검색어를 못 넘기는 호출부는 기존
동작 유지.
* SEARCH-ABANDONMENT도 같은 결함이 있었다(감사 중 발견). countSubstantiveResults는
스니펫 길이만 봐서 사전 페이지 4건을 전부 "알맹이 있음"으로 세고, 모델에게 "그걸로
답하라"고 민다 — 답을 지우는 대신 틀린 답을 강요하는, 같은 전제의 반대 방향 피해다.
같은 관문을 공유시켜 기준이 갈라지지 않게 했다.
* 그 가드는 애초에 죽어 있었다. NOTHING_FOUND가 "정보가 없다"류만 담고 있어, gemma4가
실제로 쓰는 "결과에 …이 포함되어 있지 않다"(주어가 결과)를 하나도 못 잡았다. 이
세션의 포기 답변 6건 중 0건 탐지. 사과로 시작하면 판정 대상인 첫 문장이 "죄송합니다."가
되는 두 번째 구멍도 함께 수정(한글 뒤 \b 미매칭 함정 주의).
* dead-end-fallback (신규): 두 번째로 빈손이면 검색을 끊고 "아는 것을 검증 못 했다고
라벨 붙여 말하라"로 돌린다. 문제의 스레드에서 3턴 앞서 끊긴다. 재프롬프트가 숫자를
요구하지 않아 NUMERIC-GROUNDING과 충돌하지 않는다.
* 제품·모델 선택 질문이 isFactualInfoRequest를 통째로 빠져나갔다("모델이 있을까",
"고른다면", "신형/최신"). 그래서 소설-모델 3턴이 도구 0개로 굴러가 2026년 9월에
"Gemma 2 27B"를 로컬 추천으로 냈다. 주제어가 앞 턴에만 있는 짧은 후속 질문용
isFactualThreadFollowUp 추가.
* confirmsUserGuessWithoutGrounding: 직전 턴에 확인 못 했다고 해놓고 사용자 추측에
"네, 맞습니다"로 동조하는 경로 차단(맥미니 M6 건). 4중 조건으로 좁혀 정당한 맞장구는
건드리지 않는다.
* cms_hospital_compare 게이트가 "병원"에만 걸려 있어 사용자가 내내 쓴 "의료법인"으로는
8턴 동안 스코프에 들지 않았다. 그 사이 공공 비중 답이 45~50%→39~40%→약 50%로 흔들렸다.
테스트 521개 통과(+65). 실사용 로그의 실제 발화·답변을 표본으로 고정했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
앞 커밋(2537b04)에 이어진 후속. papa "눈향나무 사진" 재현 로그:
SKIP: duplicate search_images / AUTO-RECOVER (2/2) "live-data query answered
without a grounding tool call" / LOOP WARN: search_images x3
원인: 모델이 사진 대신 눈향나무 설명 프로즈를 냈는데 그 안의 "5cm·30cm·500년"이
looksLikeUnverifiedSpecClaim에 걸려 unverifiedSpecClaim→liveDataRequest가 참이 됨.
search_images는 GROUNDING_TOOL_PATTERN에 없어(citable text 아님) hasGroundingToolCall
false → 재프롬프트 → gemma가 search_images 반복 호출.
수정: decideAutoRecover 앞에 사진 요청 + search_images 호출 시 재시도 안 함 단락.
이미지 누락은 appendDroppedSearchImages가 복구하므로 재프롬프트가 고칠 게 없음.
대조군 테스트로 unverifiedSpecClaim 가드 자체는 유지 확인. 474 통과.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
papa 세션 실측(2026-09-03): "눈향나무 사진 찾아줘" → search_images가 
3개를 정상 반환했는데 gemma4가 "눈향나무 사진들입니다" + 설명만 내고 링크를 통째로
누락. 다음 턴 "사진이 어딧지?"에도 "죄송합니다..." 하며 또 0개.
- appendDroppedSearchImages(reply-content.ts): 최종 답변에 이미지 마크다운이
하나도 없고 이번 턴 search_images가 성공했으면, 도구 결과의 를 뒤에 붙임.
하나라도 있으면 모델이 시도한 것으로 보고 안 건드림
- personality-context photo 프롬프트: " 줄을 VERBATIM으로 복사, 프로즈로
대체 금지"로 강화 (코드가 backstop, 프롬프트는 보조 — [[feedback_local_model_needs_code_backstop]])
참고: 눈향나무 같은 특정 수종은 Pexels/Unsplash 커버리지가 나빠 "빨간 배경 분재"가
매칭되기도 함 — 그건 스톡 API 한계로 별개 이슈.
tests +5 (473 통과).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
DURIrdntInfoService03(성분 서비스)도 승인돼 있었다. 안 되던 건 함정 때문:
서비스명은 03인데 오퍼레이션 접미사는 02(getUsjntTabooInfoList02), 필터 파라미터는
병용금기만 ingrKorName·나머지는 ingrName, 응답은 items[].item로 한 겹 더 감쌈.
- ingredient 파라미터 추가 → 성분 서비스로 조회. 성분 단위라 결과가 깔끔하고
행 수가 1/10. 노인주의(getOdsnAtentInfoList02)는 성분 서비스에만 있음
- drug_name(제품명)은 기존대로 품목 서비스(*List03, itemName 부분일치) 유지
- 둘 중 하나 필수. 모델엔 "성분 알면 ingredient 권장"으로 안내
- 기본 checks에 노인주의 추가
실측: 심바스타틴 → 케토코나졸/마크로라이드/프로테아제억제제 병용금기 + 노인주의,
이소트레티노인 → 임부금기 1·2등급. 468개 테스트 통과.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
닥터앱은 기본 도구만 받아서 "이 약 장기복용 괜찮나?", "이 백신 효과 몇 년?",
"이 수치 정상 범위인가?" 류 질문에 web_search로 블로그를 물어오고 있었다.
academicToolNames(pubmed_search/pubmed_fetch/pubmed_fulltext/openalex_search/
semantic_search)를 dr_ 세션 안에서만 키워드 없이 개방 — lawyer 탭이 법령 도구를,
investor 탭이 news_search를 무조건 받는 것과 같은 앱-탭 패턴.
- dr_main / dr_case_<id> / dr_<rand> 전부 dr_ 접두사라 한 번에 잡힘
- 메인챗은 불변(키워드 게이트 그대로) — 테스트로 누출 없음 확인
- 앱 상단 면책("진단·처방 대체 아님")은 유지되므로 페르소나 불변
- 프롬프트 추가 비용 0 (hasGroundingTools는 web_search로 이미 항상 참)
tests +1, 467개 전부 통과.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
앱 탭은 세션 접두사로 무조건 게이팅하는 설계인데(탭 안 후속질문은 키워드가 없어도
그 앱의 맥락이므로), 접두사를 쓰면서 게이트가 없는 앱이 둘 있었다.
- detective-app.html → 'dt_' : 페이지에 판례 12회·법률 11회 언급인데 법령 도구가
하나도 안 실렸다. lawyer 탭과 같은 도구군이 필요 → 법령 3종 + news_search 개방.
- investor-app.html → 'iv_' : 환율 6회·주가·증시·엑셀·뉴스. "그럼 작년 대비?" 같은
후속질문에 읽을 게 아무것도 없었다 → news_search + excel 개방.
doctor('dr_')·language('lg_')는 의도적으로 제외 — 전자는 개인 의료기록 앱
(기록 검색/복약·검사 항목), 후자는 튜터라 기본 세트로 충분하고 전문 스키마는 순비용.
메인챗 회귀 없음(키워드 없으면 그대로 닫힘)을 테스트로 고정. tests 466 통과.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
사용자 확인: 스킬 on/off UI는 예전 것이고 지금은 앱별 게이팅을 쓴다. 그런데
skills_state.json이 tool-scope의 meteorologist/lawyer/presenter 게이트를 통해
여전히 도구를 **막는** 유일한 소비자로 남아 있었고, 아무도 갱신하지 않는 값이라
조용한 기능 상실을 만들고 있었다.
실측된 라이브 영향: papa는 meteorologist:false → "오늘 서울 미세먼지 어때?"에
weather_kma / weather_openmeteo 만 실렸다. weather_kma엔 대기질 데이터가 아예
없으므로(모델 프로필에도 명시돼 있음) 답은 실패 아니면 창작 둘 중 하나였다.
바로 옆에 있던 weather_airkorea(에어코리아, 키 승인·정상)가 죽은 플래그에 막혀 있었음.
- meteorologist/lawyer/presenter 스킬 조건 제거 → 키워드 게이트만 사용
- ToolScopeInput.isSkillEnabledForUser는 유지(앱세션 게이트용, 스킬이 실제
프롬프트 파일을 갖게 되면 다시 쓸 자리)
- 검증: papa·jasmine 모두 미세먼지/발표자료/판례 도구 정상 노출
tests 463 통과.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs