rootr-cli 0.22.0 → 0.24.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/README.ko.md CHANGED
@@ -192,6 +192,7 @@ rootr rm /notes/todo.md --yes # 확인 없이 (스크립트용)
192
192
  ```bash
193
193
  rootr ws # 목록 (현재 워크스페이스에 *, 못 여는 곳은 표시)
194
194
  rootr use "영업팀" # 전환 — id · 이름 · rootr.io 주소 아무거나
195
+ rootr emblem ./logo.png # 워크스페이스 엠블럼(사이드바 로고, 관리자) — --clear 면 지움
195
196
  ```
196
197
 
197
198
  `rootr ws` 출력은 `id\tname` 목록입니다. 등록된 키가 워크스페이스 전용 키뿐이면 목록에는
@@ -304,6 +305,22 @@ rootr sign ledger # 워크스페이스 원장 전체 재계산
304
305
  원본은 `SUPERSEDED`로 남습니다 — 지침이 "고친 흔적과 원본이 모두 보여야 한다"를
305
306
  요구하기 때문입니다.
306
307
 
308
+ ### 문서 보내기 · 문서함 (send · mail)
309
+
310
+ 폴더만 빼고 어떤 노드든 이메일 1~20명에게 **보낼 때의 복사본**으로 보낼 수 있습니다. 담기는 내용은
311
+ 공개 페이지와 같아서 사람·첨부·관계 칸은 나가지 않습니다. 받는 사람은 메일 링크로 **루터 계정 없이**
312
+ 열고, 그 이메일에 루터 계정이 있거나 나중에 가입하면 받은 문서함에도 그대로 있습니다. 보낸 뒤 원본을
313
+ 고치거나 지워도 받은 문서는 그대로입니다. 보내려면 그 노드에 쓰기(WRITE) 권한이 있어야 합니다(공유와 같은 문턱).
314
+
315
+ ```bash
316
+ rootr send "/보고서/3분기 실적" --to partner@example.com,cfo@example.com --message "말씀드린 자료입니다"
317
+ rootr mail # 받은 문서 (안 읽은 수 함께)
318
+ rootr mail sent # 보낸 문서 — 읽었는지, 메일 실패
319
+ rootr mail show <id> # 열기 (문서는 마크다운 본문, 그 밖은 --json 으로 전부)
320
+ rootr mail revoke <id> # 회수 — 받는 쪽에서 본문이 안 보이고 링크는 410
321
+ rootr mail hide <id> # 내 문서함에서만 지우기
322
+ ```
323
+
307
324
  ### 제보 보내기 (feedback)
308
325
 
309
326
  ```bash
@@ -351,7 +368,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
351
368
 
352
369
  ## MCP 서버로 사용하기
353
370
 
354
- `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 186종:
371
+ `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 197종:
355
372
 
356
373
  노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
357
374
  `recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
@@ -401,6 +418,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
401
418
  | `rootr_create_workspace` | **계정 키(PAT) + workspaces:create** | 짓기 3단계 — 계획된 트리로 새 워크스페이스 생성(검사 결과도 같이 돌려준다) |
402
419
  | `rootr_scaffold_apply` | docs:write | 짓기 3단계 — 기존 워크스페이스에 트리 적용(검사 결과도 같이 돌려준다) |
403
420
  | `rootr_delete_workspace` | **계정 키(PAT) + OWNER** | 짓다 실패해 남은 **빈 워크스페이스 치우기**. 이름을 그대로 다시 적어야 지워진다(되돌릴 수 없음) |
421
+ | `rootr_set_workspace_emblem` | docs:write + **ADMIN** | 워크스페이스 **엠블럼**(사이드바 배지·전환기 로고) 바꾸기 — 이미지 파일·이미 올린 첨부 id·`clear` 중 하나 |
404
422
 
405
423
  스캐폴딩은 **plan → 물어보기 → 설계 → review → 고치기 → apply** 한 바퀴입니다.
406
424
  `review` 를 건너뛰면 "얇은 마크다운 파일 묶음"이 됩니다. CLI 로도 같은 세 단계를 쓸 수 있습니다:
@@ -653,6 +671,11 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
653
671
  | `rootr_set_booking` | 예약 켜기/끄기와 규칙(요일·시간·길이·여유·며칠 전·하루 최대·쉬는 날·**막는 시간**·확정 방식·물어볼 칸·**결과 메일**·**구글 밋**·**방문자 직접 취소**·**방문자 직접 시간 변경**·**미리 알림**) |
654
672
  | `rootr_booking_slots` | 방문자에게 실제로 보이는 빈 시간(규칙이 뜻대로 걸렸는지 확인) |
655
673
 
674
+ 여유 시간은 둘입니다. `bufferMin`은 이 표로 잡힌 **예약** 앞뒤(CLI `--buffer`), `calendarBufferMin`은
675
+ 연결한 구글 캘린더에 **이미 있던 일정** 앞뒤(CLI `--calendar-buffer 60`) — 밖에서 하는 미팅의 이동 시간
676
+ 같은 것입니다. 예: 구글 쪽 60분·예약 0분이면 구글에 있는 14~15시 미팅 때문에 13~16시가 닫히고, 온라인
677
+ 상담 예약끼리는 붙여 잡힙니다. 이 표가 구글에 적은 예약은 "이미 있던 일정"으로 세지 않습니다.
678
+
656
679
  방문자 화면은 **방문자 자신의 시간대**로 빈 시간을 보여주고(고칠 수도 있습니다), 서버와
657
680
  주고받는 값은 끝까지 주인 시간대입니다. 확정·이동 메일에는 **일정 초대장(.ics)** 이 붙어
658
681
  방문자 캘린더에 바로 들어가고, 시간을 옮기면 그 일정이 고쳐지고 취소하면 사라집니다.
@@ -792,6 +815,18 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
792
815
  주세요 — 그 표는 행을 열 때마다 서명문서(연구노트)가 만들어집니다(실험 기록 대장,
793
816
  점검 대장).
794
817
 
818
+ #### 문서 보내기 · 문서함 (docs:read/docs:write)
819
+
820
+ | 도구 | 설명 |
821
+ |---|---|
822
+ | `rootr_send_document` | 노드 하나를 이메일 1~20명에게 보낼 때의 복사본으로 보내기 (계정 없어도 메일 링크로 열림) |
823
+ | `rootr_list_doc_mail` | 내 문서함 — `box`: inbox(받은, 기본) / sent(보낸), 안 읽은 수 함께 |
824
+ | `rootr_read_doc_mail` | 한 통 열기 — 본문(`payload`), 받은 것은 읽음 처리, 회수된 것은 본문 없음 |
825
+ | `rootr_revoke_doc_mail` | 보낸 문서 회수 (보낸 사람만) |
826
+
827
+ 문서함은 워크스페이스가 아니라 **사람(이메일)**에 붙습니다. 사람 없는 워크스페이스 키(원격 커넥터)는
828
+ 그 키를 만든 구성원으로 동작합니다.
829
+
795
830
  #### 문서 버전 / 삭제
796
831
 
797
832
  | 도구 | 설명 |
@@ -822,6 +857,13 @@ HTML을 짜는 것이 아니라 **정해진 블록 15종을 골라 쌓습니다*
822
857
  화면 한 장이 되고(숫자 `height`를 같이 주면 그 값이 하한), `bleed: true`와 함께 쓰면 화면을 꽉 채우는
823
858
  섹션이 됩니다. 맨 위 이름 줄은 `config.navEnabled: false`로 끕니다.
824
859
 
860
+ **메타(페이스북·인스타그램) 광고 성과 재기** — `config.metaPixelId`에 픽셀 ID(숫자)를 넣으면 발행한
861
+ 페이지가 방문(`PageView`)과 `booking` 블록의 예약 완료(`Schedule`)를 그 픽셀로 보냅니다. 편집 화면에서는
862
+ 싣지 않고, `""`를 보내면 꺼집니다. `config`는 고칠 때 통째로 바뀌니 다른 값도 같이 보내세요. 어느 광고에서
863
+ 온 예약인지 표에서 바로 보려면 예약 표에 유입 경로 기록을 켭니다(`rootr booking set <표> --track-source on`,
864
+ MCP는 `rootr_set_booking`의 `trackSource: true`). "유입 경로" 칸에 방문자가 들어온 주소의 꼬리표가
865
+ `source=meta, medium=paid, campaign=ax, content=3-docs`처럼 적힙니다.
866
+
825
867
  **스크롤 등장 효과** — 어느 블록이나 `reveal: "fade"`(서서히) 또는 `"fade-up"`(아래에서 위로)을 주면
