diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 09fdc2c..27cb137 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -34,6 +34,7 @@
| 2026-11-05 | v3.99 | **의존성 줄이기: onnxruntime을 뺐다(소유자 선택: 지금 모델을 MLX로).** 목소리 프로필의 CAM++ ONNX를 `onnx_mlx`가 그대로 실행한다: protobuf에서 그래프·가중치·메타데이터를 직접 읽고(필드 몇 개뿐이라 `onnx` 패키지 없이), 이 모델이 쓰는 26개 연산만 구현한다(모양 계산은 numpy, 텐서는 MLX, 모르는 연산은 실행 전 오류). 받는 파일·sha256·`MODEL_ID`는 그대로라 **등록한 목소리를 다시 등록하지 않는다**. onnxruntime 1.30과 비교: 무작위 입력 5가지 길이에서 cosine 1.0·최대 차이 약 1e-5, `bench/voice_eval.py`(AI Hub 4세션 40명 1427발화, 지표만) 결과가 RTF(0.0085 → 0.0082) 말고 모두 같다. 검사: `tests/test_onnx_mlx.py`(테스트 안에서 만든 작은 ONNX로 protobuf·연산을 numpy와 비교, slow는 실제 모델을 onnxruntime으로 미리 뽑아 둔 임베딩과 비교). 후보였던 mlx-community CAM++는 출력 512차원·가중치 6MB로 우리 모델(192차원·28MB)과 달라 쓰지 않았다. 번들 감소량은 다음 번들 빌드에서 잰다(개발 환경의 onnxruntime 폴더는 75MB). |
| 2026-11-06 | v3.100 | **⌘K 넓히기: 이 노트의 동작과 폴더·태그로 가기(브랜치 `feat/palette-note-actions`, 앱만).** 노트를 열었을 때 맨 앞 `이 노트` 묶음: 즐겨찾기(`org.setFavorite`)·태그 편집(`org.requestTagFocus`, 사이드바 줄 메뉴와 같은 통로)·형식별 내보내기·Obsidian. 내보내기는 `useNoteExport`가 MeetingView 안에 있어 App이 `exportReq {id, format, nonce}`를 내려 주고 MeetingView가 노트를 다 읽은 뒤 한 번 `exp.exportAs`/`toObsidian`을 부른다(노트 머리 메뉴와 같은 함수: Markdown·Word는 넣을 내용 고르기 창). 목록이 길어지지 않게 `PaletteAction.typed`(빈 입력에서는 숨기고 찾을 때만)를 두어 빈 입력에서는 Markdown·Word만 보인다. 입력이 있으면 `폴더·태그` 묶음: 고르면 사이드바 거르기를 그 폴더(태그) 하나로 바꾸고(`clearFilters` → `selectFolder`/`toggleTag`) 노트 목록을 펼친다. 폴더로 옮기기·인쇄는 고르는 창·쓰던 메모 저장이 끼어 넣지 않았다. WORDS §4.30. |
| 2026-11-06 | v3.101 | **일정과 노트 잇기(브랜치 `feat/event-note-link`).** v3.96의 이번 주 띠는 일정과 그 일정으로 녹음한 노트를 따로 보였다(노트에 일정 ID가 없었다). `event.json`에 선택 키 `id`(EventKit 일정 ID, 한 줄 256자 이하)를 더했다: 앱 `calendar::clean_event`와 코어 `calendar_event.clean_event`가 둘 다 남긴다(코어가 녹음 폴더 → 노트 폴더로 옮길 때 다시 검사하므로). 노트 목록 `MeetingInfo.event`에 `{id?, title, start}`만 싣는다(`calendar::note_event`, 목록 캐시 안: `event.json`은 두 저장 경로 모두 transcript.json보다 먼저 쓰인다). 화면 `week.linked`: 같은 순간에 시작하고 ID가 같으면(반복 일정은 회차마다 ID가 같아 시각으로 가른다), ID가 없는 옛 노트는 제목이 같으면 잇는다. `groupWeek`가 이어진 노트를 일정(`DayEvent.notes`) 아래로 옮기고 그날 노트에서는 뺀다(`dayNotes`는 둘 다 센다). 미리보기는 세 번째 노트를 화요일 스탠드업에 이었다. 실제 EventKit ID로 녹음해 잇는 것은 이 터미널에 캘린더 권한이 없어 확인하지 않았다(릴리스 번들 확인 때 함께). |
+| 2026-11-06 | v3.102 | **⌘K에 폴더로 옮기기·PDF(브랜치 `feat/palette-move-print`, 앱만).** v3.100에서 뺀 둘을 더했다. 폴더 고르는 창을 띄우는 대신 폴더마다 `폴더로 이동: {경로}` 한 줄과 `폴더에서 빼기`(`org.moveNote`, 사이드바 줄 메뉴와 같다, 둘 다 `typed`라 찾을 때만). PDF는 `exportReq`의 `format: "print"`: MeetingView가 노트 머리 PDF와 똑같이 `flushMemos` 뒤 `setPrintNonce`. |
| 2026-10-30 | v3.78 | **한국어 파인튜닝 엔진을 8bit 변환본으로 교체(브랜치 `feat/univa-8bit`).** 0.5.2에 올린 `asr-ko-univa`가 원본 bf16(4.7GB)이었다(변환·업로드 위치를 정하지 않고 허브 원본을 그대로 등록했다). `mlx_audio.convert -q --q-bits 8 --q-group-size 64 --q-mode affine`로 변환해(표준 엔진과 같은 설정, `model.safetensors` 2,463,307,541바이트로 공식 Qwen3-ASR 1.7B 8bit와 같다) 소유자 계정에 `voyagar/Qwen3-ASR-1.7B-ko-mlx-8bit`(공개, Apache-2.0, 커밋 `8fcc32c4`, 모델 카드에 원 제작자·원본 revision·변환 명령)로 올리고 등록 목록을 바꿨다. ID `asr-ko-univa`·`level_note`는 그대로라 사용자가 고른 엔진 설정은 유지되고, 새 폴더(`qwen3-asr-1.7b-ko-8bit`)를 받을 때까지는 받아 둔 다른 엔진으로 변환한다. 0.5.2로 bf16을 받은 폴더(`qwen3-asr-1.7b-ko-bf16`)는 등록 밖이 되어 `models prune`으로 지운다. 간이 점검: 원본과 출력 차이 CER 2.5, 처리 시간 약 30% 단축(docs/design/asr-engines.md). |
| 2026-10-30 | v3.77 | **0.5.2 릴리스 준비(브랜치 `fix/mic-config-change-crash`).** 버전 0.5.2를 다섯 매니페스트와 잠금 파일에 맞췄다. 0.5.1 이후: 한국어 파인튜닝 엔진 `asr-ko-univa` 등록과 모델 화면 변환 엔진 라디오 목록(v3.75), 장치 전환 중 녹음 프로그램 크래시 수정(v3.76, 이슈 #57). 릴리스 노트 `docs/releases/v0.5.2.md`, 로드맵 항목. |
| 2026-10-30 | v3.76 | **녹음 중 입력 장치가 바뀌면 `tractus-rec`가 죽던 것(이슈 #57, 브랜치 `fix/mic-config-change-crash`).** `AVAudioEngineConfigurationChange` → `Recorder.restart` → `MicSource.open`의 `installTap`이 Objective-C 예외(NSException)를 던져 Swift `do/catch`를 지나 프로세스가 abort됐다(66분 녹음 중 크래시, `recording.json`의 `duration`이 null로 남음). ① `RecorderKit.micFormatProblem`: tap 전에 채널·표본 속도 0과 하드웨어 포맷 불일치를 오류로 던진다(`restart`의 경고와 watchdog 2초 재시도로 넘어감). 음성 처리(에코 제거) 중에는 두 포맷이 다를 수 있어 표본 속도 일치는 보지 않는다. ② `MicSource.stop()`마다 새 `AVAudioEngine`으로 바꾸고 관찰자를 다시 단다(장치가 바뀐 뒤 같은 엔진의 입력 노드가 낡은 포맷을 쥐는 것을 피함). ③ 새 SwiftPM 타깃 `ObjCCatch`(Objective-C, `TractusObjC.catching { }`)로 `installTap`을 감싸 NSException을 Swift 오류로 바꾼다(`recorder/`의 tools-version 5.10·플랫폼은 그대로). 이 Mac의 실제 마이크로 일반·에코 제거 녹음이 정상임은 확인했고, **실제 장치 전환(블루투스 연결·해제)은 재현하지 못했다.** 비정상 종료로 `duration`이 null인 녹음은 앱의 `변환 대기 중인 녹음`에 보이고 코어가 그대로 변환 전 노트로 저장한다는 것을 테스트로 고정했다. |
diff --git a/WORDS.md b/WORDS.md
index f963aeb..2b15a74 100644
--- a/WORDS.md
+++ b/WORDS.md
@@ -881,7 +881,7 @@ LLM 프롬프트(요약·AI 질문)도 이름 없는 참석자를 `참석자 N`
| 창 이름 (`aria-label`) | `빠른 실행` |
| 입력 칸 | placeholder·`aria-label` `노트·동작 찾기` |
| 묶음 제목 | 노트를 열었으면 맨 앞에 `이 노트`. 빈 입력 `최근 노트`(최근에 고친 5개) · `동작`, 입력이 있으면 `노트`(제목 일치, 사이드바 검색과 같은 규칙) · `동작`(이름에 낱말이 모두 든 것) · `폴더·태그` · `찾기` |
-| 이 노트 (2026-11-06) | `즐겨찾기에 추가`/`즐겨찾기에서 빼기` · `태그 편집…`(사이드바 줄 메뉴와 같은 말, 노트 머리의 태그 칸에 초점) · `내보내기: {format}`(형식 이름은 내보내기 메뉴와 같다. 빈 입력에서는 Markdown·Word만, 나머지는 `내보내기`나 확장자로 찾을 때) · `Obsidian으로 보내기`. 노트 머리의 메뉴와 같은 함수다 |
+| 이 노트 (2026-11-06) | `즐겨찾기에 추가`/`즐겨찾기에서 빼기` · `태그 편집…`(사이드바 줄 메뉴와 같은 말, 노트 머리의 태그 칸에 초점) · `내보내기: {format}`(형식 이름은 내보내기 메뉴와 같다. 빈 입력에서는 Markdown·Word만, 나머지는 `내보내기`나 확장자로 찾을 때. PDF는 `내보내기: PDF`, 찾기 말 `인쇄`) · `폴더로 이동: {folder}`(폴더 경로, 지금 폴더는 빼고)·`폴더에서 빼기`(폴더에 있을 때. 둘 다 찾을 때만) · `Obsidian으로 보내기`. 노트 머리의 메뉴·사이드바 줄 메뉴와 같은 함수다 |
| 폴더·태그 (2026-11-06) | 폴더는 경로(`상위/하위`), 태그는 `#태그`. 고르면 사이드바 거르기가 그 폴더(태그) 하나로 바뀌고 노트 목록이 접혀 있으면 펼친다 |
| 동작 | `녹음 시작` · `글쓰기`(⌘N) · `파일 가져오기` · `AI 어시스턴트`(⌘J) · `홈` · `사전` · `설정` · `모델` · `시작 점검` · `노트 목록 접기`/`노트 목록 펼치기`(⌘B). 새 노트 메뉴와 같은 이유로 막힌 동작은 흐리게 하고 오른쪽에 이유(`이미 녹음 중입니다` 등)를 보이며 고를 수 없다. 모델이 없는 것 같은 기능 잠금은 실행하면 그 단추와 똑같이 알린다 |
| 내용 검색 | `내용에서 ‘{q}’ 찾기` (스마트 검색 화면으로 간다) |
diff --git a/app/src/App.tsx b/app/src/App.tsx
index f798834..effabb7 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -665,6 +665,10 @@ export function App() {
{ id: "favorite", label: openNote.favorite ? t("즐겨찾기에서 빼기") : t("즐겨찾기에 추가"), icon: , run: () => void org.setFavorite(openNote.id, !openNote.favorite) },
{ id: "tags", label: t("태그 편집…"), icon: , run: () => org.requestTagFocus(openNote.id) },
...exportFormatsFor(isMemo(openNote), isRecorded(openNote)).map((f) => ({ id: `export-${f.format}`, label: t("내보내기: {format}", { format: f.label }), keywords: f.ext, icon: , typed: !COMMON_EXPORTS.includes(f.format), run: () => setExportReq({ id: openNote.id, format: f.format, nonce: Date.now() }) })),
+ { id: "print", label: t("내보내기: {format}", { format: "PDF" }), keywords: t("인쇄"), icon: , typed: true, run: () => setExportReq({ id: openNote.id, format: "print", nonce: Date.now() }) },
+ // 폴더로 옮기기: 사이드바 줄 메뉴의 고르는 창 대신 폴더마다 한 줄 (찾을 때만)
+ ...folders.filter((f) => f.id !== openNote.folder).map((f) => ({ id: `move-${f.id}`, label: t("폴더로 이동: {folder}", { folder: folderPath(folders, f.id) }), icon: , typed: true, run: () => void org.moveNote(openNote.id, f.id) })),
+ ...(openNote.folder ? [{ id: "move-out", label: t("폴더에서 빼기"), keywords: t("폴더"), icon: , typed: true, run: () => void org.moveNote(openNote.id, null) }] : []),
{ id: "obsidian", label: t("Obsidian으로 보내기"), keywords: t("내보내기"), icon: , run: () => setExportReq({ id: openNote.id, format: "obsidian", nonce: Date.now() }) },
]
: [];
diff --git a/app/src/MeetingView.tsx b/app/src/MeetingView.tsx
index 16aec74..c4fc6ca 100644
--- a/app/src/MeetingView.tsx
+++ b/app/src/MeetingView.tsx
@@ -38,7 +38,7 @@ import { LOCK_NOTICE, type AttachControls } from "./attach";
import { ConvertPanel } from "./ConvertPanel";
import { useConvertJob } from "./convertJobs";
import { CONVERT_FIRST, deleteBlockedWhileConverting, isRecorded, keptNotice, recordedStats, type ConvertControls } from "./recorded";
-import { exportFormatsFor, isMemo, memoLineNumbers, memoLineSegments, segmentSpans, type SaveState } from "./memo";
+import { exportFormatsFor, flushMemos, isMemo, memoLineNumbers, memoLineSegments, segmentSpans, type SaveState } from "./memo";
import { Button } from "@/components/ui/button";
import { cn } from "@/components/ui/utils";
import { PolishBar } from "./PolishControls";
@@ -59,7 +59,7 @@ import { SegmentRow } from "@/components/note/SegmentRow";
import { MemoRow } from "@/components/note/MemoRow";
import { livePolish, nextSuggestion, pendingIds, suggestionMarks } from "./polish";
-/** 노트 머리의 내보내기 메뉴와 같은 일을 밖에서 부탁한다: format은 EXPORT_FORMATS의 형식 또는 "obsidian" */
+/** 노트 머리의 내보내기 메뉴와 같은 일을 밖에서 부탁한다: format은 EXPORT_FORMATS의 형식, "print"(PDF) 또는 "obsidian" */
export type ExportReq = { id: string; format: string; nonce: number };
export function MeetingView({
@@ -195,6 +195,7 @@ export function MeetingView({
if (!exportReq || !detail || exportReq.nonce === doneReq.current) return; // 노트를 다 읽은 뒤에 한 번만
doneReq.current = exportReq.nonce;
if (exportReq.format === "obsidian") return void exp.toObsidian();
+ if (exportReq.format === "print") return void flushMemos().then((ok) => ok && exp.setPrintNonce((n) => n + 1)); // 노트 머리의 PDF와 같다: 쓰던 글까지
const f = exportFormatsFor(memoNote, isRecorded(detail.info)).find((x) => x.format === exportReq.format);
if (f) void exp.exportAs(f);
}, [exportReq, detail]); // exp는 렌더마다 새로 만들어 넣지 않는다
diff --git a/app/src/en/palette.ts b/app/src/en/palette.ts
index 4a45741..62d8973 100644
--- a/app/src/en/palette.ts
+++ b/app/src/en/palette.ts
@@ -11,4 +11,7 @@ export default {
"이 노트": "This note",
"폴더·태그": "Folders & tags",
"내보내기: {format}": "Export: {format}",
+ "폴더로 이동: {folder}": "Move to folder: {folder}",
+ "폴더에서 빼기": "Remove from folder",
+ 인쇄: "print",
} satisfies Dict;