826
868
  (기본 `"none"`), 발행한 페이지에서 그 블록이 화면에 들어올 때 한 번 나타납니다(큰 제목은 제목 → 부제 →
827
869
  버튼 순서로). `custom`에 `reveal`을 주면 **틀 안의 CSS 애니메이션도 그 순간에 시작**합니다 — 틀을 블록이
package/README.md CHANGED
@@ -11,7 +11,7 @@ citations back to the exact paragraph.
11
11
  *(한국어 문서: [README.ko.md](https://github.com/gwmage/rootr-cli/blob/main/README.ko.md))*
12
12
 
13
13
  - `rootr` CLI — documents, databases, forms, signed documents, publishing, permissions, memory
14
- - `rootr mcp` — stdio MCP server, **192 tools** (see [MCP tools](#mcp-tools))
14
+ - `rootr mcp` — stdio MCP server, **197 tools** (see [MCP tools](#mcp-tools))
15
15
 
16
16
  Plain Node.js ESM, no build step. Node 18+ (uses the built-in `fetch`).
17
17
 
@@ -180,6 +180,7 @@ as-is. `write --if-match` returns 412 if the document changed in the meantime.
180
180
  ```bash
181
181
  rootr ws # id<TAB>name
182
182
  rootr use "Sales" # switch — id, name, or rootr.io URL
183
+ rootr emblem ./logo.png # workspace emblem (sidebar logo, admin) — --clear removes it
183
184
  rootr ask "why did latency go up after last week's deploy?"
184
185
  rootr ask "why did payment failures rise?" --workspace ws_123
185
186
  ```
@@ -326,6 +327,23 @@ A signed document is never edited in place. You issue a **revision**
326
327
  (`rootr sign revise <id> --note "…"`) and the original stays as `SUPERSEDED`, because the
327
328
  regulations require both the original and the trace of the change to remain visible.
328
329
 
330
+ ### Sending a document to someone (mailbox)
331
+
332
+ Any node except a folder can be emailed to 1–20 addresses as a **copy taken at send time** — the
333
+ same content a public page shows, so person / attachment / relation columns never go out. The
334
+ recipient opens it from the email link **without a Rootr account**; if that address has (or later
335
+ gets) a Rootr account, it is also in their inbox. Editing or deleting the original afterwards does
336
+ not change what they received. Sending needs WRITE on the node, the same bar as sharing.
337
+
338
+ ```bash
339
+ rootr send "/reports/Q3 numbers" --to partner@example.com,cfo@example.com --message "As discussed"
340
+ rootr mail # received (with the unread count)
341
+ rootr mail sent # what you sent — read or not, email failures
342
+ rootr mail show <id> # open it (a document prints its markdown; --json for any type)
343
+ rootr mail revoke <id> # withdraw: the recipient no longer sees the content, the link answers 410
344
+ rootr mail hide <id> # remove from your own mailbox only
345
+ ```
346
+
329
347
  ### Team memory
330
348
 
331
349
  ```bash
@@ -407,7 +425,7 @@ is refused and says when the grace was used.
407
425
 
408
426
  ## MCP tools
409
427
 
410
- `rootr mcp` speaks MCP over stdio. 192 tools:
428
+ `rootr mcp` speaks MCP over stdio. 197 tools:
411
429
 
412
430
  | Group | Count | Tools |
413
431
  |---|---|---|
@@ -415,9 +433,10 @@ is refused and says when the grace was used.
415
433
  | Databases | 32 | create, columns, views, rows (single & bulk), row pages, permissions, notifications, buttons, duplicates (find / merge / keep & link), history & point-in-time restore (row / whole table) |
416
434
  | CRM | 18 | companies, contacts, deals (incl. pipeline moves), activities, tasks, CSV import |
417
435
  | Signed documents & research notes | 14 | create, sign, revise, reject, reopen, approval lines, signing codes, ledger verification, research-note preset |
436
+ | Sending documents (mailbox) | 4 | send any node as a frozen copy to email addresses (no account needed to open), list received / sent, read, withdraw |
418
437
  | Forms | 10 | create, read, update, responses, share links, collectors |
419
438
  | Presentations & images | 10 | decks, slides, diagram slides, image generation and background removal |
420
- | Workspaces & scaffolding | 9 | list, create, delete, switch, plan / review / apply a document tree, health check |
439
+ | Workspaces & scaffolding | 10 | list, create, delete, switch, set the emblem (sidebar logo), plan / review / apply a document tree, health check |
421
440
  | Pages | 6 | HTML page nodes: create, read, update, append and patch blocks, live data |
422
441
  | Spreadsheets | 7 | create, read, update, per-sheet create/update/delete, patch cells & formulas |
423
442
  | Recordings | 7 | create, upload & download audio, transcribe, summarize, rename speakers |
@@ -433,6 +452,10 @@ is refused and says when the grace was used.
433
452
  | Root-cause Q&A | 1 | ask the workspace knowledge graph, answer with citations |
434
453
  | Feedback | 1 | send feedback to the Rootr team |
435
454
 
455
+ Two gaps keep breathing room: `bufferMin` around bookings made through the table (CLI `--buffer`) and
456
+ `calendarBufferMin` around events already on the connected Google Calendar, e.g. travel time to an outside
457
+ meeting (CLI `--calendar-buffer 60`). Bookings the table wrote to Google count as bookings, not existing events.
458
+
436
459
  Open hours can be narrowed by blocked times — one afternoon, lunch every day, Monday mornings, or a whole
437
460
  weekday — with `blockedTimes` on `rootr_set_booking` (CLI: `--block-time "daily 12:00-13:00, mon 09:00-11:00, fri"`).
438
461
 
@@ -478,6 +501,14 @@ viewport tall on every device (a numeric `height` given with it is the floor), s
478
501
  `bleed: true` + `minHeight: "screen"` is a section that fills the screen. Pair them with
479
502
  `config.navEnabled: false`.
480
503
 
504
+ To **measure Meta (Facebook/Instagram) ads**, set `config.metaPixelId` to the numeric Pixel ID: the
505
+ published page then reports `PageView` on load and `Schedule` when a visitor completes a booking in a
506
+ `booking` block. The editor never loads the pixel; send `""` to turn it off. `config` is replaced as a
507
+ whole on update, so resend the other keys with it. To see which ad each booking came from in the table
508
+ itself, turn on source tracking on the booking table (`rootr booking set <table> --track-source on`,
509
+ or `trackSource: true` in `rootr_set_booking`): a "유입 경로" column is filled from the visitor's
510
+ address, e.g. `source=meta, medium=paid, campaign=ax, content=3-docs`.
511
+
481
512
  For **scroll-in animation**, any block takes `reveal: "fade"` or `"fade-up"` (default `"none"`): on
482
513
  the published page it appears once, when it scrolls into view (a hero staggers title → subtitle →
483
514
  button). On a `custom` block `reveal` also **starts the CSS animations inside the frame at that
@@ -536,7 +567,10 @@ Decks are authored with `rootr_create_presentation`, `rootr_read_presentation`,
536
567
  `rootr_append_presentation_slides`, `rootr_update_presentation_slide`,
537
568
  `rootr_reorder_presentation_slides` and `rootr_update_presentation`. Artwork can be produced with
538
569
  `rootr_generate_image` (image from a sentence) and `rootr_remove_image_background` — both spend
539
- AI credits. The same thing works over plain REST with the same API key (`x-api-key`,
570
+ AI credits. Slides may also carry `videos[]` (uploaded mp4/webm ≤75MB, https video files, YouTube/Vimeo links;
571
+ Team plan and above), and any element in slide `html` can animate with `data-anim="fade-up"` (also fade,
572
+ fade-down, fly-left, fly-right, zoom, pop, blur, and looping pulse/float/spin/blink/shake; timing via
573
+ `--anim-dur`/`--anim-delay` inline CSS variables; all plans). The same thing works over plain REST with the same API key (`x-api-key`,
540
574
  `docs:write`):
541
575
 
542
576
  ```bash
package/bin/rootr.js CHANGED
@@ -28,6 +28,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
28
28
  rootr restore <id> 지운 것 되돌리기 (화면의 Ctrl+Z 와 같은 일 — 경로 아닌 id)
29
29
  rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] 파일 업로드(+문서에 삽입)
30
30
  rootr ws 내 워크스페이스 목록 (id\tname, 열 수 없는 곳은 표시)
31
+ rootr emblem <이미지 파일> | --clear 워크스페이스 엠블럼(사이드바 로고) 바꾸기·지우기 (관리자)
31
32
  rootr use <id|이름|주소> 활성 워크스페이스 전환 (그 워크스페이스용 키도 기억)
32
33
  rootr ask <question> [--workspace <w>] GraphRAG 질의응답 (RCA), 인용 포함 출력
33
34
  rootr db create [--file <spec.json>] [--parent <path|id>] 표(DATABASE) 생성 — 컬럼 순서 그대로, 행까지 한 번에
@@ -94,6 +95,11 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
94
95
  rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] 서명문서 목록 (--pending: 내 차례)
95
96
  rootr sign bulk <id…> --code <6자리> 여러 건 한 번에 올리기+서명 (코드 하나로, 초안 포함)
96
97
  rootr sign ledger [--json] 워크스페이스 원장(해시 체인) 전체 검증
98
+ rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."] 문서(어떤 타입이든)를 이메일로 보내기 — 보낼 때의 복사본, 계정 없어도 열림
99
+ rootr mail [inbox|sent] [--limit N] [--q <찾기>] [--json] 문서함 — 받은 문서(기본)·보낸 문서
100
+ rootr mail show <id> [--json] 받은/보낸 문서 열기(문서면 본문, 그 밖은 요약) — 받은 것은 읽음 처리
101
+ rootr mail revoke <id> 보낸 문서 회수 — 받는 쪽에서 본문이 안 보임
102
+ rootr mail hide <id> [--box inbox|sent] 내 문서함에서만 지우기(상대 화면은 그대로)
97
103
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
98
104
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
99
105
  rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
@@ -107,7 +113,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
107
113
  rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
108
114
  rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
109
115
  rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
110
- rootr mcp MCP stdio 서버 구동 (도구 178종 — README 참고)
116
+ rootr mcp MCP stdio 서버 구동 (도구 197종 — README 참고)
111
117
  rootr --help 도움말
112
118
  rootr --version 버전 출력
113
119
 
@@ -134,6 +140,7 @@ usage:
134
140
  rootr restore <id> undo a delete (same as Ctrl+Z in the app — id, not path)
135
141
  rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] upload a file (+ insert it into a document)
136
142
  rootr ws my workspaces (id\tname; unreachable ones are marked)
143
+ rootr emblem <image file> | --clear set or clear the workspace emblem (sidebar logo; admin)
137
144
  rootr use <id|name|url> switch the active workspace (remembers its key too)
138
145
  rootr ask <question> [--workspace <w>] GraphRAG question answering (RCA), prints citations
139
146
  rootr db create [--file <spec.json>] [--parent <path|id>] create a DATABASE — columns in order, rows included
@@ -199,6 +206,11 @@ usage:
199
206
  rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] list signed documents (--pending: waiting on you)
200
207
  rootr sign bulk <id…> --code <6 digits> submit+sign many at once (one code, drafts included)
201
208
  rootr sign ledger [--json] verify the whole workspace ledger (hash chain)
209
+ rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."] email any node as a frozen copy — opens without an account
210
+ rootr mail [inbox|sent] [--limit N] [--q <text>] [--json] your document mailbox — received (default) or sent
211
+ rootr mail show <id> [--json] open a received/sent document (body for docs, a summary otherwise); marks received ones read
212
+ rootr mail revoke <id> withdraw a document you sent — the recipient no longer sees the content
213
+ rootr mail hide <id> [--box inbox|sent] remove from your own mailbox only (the other side keeps it)
202
214
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
203
215
  rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
204
216
  rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
@@ -212,7 +224,7 @@ usage:
212
224
  rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
213
225
  rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
214
226
  rootr memory forget <id> [--hard] retire a memory (--hard deletes the row)
215
- rootr mcp run the MCP stdio server (178 tools — see the README)
227
+ rootr mcp run the MCP stdio server (197 tools — see the README)
216
228
  rootr --help this help
217
229
  rootr --version print the version
218
230
 
@@ -597,6 +609,30 @@ async function cmdAttach(args) {
597
609
  console.log(markdown);
598
610
  }
599
611
 
612
+ /** WS-EMBLEM — 설정 → 일반 → 엠블럼과 같은 일. 이미지를 올려 걸거나(--clear 면) 이름 첫 글자 배지로 되돌린다. */
613
+ async function cmdEmblem(args) {
614
+ const filePath = args._[0];
615
+ if (!filePath && !args.clear) throw new Error(usage(`rootr emblem <image file> | --clear`));
616
+ const cfg = loadConfig();
617
+ const workspace = args.workspace || cfg.workspace;
618
+ if (!workspace) {
619
+ throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
620
+ hint: tr('--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.', `Pass --workspace <id>, or set it with rootr config --workspace <id> or the ROOTR_WORKSPACE environment variable.`),
621
+ });
622
+ }
623
+ const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
624
+ let id = null;
625
+ if (filePath) {
626
+ const mimeType = args.mime || MIME_BY_EXT[extname(filePath).toLowerCase()];
627
+ if (!mimeType || !mimeType.startsWith('image/')) {
628
+ throw new RootrApiError(tr('엠블럼은 이미지 파일만 됩니다 (png·jpg·webp·svg·gif).', 'The emblem must be an image file (png, jpg, webp, svg, gif).'));
629
+ }
630
+ ({ id } = await client.uploadAttachment(workspace, { filename: basename(filePath), mimeType, data: readFileSync(filePath) }));
631
+ }
632
+ await client.setWorkspaceEmblem(workspace, id);
633
+ console.log(id ? tr(`엠블럼을 바꿨습니다: ${client.attachmentRawUrl(id)}`, `Emblem set: ${client.attachmentRawUrl(id)}`) : tr('엠블럼을 지웠습니다 (이름 첫 글자 배지).', 'Emblem cleared (initial-letter badge).'));
634
+ }
635
+
600
636
  async function cmdWs() {
601
637
  const cfg = loadConfig();
602
638
  const candidates = candidateClients(cfg);
@@ -1917,24 +1953,24 @@ async function cmdBooking(args) {
1917
1953
  usage(tr(`rootr booking show <path|id>`, `rootr booking show <path|id>`)) +
1918
1954
  tr(
1919
1955
  `\n rootr booking set <path|id> [--on|--off] [--days 1,2,3,4,5] [--hours 10:00-18:00]\n` +
1920
- ` [--duration 30] [--buffer 10] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1956
+ ` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1921
1957
  ` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
1922
1958
  ` [--block-time "daily 12:00-13:00=점심, mon 09:00-11:00, fri"]\n` +
1923
1959
  ` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
1924
1960
  ` [--self-move on|off] [--remind 1d,1h (안 보내려면 --remind off)]\n` +
1925
- ` [--types "30분 상담=30m,데모=60m|화면 보며 설명"] [--guests on|off] [--max-guests 5]\n` +
1961
+ ` [--types "30분 상담=30m,데모=60m|화면 보며 설명"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
1926
1962
  ` [--after https://example.com/thanks]\n` +
1927
1963
  ` [--hosts "김상담=a@acme.com,이데모=b@acme.com"] [--assign roundrobin|collective|off]\n` +
1928
1964
  ` [--ask "연락처:required,상담 종류"] [--message "안내 문구"]\n` +
1929
1965
  ` [--gcal-connection <id>] [--gcal-busy <캘린더id,...>] [--gcal-write <캘린더id|none>]\n` +
1930
1966
  ` rootr booking slots <path|id> [--from 2026-10-01] [--to 2026-10-31] [--type <종류 이름표>]\n`,
1931
1967
  `\n rootr booking set <path|id> [--on|--off] [--days 1,2,3,4,5] [--hours 10:00-18:00]\n` +
1932
- ` [--duration 30] [--buffer 10] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1968
+ ` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1933
1969
  ` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
1934
1970
  ` [--block-time "daily 12:00-13:00=lunch, mon 09:00-11:00, fri"]\n` +
1935
1971
  ` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
1936
1972
  ` [--self-move on|off] [--remind 1d,1h (--remind off to stop)]\n` +
1937
- ` [--types "Intro=30m,Demo=60m|walk through the product"] [--guests on|off] [--max-guests 5]\n` +
1973
+ ` [--types "Intro=30m,Demo=60m|walk through the product"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
1938
1974
  ` [--after https://example.com/thanks]\n` +
1939
1975
  ` [--hosts "Sam=a@acme.com,Lee=b@acme.com"] [--assign roundrobin|collective|off]\n` +
1940
1976
  ` [--ask "Phone:required,Topic"] [--message "note for visitors"]\n` +
@@ -1958,7 +1994,7 @@ async function cmdBooking(args) {
1958
1994
  const days = (b.weekdays || []).map((d) => DAY_NAMES[d]).join('·');
1959
1995
  console.log(tr(`방문자 일정 등록: 켜짐`, `Visitor booking: on`));
1960
1996
  console.log(tr(` 받는 때: ${days} ${b.startTime}~${b.endTime} (${b.timezone})`, ` when: ${days} ${b.startTime}-${b.endTime} (${b.timezone})`));
1961
- console.log(tr(` 한 번 길이: ${b.durationMin}분 · 사이 여유 ${b.bufferMin}분`, ` slot: ${b.durationMin} min · gap ${b.bufferMin} min`));
1997
+ console.log(tr(` 한 번 길이: ${b.durationMin}분 · 예약 앞뒤 여유 ${b.bufferMin}분 · 구글 기존 일정 앞뒤 여유 ${b.calendarBufferMin ?? b.bufferMin}분`, ` slot: ${b.durationMin} min · gap around bookings ${b.bufferMin} min · gap around existing Google events ${b.calendarBufferMin ?? b.bufferMin} min`));
1962
1998
  console.log(tr(` ${b.minNoticeHours}시간 전까지 · ${b.maxDaysAhead}일 뒤까지 · 하루 ${b.maxPerDay || '제한 없음'}`, ` ${b.minNoticeHours}h notice · up to ${b.maxDaysAhead} days · per day ${b.maxPerDay || 'unlimited'}`));
1963
1999
  console.log(tr(` 확정: ${b.approval === 'manual' ? '승인 후' : '바로'}`, ` confirm: ${b.approval === 'manual' ? 'after approval' : 'instantly'}`));
1964
2000
  if (b.blockedDates?.length) console.log(tr(` 쉬는 날: ${b.blockedDates.join(', ')}`, ` days off: ${b.blockedDates.join(', ')}`));
@@ -1976,6 +2012,7 @@ async function cmdBooking(args) {
1976
2012
  if (b.allowGuests && b.maxGuests > 0) {
1977
2013
  console.log(tr(` 손님 함께 부르기: 최대 ${b.maxGuests}명`, ` guests: up to ${b.maxGuests}`));
1978
2014
  }
2015
+ if (b.trackSource) console.log(tr(' 유입 경로 기록: 켜짐', ' source tracking: on'));
1979
2016
  if (b.redirectUrl) console.log(tr(` 잡은 뒤 보낼 곳: ${b.redirectUrl}`, ` after booking: ${b.redirectUrl}`));
1980
2017
  if (b.allowVisitorCancel === false) console.log(tr(' 방문자 직접 취소: 꺼짐', ' visitor self-cancel: off'));
1981
2018
  if (b.allowVisitorReschedule === false) console.log(tr(' 방문자 직접 시간 변경: 꺼짐', ' visitor reschedule: off'));
@@ -2043,6 +2080,8 @@ async function cmdBooking(args) {
2043
2080
  }
2044
2081
  if (args.duration !== undefined) body.durationMin = Number(args.duration);
2045
2082
  if (args.buffer !== undefined) body.bufferMin = Number(args.buffer);
2083
+ // 구글 캘린더에 이미 있던 일정 앞뒤 여유(이동 시간 등). --buffer 는 이 표로 잡힌 예약 앞뒤.
2084
+ if (args['calendar-buffer'] !== undefined) body.calendarBufferMin = Number(args['calendar-buffer']);
2046
2085
  if (args.notice !== undefined) body.minNoticeHours = Number(args.notice);
2047
2086
  if (args.ahead !== undefined) body.maxDaysAhead = Number(args.ahead);
2048
2087
  if (args['per-day'] !== undefined) body.maxPerDay = Number(args['per-day']);
@@ -2054,6 +2093,7 @@ async function cmdBooking(args) {
2054
2093
  if (args['self-move'] !== undefined) body.allowVisitorReschedule = args['self-move'] !== 'off' && args['self-move'] !== false;
2055
2094
  if (args['guests'] !== undefined) body.allowGuests = args['guests'] !== 'off' && args['guests'] !== false;
2056
2095
  if (args['max-guests'] !== undefined) body.maxGuests = Number(args['max-guests']);
2096
+ if (args['track-source'] !== undefined) body.trackSource = args['track-source'] !== 'off' && args['track-source'] !== false;
2057
2097
  if (args['after'] !== undefined) body.redirectUrl = String(args['after'] === true ? '' : args['after']).trim();
2058
2098
  if (args['assign'] !== undefined) {
2059
2099
  const a = String(args['assign']).trim().toLowerCase();
@@ -3031,6 +3071,128 @@ async function cmdSign(args) {
3031
3071
  }
3032
3072
  }
3033
3073
 
3074
+ // ---- doc mail (DOC-MAIL — 문서 송수신) ------------------------------------
3075
+
3076
+ const MAIL_USAGE =
3077
+ usage(`rootr mail [inbox|sent] | show <id> | revoke <id> | hide <id>\n`) +
3078
+ tr(' (자세한 건 rootr --help)', ` (see rootr --help)`);
3079
+
3080
+ const NODE_TYPE_KO = {
3081
+ DOCUMENT: tr('문서', 'document'),
3082
+ DATABASE: tr('표', 'table'),
3083
+ SPREADSHEET: tr('스프레드시트', 'spreadsheet'),
3084
+ WHITEBOARD: tr('화이트보드', 'whiteboard'),
3085
+ PRESENTATION: tr('발표자료', 'deck'),
3086
+ FORM: tr('폼', 'form'),
3087
+ ISSUE_TRACKER: tr('이슈', 'issues'),
3088
+ LOG: tr('로그', 'log'),
3089
+ CRM: 'CRM',
3090
+ RECORDING: tr('녹음', 'recording'),
3091
+ PAGE: tr('페이지', 'page'),
3092
+ SIGNED: tr('서명문서', 'signed document'),
3093
+ };
3094
+
3095
+ async function cmdSend(args) {
3096
+ const target = args._[0];
3097
+ const toArg = typeof args.to === 'string' ? args.to : '';
3098
+ const to = toArg.split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean);
3099
+ if (!target || !to.length) {
3100
+ throw new Error(usage(tr(`rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."]`, `rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."]`)));
3101
+ }
3102
+ const client = makeClient();
3103
+ const nodeId = requireResolved(await resolveTarget(client, target));
3104
+ const out = await client.sendNode(nodeId, { to, message: typeof args.message === 'string' ? args.message : undefined });
3105
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3106
+ const items = out.items ?? [];
3107
+ console.log(tr(`${items.length}명에게 보냈습니다 (묶음 ${out.batchId})`, `Sent to ${items.length} ${items.length === 1 ? 'person' : 'people'} (batch ${out.batchId})`));
3108
+ for (const it of items) {
3109
+ console.log(
3110
+ ` ${it.to}\t${it.id}\t` +
3111
+ (it.emailed ? tr('메일 보냄', 'emailed') : tr(`메일 실패${it.error ? `: ${it.error}` : ''} — 문서는 받은 문서함에 있습니다`, `email failed${it.error ? `: ${it.error}` : ''} — the copy is still in their inbox`)),
3112
+ );
3113
+ }
3114
+ }
3115
+
3116
+ function mailWhen(iso) {
3117
+ return iso ? String(iso).replace('T', ' ').slice(0, 16) : '';
3118
+ }
3119
+
3120
+ async function cmdMail(args) {
3121
+ const sub = args._[0];
3122
+ const client = makeClient();
3123
+ if (!sub || sub === 'inbox' || sub === 'sent' || sub === 'list') {
3124
+ const box = sub === 'sent' || args.box === 'sent' ? 'sent' : 'inbox';
3125
+ const limit = args.limit ? Number(args.limit) : undefined;
3126
+ const out = await client.listDocMail({ box, limit, q: typeof args.q === 'string' ? args.q : undefined, cursor: typeof args.cursor === 'string' ? args.cursor : undefined });
3127
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3128
+ if (box === 'inbox') console.log(tr(`받은 문서 — 안 읽은 것 ${out.unread}개`, `Received — ${out.unread} unread`));
3129
+ else console.log(tr('보낸 문서', 'Sent'));
3130
+ if (!out.items?.length) return console.log(tr(' (없음)', ' (none)'));
3131
+ for (const m of out.items) {
3132
+ const type = NODE_TYPE_KO[m.nodeType] ?? m.nodeType;
3133
+ const state = m.revoked
3134
+ ? tr('[회수됨]', '[withdrawn]')
3135
+ : box === 'inbox'
3136
+ ? m.readAt ? '' : tr('[새 문서]', '[new]')
3137
+ : m.readAt ? tr(`[읽음 ${mailWhen(m.readAt)}]`, `[read ${mailWhen(m.readAt)}]`) : tr('[안 읽음]', '[unread]');
3138
+ const who = box === 'inbox' ? `${m.senderName} <${m.senderEmail}>` : `→ ${m.recipientEmail}`;
3139
+ const mailFail = box === 'sent' && m.emailed === false ? tr(' [메일 실패]', ' [email failed]') : '';
3140
+ console.log(`${m.id}\t${mailWhen(m.createdAt)}\t${type}\t${m.title}\t${who}${state ? ` ${state}` : ''}${mailFail}`);
3141
+ if (m.message) console.log(` ${String(m.message).split('\n')[0].slice(0, 100)}`);
3142
+ }
3143
+ if (out.nextCursor) console.log(tr(`\n다음 쪽: rootr mail ${box} --cursor ${out.nextCursor}`, `\nNext page: rootr mail ${box} --cursor ${out.nextCursor}`));
3144
+ return;
3145
+ }
3146
+ const id = args._[1];
3147
+ if (!id) throw new Error(MAIL_USAGE);
3148
+ switch (sub) {
3149
+ case 'show':
3150
+ case 'read': {
3151
+ const m = await client.getDocMail(id);
3152
+ if (args.json) return console.log(JSON.stringify(m, null, 2));
3153
+ console.log(`${m.title} (${NODE_TYPE_KO[m.nodeType] ?? m.nodeType})`);
3154
+ console.log(tr(`보낸 사람: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`, `From: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`));
3155
+ console.log(tr(`받는 사람: ${m.recipientEmail} · 보낸 때 ${mailWhen(m.createdAt)}${m.readAt ? ` · 읽음 ${mailWhen(m.readAt)}` : ''}`, `To: ${m.recipientEmail} · sent ${mailWhen(m.createdAt)}${m.readAt ? ` · read ${mailWhen(m.readAt)}` : ''}`));
3156
+ if (m.message) console.log(tr(`메시지: ${m.message}`, `Message: ${m.message}`));
3157
+ if (m.revoked && !m.payload) return console.log(tr('\n보낸 사람이 이 문서를 회수했습니다.', '\nThe sender withdrew this document.'));
3158
+ if (m.revoked) console.log(tr('(회수함 — 받는 쪽에서는 본문이 안 보입니다)', '(withdrawn — the recipient no longer sees the content)'));
3159
+ const p = m.payload ?? {};
3160
+ console.log('');
3161
+ if (p.kind === 'doc') return console.log(p.content ?? '');
3162
+ if (p.kind === 'database') {
3163
+ const props = p.properties ?? [];
3164
+ const rows = p.rows ?? [];
3165
+ console.log(props.map((c) => c.name).join('\t'));
3166
+ for (const r of rows) {
3167
+ console.log(props.map((c) => {
3168
+ const v = r.values?.[c.id];
3169
+ return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
3170
+ }).join('\t'));
3171
+ }
3172
+ if (p.truncated) console.log(tr(`… ${p.truncated.total}행 중 ${p.truncated.shown}행만 담겼습니다`, `… ${p.truncated.shown} of ${p.truncated.total} rows were included`));
3173
+ return;
3174
+ }
3175
+ const keys = Object.keys(p).filter((k) => k !== 'kind');
3176
+ console.log(tr(`[${p.kind}] 담긴 것: ${keys.join(', ')} — 전부 보려면 --json`, `[${p.kind}] contains: ${keys.join(', ')} — use --json for everything`));
3177
+ return;
3178
+ }
3179
+ case 'revoke': {
3180
+ const out = await client.revokeDocMail(id);
3181
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3182
+ return console.log(tr(`회수했습니다 (${mailWhen(out.revokedAt)}) — 받는 쪽에서 본문이 안 보입니다.`, `Withdrawn (${mailWhen(out.revokedAt)}) — the recipient no longer sees the content.`));
3183
+ }
3184
+ case 'hide':
3185
+ case 'rm': {
3186
+ const box = args.box === 'inbox' || args.box === 'sent' ? args.box : undefined;
3187
+ const out = await client.hideDocMail(id, { box });
3188
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3189
+ return console.log(tr(`내 문서함에서 지웠습니다 (${out.hidden}). 상대 화면은 그대로입니다.`, `Removed from your mailbox (${out.hidden}). The other side still has it.`));
3190
+ }
3191
+ default:
3192
+ throw new Error(MAIL_USAGE);
3193
+ }
3194
+ }
3195
+
3034
3196
  async function main() {
3035
3197
  const argv = process.argv.slice(2);
3036
3198
  const args = parseArgs(argv);
@@ -3072,6 +3234,8 @@ async function main() {
3072
3234
  return cmdRestore(args);
3073
3235
  case 'ws':
3074
3236
  return cmdWs(args);
3237
+ case 'emblem':
3238
+ return cmdEmblem(args);
3075
3239
  case 'ask':
3076
3240
  return cmdAsk(args);
3077
3241
  case 'config':
@@ -3094,6 +3258,10 @@ async function main() {
3094
3258
  return cmdAccess(args);
3095
3259
  case 'sign':
3096
3260
  return cmdSign(args);
3261
+ case 'send':
3262
+ return cmdSend(args);
3263
+ case 'mail':
3264
+ return cmdMail(args);
3097
3265
  case 'feedback':
3098
3266
  return cmdFeedback(args);
3099
3267
  case 'scaffold':
package/lib/client.js CHANGED
@@ -757,6 +757,16 @@ export class RootrClient {
757
757
  * 이름을 그대로 다시 적게 하는 것은 서버가 요구하는 안전장치다 — 지울 것을
758
758
  * 정말 보고 있는지 확인한다.
759
759
  */
760
+ /**
761
+ * WS-EMBLEM — set (attachment id) or clear (null) the workspace emblem shown in the
762
+ * sidebar badge and workspace switcher. The server only accepts the relative raw path
763
+ * of an image attachment already uploaded to this same workspace.
764
+ */
765
+ async setWorkspaceEmblem(workspaceId, attachmentId) {
766
+ const iconUrl = attachmentId ? `/api/v1/attachments/${encodeURIComponent(attachmentId)}/raw` : null;
767
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}`, { method: 'PATCH', body: { iconUrl } });
768
+ }
769
+
760
770
  async deleteWorkspace({ workspaceId, confirmName }) {
761
771
  return this.request(`/workspaces/${workspaceId}`, {
762
772
  method: 'DELETE',
@@ -2039,6 +2049,48 @@ export class RootrClient {
2039
2049
  return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/ledger/verify`);
2040
2050
  }
2041
2051
 
2052
+ // ---- Doc mail (DOC-MAIL — 문서 송수신) ---------------------------------
2053
+ //
2054
+ // 노드 하나를 **보낼 때의 복사본**으로 이메일 1~20명에게 보낸다. 받은 문서는 받는 사람의
2055
+ // 이메일에 붙는다 — 계정이 없어도 메일 링크로 열리고, 나중에 그 이메일로 가입하면 문서함에
2056
+ // 그대로 있다. 문서함은 사람(이메일)에 붙으므로 워크스페이스 주소 밑이 아니다.
2057
+
2058
+ /** 보내기 — WRITE 이상. 응답 `{batchId, items:[{id,to,emailed}]}`. */
2059
+ async sendNode(nodeId, { to, message } = {}) {
2060
+ return this.request(`/nodes/${encodeURIComponent(nodeId)}/send`, {
2061
+ method: 'POST',
2062
+ body: { to, ...(message ? { message } : {}) },
2063
+ });
2064
+ }
2065
+
2066
+ /** 문서함 목록 — box: inbox(받은, 기본) | sent(보낸). 본문 없이 요약 + 안 읽은 수(unread). */
2067
+ async listDocMail({ box, limit, cursor, q } = {}) {
2068
+ const qs = new URLSearchParams();
2069
+ if (box) qs.set('box', box);
2070
+ if (limit) qs.set('limit', String(limit));
2071
+ if (cursor) qs.set('cursor', cursor);
2072
+ if (q) qs.set('q', q);
2073
+ const s = qs.toString();
2074
+ return this.request(`/doc-mail${s ? `?${s}` : ''}`);
2075
+ }
2076
+
2077
+ /** 한 통 열기(본문 payload 포함). 받는 사람이 열면 읽음으로 바뀐다. */
2078
+ async getDocMail(id) {
2079
+ return this.request(`/doc-mail/${encodeURIComponent(id)}`);
2080
+ }
2081
+
2082
+ /** 회수 — 보낸 사람만. 이후 받는 쪽에서 본문이 안 보인다. */
2083
+ async revokeDocMail(id) {
2084
+ return this.request(`/doc-mail/${encodeURIComponent(id)}/revoke`, { method: 'POST', body: {} });
2085
+ }
2086
+
2087
+ /** 내 문서함에서만 지우기(상대 화면은 그대로). box 로 나에게 보낸 것의 한쪽만 고를 수 있다. */
2088
+ async hideDocMail(id, { box } = {}) {
2089
+ return this.request(`/doc-mail/${encodeURIComponent(id)}${box ? `?box=${encodeURIComponent(box)}` : ''}`, {
2090
+ method: 'DELETE',
2091
+ });
2092
+ }
2093
+
2042
2094
  /**
2043
2095
  * FEEDBACK — 제보를 보낸다. 다른 메서드와 달리 `request()`를 쓰지 않고 직접
2044
2096
  * fetch 한다: **API 키가 없어도, 키가 틀렸어도 보낼 수 있어야** 하기 때문이다.
@@ -82,7 +82,10 @@ export function registerAttachmentTools(server) {
82
82
  '`markdown` is ready to paste into a document body (![alt](url)). For the common case of inserting an ' +
83
83
  'image straight into a document, prefer rootr_attach_image_to_document instead. For a large file (or ' +
84
84
  'over the remote connector, where filePath is not available), use rootr_create_upload_url → PUT → ' +
85
- 'rootr_complete_upload instead of inlining the bytes as base64Data.',
85
+ 'rootr_complete_upload instead of inlining the bytes as base64Data. ' +
86
+ 'Video files (mp4/webm/mov, up to 75MB) are allowed; to put one on a slide, upload it with nodeId = the deck and add it to the slide\'s ' +
87
+ '`videos[]` (Team plan and above for decks). Videos play via signed URLs (`vt=`) that the server adds when it returns the slide — a bare /raw link ' +
88
+ 'opened from another website is blocked.',
86
89
  annotations: { readOnlyHint: false, destructiveHint: false },
87
90
  inputSchema: uploadInputSchema,
88
91
  },
@@ -154,6 +157,60 @@ export function registerAttachmentTools(server) {
154
157
  },
155
158
  );
156
159
 
160
+ // 엠블럼은 워크스페이스에 붙는 것이라 노드 칸(nodeId)은 받지 않는다.
161
+ const { nodeId: _unusedNodeId, ...emblemUploadSchema } = uploadInputSchema;
162
+ // WS-EMBLEM — 화면(설정 → 일반 → 엠블럼)에서만 되던 일을 에이전트도 하게. 새 파일을 주면
163
+ // 올린 뒤 걸고, 이미 올린 첨부 id 를 주면 그것을 걸고, clear 면 이름 첫 글자 배지로 되돌린다.
164
+ server.registerTool(
165
+ 'rootr_set_workspace_emblem',
166
+ {
167
+ title: 'Set or clear the workspace emblem (logo)',
168
+ description:
169
+ 'Set the Rootr (루터) workspace emblem — the small square logo shown in the sidebar badge and the ' +
170
+ 'workspace switcher for every member. Give ONE of: an image file (filePath or base64Data, with filename ' +
171
+ '+ mimeType — it is uploaded to this workspace first), `attachmentId` of an image already uploaded to ' +
172
+ 'this workspace (e.g. from rootr_upload_attachment / rootr_complete_upload), or `clear: true` to go back ' +
173
+ 'to the default initial-letter badge. Requires ADMIN on the workspace. A square PNG/SVG/WebP of 256px+ ' +
174
+ 'looks best; it is shown cropped to a rounded square.',
175
+ annotations: { readOnlyHint: false, destructiveHint: false },
176
+ inputSchema: {
177
+ ...emblemUploadSchema,
178
+ filename: emblemUploadSchema.filename.optional(),
179
+ mimeType: emblemUploadSchema.mimeType.optional(),
180
+ attachmentId: z.string().optional().describe('Id of an image attachment already in this workspace'),
181
+ clear: z.boolean().optional().describe('true = remove the emblem (back to the initial-letter badge)'),
182
+ },
183
+ },
184
+ async ({ filename, mimeType, filePath, base64Data, attachmentId, clear, workspace }) => {
185
+ try {
186
+ const ws = requireWorkspace(workspace);
187
+ const client = makeClient(ws);
188
+ const given = [Boolean(filePath || base64Data), Boolean(attachmentId), Boolean(clear)].filter(Boolean).length;
189
+ if (given !== 1) {
190
+ throw new RootrApiError('파일(filePath/base64Data), attachmentId, clear 중 하나만 주세요.');
191
+ }
192
+ let id = null;
193
+ if (attachmentId) {
194
+ if (!ATTACHMENT_ID.test(attachmentId)) throw new RootrApiError(`첨부 id 모양이 아닙니다: ${attachmentId}`);
195
+ id = attachmentId;
196
+ } else if (!clear) {
197
+ if (!filename || !mimeType) throw new RootrApiError('파일을 올릴 때는 filename 과 mimeType 이 필요합니다.');
198
+ if (!mimeType.startsWith('image/')) throw new RootrApiError('엠블럼은 이미지(image/*)만 됩니다.');
199
+ const data = readAttachmentBytes({ filePath, base64Data });
200
+ ({ id } = await client.uploadAttachment(ws, { filename, mimeType, data }));
201
+ }
202
+ const updated = await client.setWorkspaceEmblem(ws, id);
203
+ return jsonResult({
204
+ workspaceId: ws,
205
+ iconUrl: updated?.iconUrl ?? null,
206
+ emblemUrl: id ? client.attachmentRawUrl(id) : null,
207
+ });
208
+ } catch (err) {
209
+ return errorResult(err);
210
+ }
211
+ },
212
+ );
213
+
157
214
  // ISSUE-222 — rootr_upload_attachment/rootr_attach_image_to_document require
158
215
  // the full file as base64Data over the remote connector (it can't read
159
216
  // filePath), which breaks down for larger images. This gives the caller a
@@ -119,7 +119,18 @@ export function registerBookingTools(server) {
119
119
  .number()
120
120
  .optional()
121
121
  .describe('Length of one booking in minutes: 15, 20, 30, 45, 60, 90, 120, 180 or 240.'),
122
- bufferMin: z.number().optional().describe('Gap kept before AND after each booking, in minutes.'),
122
+ bufferMin: z
123
+ .number()
124
+ .optional()
125
+ .describe('Gap kept before AND after each booking made through this table, in minutes (0-240).'),
126
+ calendarBufferMin: z
127
+ .number()
128
+ .optional()
129
+ .describe(
130
+ 'Gap kept before AND after every event already on the connected Google Calendar (travel time etc.), in minutes (0-240). ' +
131
+ 'Separate from bufferMin: e.g. 60 here and 0 in bufferMin blocks an hour around outside meetings while online bookings may sit back to back. ' +
132
+ 'Bookings this table wrote to Google are not "existing events" — they get bufferMin. If never set, it follows bufferMin.',
133
+ ),
123
134
  minNoticeHours: z.number().optional().describe('No bookings closer than this many hours from now.'),
124
135
  maxDaysAhead: z.number().optional().describe('How far ahead visitors may book, in days.'),
125
136
  maxPerDay: z.number().optional().describe('Max bookings per day. 0 = unlimited.'),
@@ -209,6 +220,15 @@ export function registerBookingTools(server) {
209
220
  .optional()
210
221
  .describe('Let the visitor add other email addresses. Guests get the same emails and the same calendar invite.'),
211
222
  maxGuests: z.number().int().optional().describe('How many guests a visitor may add (default 5). 0 turns it off.'),
223
+ trackSource: z
224
+ .boolean()
225
+ .optional()
226
+ .describe(
227
+ 'Record where each booking came from. Adds a text column ("유입 경로" unless you pass sourceLabel) and fills it ' +
228
+ 'from the address the visitor arrived on, e.g. "source=meta, medium=paid, campaign=ax, content=3-docs" ' +
229
+ '(utm_* first, else the ad click id, else the referring site). Left empty when nothing is known.',
230
+ ),
231
+ sourceLabel: z.string().optional().describe('Name for the column trackSource creates (default "유입 경로").'),
212
232
  redirectUrl: z
213
233
  .string()
214
234
  .optional()
@@ -0,0 +1,116 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
4
+
5
+ /**
6
+ * DOC-MAIL — 문서 송수신(창업자 지시 2026-10-20).
7
+ *
8
+ * 어떤 타입이든(문서·표·시트·화이트보드·덱·폼·이슈·로그·CRM·녹음·페이지·서명문서) 노드 하나를
9
+ * **보낼 때의 복사본**으로 특정 이메일에게 보낸다. 받은 문서는 받는 사람의 **이메일 주소**에 붙는다 —
10
+ * 계정이 없어도 메일 링크로 열리고, 나중에 그 이메일로 가입하면 받은 문서함에 그대로 있다.
11
+ * 보낸 뒤 원본을 고치거나 지워도 받은 문서는 그대로다(살아 있는 공유가 필요하면 권한/공개를 쓴다).
12
+ */
13
+ export function registerDocMailTools(server) {
14
+ server.registerTool(
15
+ 'rootr_send_document',
16
+ {
17
+ title: 'Send a Rootr node to email addresses',
18
+ description:
19
+ 'Send a SNAPSHOT of any Rootr node (document, table, spreadsheet, whiteboard, deck, form, issues, log, CRM, ' +
20
+ 'recording, page, signed document — anything but a folder) to 1–20 email addresses. Each recipient gets an ' +
21
+ 'email with a link that opens the copy WITHOUT a Rootr account; if they have (or later create) a Rootr account ' +
22
+ 'with that email, it also sits in their inbox (rootr_list_doc_mail). The copy is frozen at send time — later ' +
23
+ 'edits or deletion of the original do not change it; use permissions/publishing for a live share instead. ' +
24
+ 'Needs WRITE on the node (the same bar as sharing). What never goes out: the same things a public page hides ' +
25
+ '(person/attachment/relation columns, row documents, form responses, signer emails). ' +
26
+ 'The result lists each recipient with `emailed` (false = the notification email failed; the copy still exists). ' +
27
+ 'Only send to addresses the user asked for — this emails real people.',
28
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
29
+ inputSchema: {
30
+ node: z.string().describe(`보낼 노드 id${NODE_TARGET_HINT}`),
31
+ to: z.array(z.string()).min(1).max(20).describe('받는 사람 이메일 주소들(1~20개). 대소문자만 다른 주소는 같은 사람'),
32
+ message: z.string().max(2000).optional().describe('보내며 함께 적을 말(메일과 문서함에 보인다, 2000자까지)'),
33
+ workspace: z.string().optional().describe('경로로 줄 때 찾을 워크스페이스 id (생략하면 설정값)'),
34
+ },
35
+ },
36
+ async ({ node, to, message, workspace }) => {
37
+ try {
38
+ const client = makeClient(workspace);
39
+ return jsonResult(await client.sendNode(await resolveNodeId(client, node), { to, message }));
40
+ } catch (err) {
41
+ return errorResult(err);
42
+ }
43
+ },
44
+ );
45
+
46
+ server.registerTool(
47
+ 'rootr_list_doc_mail',
48
+ {
49
+ title: 'List my received or sent Rootr documents',
50
+ description:
51
+ "List the caller's document mailbox: box=inbox (documents sent TO the caller's account email — default) or " +
52
+ 'box=sent (documents the caller sent). Summaries only (title, type, sender, recipient, message, sentAt, ' +
53
+ 'readAt, revoked); open one with rootr_read_doc_mail. `unread` is the number of unread received documents. ' +
54
+ 'Page with `cursor` = the previous `nextCursor`. Belongs to the person (their email), not to a workspace.',
55
+ annotations: { readOnlyHint: true },
56
+ inputSchema: {
57
+ box: z.enum(['inbox', 'sent']).optional().describe('inbox=받은 문서(기본), sent=보낸 문서'),
58
+ limit: z.number().int().min(1).max(100).optional().describe('한 번에 몇 개(기본 30)'),
59
+ cursor: z.string().optional().describe('다음 쪽 — 앞 응답의 nextCursor'),
60
+ q: z.string().optional().describe('제목·보낸 사람·받는 사람으로 찾기'),
61
+ },
62
+ },
63
+ async ({ box, limit, cursor, q }) => {
64
+ try {
65
+ return jsonResult(await makeClient().listDocMail({ box, limit, cursor, q }));
66
+ } catch (err) {
67
+ return errorResult(err);
68
+ }
69
+ },
70
+ );
71
+
72
+ server.registerTool(
73
+ 'rootr_read_doc_mail',
74
+ {
75
+ title: 'Read a received or sent Rootr document',
76
+ description:
77
+ 'Open one document from the mailbox (id from rootr_list_doc_mail). Returns the summary plus `payload` — the ' +
78
+ 'snapshot taken at send time, in the same shape a public page uses: `kind` says what it is (doc → `content` ' +
79
+ 'markdown; database → `properties` + `rows`; spreadsheet → `data`; …). Opening a received one marks it read. ' +
80
+ 'If the sender withdrew it, `revoked` is true and `payload` is null.',
81
+ annotations: { readOnlyHint: false },
82
+ inputSchema: {
83
+ id: z.string().describe('문서함 항목 id (rootr_list_doc_mail 의 items[].id)'),
84
+ },
85
+ },
86
+ async ({ id }) => {
87
+ try {
88
+ return jsonResult(await makeClient().getDocMail(id));
89
+ } catch (err) {
90
+ return errorResult(err);
91
+ }
92
+ },
93
+ );
94
+
95
+ server.registerTool(
96
+ 'rootr_revoke_doc_mail',
97
+ {
98
+ title: 'Withdraw a sent Rootr document',
99
+ description:
100
+ 'Withdraw a document the caller sent (id from rootr_list_doc_mail box=sent). From then on the recipient sees ' +
101
+ 'only the title and "withdrawn" — no content — and the email link answers 410. Only the sender can do this; ' +
102
+ 'it cannot be undone (send it again instead). Copies the recipient already saved elsewhere are out of reach.',
103
+ annotations: { readOnlyHint: false, destructiveHint: true },
104
+ inputSchema: {
105
+ id: z.string().describe('보낸 문서함 항목 id'),
106
+ },
107
+ },
108
+ async ({ id }) => {
109
+ try {
110
+ return jsonResult(await makeClient().revokeDocMail(id));
111
+ } catch (err) {
112
+ return errorResult(err);
113
+ }
114
+ },
115
+ );
116
+ }
@@ -94,8 +94,10 @@ const BLOCK_GUIDE =
94
94
  'for a document in the same workspace. Internal path strings are rejected. A node link renders as the in-app ' +
95
95
  'document for members, as the public address on the published page, and is hidden entirely if that document is ' +
96
96
  'not published — so link to nodes rather than pasting addresses.\n' +
97
- 'CONFIG: {theme, accentColor?, navEnabled?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
98
- 'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent. ' +
97
+ 'CONFIG: {theme, accentColor?, navEnabled?, metaPixelId?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
98
+ 'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent.' +
99
+ ' metaPixelId is the numeric Meta (Facebook/Instagram) Pixel ID: when set, the PUBLISHED page reports PageView and, when a visitor books through a booking block, Schedule to that pixel (never in the editor); send "" to turn it off. ' +
100
+ 'config is replaced as a whole on update, so resend the keys you want to keep. ' +
99
101
  'Every published page carries the Rootr signature at the bottom.\n' +
100
102
  'Block ids are optional — omit them and they are generated. Keep the ids you get back if you want to edit one block later.';
101
103
 
@@ -129,7 +131,7 @@ export function registerPageTools(server) {
129
131
  config: z
130
132
  .record(z.unknown())
131
133
  .optional()
132
- .describe('{theme, accentColor?, navEnabled?} — theme: classic|minimal|warm|pastel|editorial|mono|dark|midnight'),
134
+ .describe('{theme, accentColor?, navEnabled?, metaPixelId?} — theme: classic|minimal|warm|pastel|editorial|mono|dark|midnight; metaPixelId: numeric Meta Pixel ID'),
133
135
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
134
136
  },
135
137
  },
@@ -211,7 +213,7 @@ export function registerPageTools(server) {
211
213
  .array(z.record(z.unknown()))
212
214
  .optional()
213
215
  .describe('COMPLETE blocks array to set — any block you omit is deleted'),
214
- config: z.record(z.unknown()).optional().describe('{theme, accentColor?, navEnabled?}'),
216
+ config: z.record(z.unknown()).optional().describe('{theme, accentColor?, navEnabled?, metaPixelId?} — replaces the whole config'),
215
217
  },
216
218
  },
217
219
  async ({ pageId, blocks, config }) => {
@@ -24,6 +24,8 @@ const AUTHORING_GUIDE =
24
24
  'placement does not draw the image. For `images[]`, x/y/w/h give freeform PPT-style placement in 1280x720 canvas ' +
25
25
  'coords (drawn only when both x and y are set). ALWAYS add an `alt` caption to every image — alt text is part of the graph text spine, so a captioned image ' +
26
26
  'connects into the knowledge graph while an uncaptioned one does not.\n' +
27
+ 'Videos (Team plan and above — a Free workspace gets 403 PLAN_UPGRADE_REQUIRED when it ADDS a video; existing ones stay editable/removable): `videos[]` (max 20 per slide) holds {id?, src, title?, poster?, x?, y?, w?, h?} in 1280x720 canvas coords (default 640x360 centred). `src` is an uploaded video file URL (upload mp4/webm ≤75MB via rootr_upload_attachment with nodeId = this deck, then use /api/v1/attachments/{id}/raw), an https video-file URL, or a YouTube/Vimeo link; `poster` is an image URL (thumbnail). `title` is a caption and joins the graph text spine. Slide `html` may instead contain <video src="…/v1/attachments/{id}/raw" controls>, <div data-video-embed="https://www.youtube.com/watch?v=…">, or a YouTube/Vimeo <iframe>. Uploaded videos play through short-lived signed URLs (`vt=` query) the server adds when it returns the slide — never write `vt=` yourself; it is stripped on save.\n' +
28
+ 'Animations (all plans): in slide `html`, add data-anim="<effect>" to any element to animate it when the slide opens in view/present mode. Entrance effects: fade, fade-up, fade-down, fly-left, fly-right, zoom, pop, blur. Looping effects: pulse, float, spin, blink, shake. Duration/delay go in inline CSS variables, e.g. <div data-anim="fade-up" style="--anim-delay:.4s;--anim-dur:.8s">; stagger delays to set the order. Thumbnails and PDF/PPTX exports show the finished state. Your own @keyframes/animation CSS in the html also plays.\n' +
27
29
  'Use kind to mark each slide\'s role: "cover" (deck title slide), "section" (chapter divider), "content" (body slide, ' +
28
30
  'the default), "closing" (last slide / call to action).\n' +
29
31
  'Page numbers: "content" and "section" slides automatically show a page number that follows the slide order (it ' +
@@ -70,6 +72,19 @@ const imageShape = z
70
72
  })
71
73
  .describe('A slide image. Set src (uploaded/https/data: URI) or prompt (AI-generate); always add an alt caption.');
72
74
 
75
+ const videoShape = z
76
+ .object({
77
+ id: z.string().optional().describe('Stable video id. Auto-generated by the editor if omitted.'),
78
+ src: z.string().describe('Uploaded video URL (/api/v1/attachments/{id}/raw), https video file URL, or a YouTube/Vimeo link'),
79
+ title: z.string().optional().describe('Caption — part of the graph text spine'),
80
+ poster: z.string().optional().describe('Thumbnail image URL for file videos'),
81
+ x: z.number().optional().describe('Left in 1280x720 canvas px'),
82
+ y: z.number().optional().describe('Top in 1280x720 canvas px'),
83
+ w: z.number().optional().describe('Width px (default 640)'),
84
+ h: z.number().optional().describe('Height px (default 360)'),
85
+ })
86
+ .describe('A video placed on a slide. Team plan and above.');
87
+
73
88
  const slideShape = z
74
89
  .object({
75
90
  id: z.string().optional().describe('Unique slide id, e.g. "SLD-001". Auto-generated if omitted.'),
@@ -108,6 +123,10 @@ const slideShape = z
108
123
  .array(imageShape)
109
124
  .optional()
110
125
  .describe('Additional inserted/uploaded images placed on the slide (up to 50). Each should carry an alt caption.'),
126
+ videos: z
127
+ .array(videoShape)
128
+ .optional()
129
+ .describe('Videos placed on the slide (Team plan+, up to 20): {src (uploaded /attachments/{id}/raw URL, https video file, or YouTube/Vimeo link), title?, poster?, x?, y?, w?, h? in 1280x720 coords}.'),
111
130
  code: z.string().optional().describe('Code-first React slide source (max design freedom, advanced use)'),
112
131
  html: z.string().optional().describe('Rendered self-contained slide HTML (usually machine-produced, not hand-authored)'),
113
132
  notes: z.string().optional().describe('Speaker notes — text spine'),
package/lib/mcp.js CHANGED
@@ -24,6 +24,7 @@ import { registerAttachmentTools } from './mcp-tools/attachments.js';
24
24
  import { registerSignedDocTools } from './mcp-tools/signed.js';
25
25
  import { registerFeedbackTools } from './mcp-tools/feedback.js';
26
26
  import { registerMemoryTools } from './mcp-tools/memory.js';
27
+ import { registerDocMailTools } from './mcp-tools/doc-mail.js';
27
28
  import { withRateLimitSink } from './context.js';
28
29
  import { makeClient } from './mcp-tools/shared.js';
29
30
  import { t as tr } from './i18n.js';
@@ -224,6 +225,10 @@ export function buildMcpServer({ name, version }) {
224
225
  'rootr_request_signing_code first and ASK THE HUMAN for the 6 digits that arrive in their mail — ' +
225
226
  'that code is the only evidence a person (not an agent) pressed sign; ' +
226
227
  'FOLDER = grouping; LOG store = typed event table whose relation fields auto-build a lineage graph (metrics, incidents, pipeline events — use the rootr_*_log_* tools).\n\n' +
228
+ 'Sending a document to someone by email (문서 보내기 · 문서함): rootr_send_document mails a frozen COPY of any ' +
229
+ 'node (not a folder) to 1-20 addresses — no Rootr account needed to open it, and it lands in their inbox if ' +
230
+ 'that email has an account; rootr_list_doc_mail (box inbox|sent) / rootr_read_doc_mail / rootr_revoke_doc_mail ' +
231
+ 'cover the mailbox. Only send to addresses the user gave you.\n\n' +
227
232
  'Scaffolding discipline — treat "set up a workspace for X" exactly like "build me this app": you ask a ' +
228
233
  'few clarifying questions, write it, compile it, run the tests, leave a README, and report. The user ' +
229
234
  'should only have to type that one sentence; everything else finishes inside the conversation. Never ask ' +
@@ -286,6 +291,7 @@ export function buildMcpServer({ name, version }) {
286
291
  registerSignedDocTools(server);
287
292
  registerFeedbackTools(server);
288
293
  registerMemoryTools(server);
294
+ registerDocMailTools(server);
289
295
 
290
296
  return server;
291
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {