rootr-cli 0.24.0 → 0.26.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.
@@ -3,40 +3,44 @@ import { z } from 'zod';
3
3
  import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
4
4
 
5
5
  /**
6
- * DOC-MAIL — 문서 송수신(창업자 지시 2026-10-20).
6
+ * DOC-MAIL — 문서 송수신(창업자 지시 2026-10-20, 같은 날 정정 "문서자체를 보내란 뜻임").
7
7
  *
8
- * 어떤 타입이든(문서·표·시트·화이트보드·덱·폼·이슈·로그·CRM·녹음·페이지·서명문서) 노드 하나를
9
- * **보낼 때의 복사본**으로 특정 이메일에게 보낸다. 받은 문서는 받는 사람의 **이메일 주소**에 붙는다 —
10
- * 계정이 없어도 메일 링크로 열리고, 나중에 그 이메일로 가입하면 받은 문서함에 그대로 있다.
11
- * 보낸 뒤 원본을 고치거나 지워도 받은 문서는 그대로다(살아 있는 공유가 필요하면 권한/공개를 쓴다).
8
+ * 어떤 타입이든(문서·폴더·표·시트·화이트보드·덱·폼·이슈·로그·CRM·녹음·페이지·서명문서) 노드 하나를 특정 이메일에게
9
+ * **보낸다 = 원본 그 자체를 그 이메일에 열어 준다**(보기·댓글·편집). 복사본이 아니라서 보낸 사람이 고치면 받는 사람에게
10
+ * 바로 보이고, 편집을 받았으면 받는 사람이 고친 것이 원본에 남는다. 받은 문서는 받는 사람의 **이메일 주소**에 붙는다 —
11
+ * 계정이 없어도 메일 링크(받은 이메일을 넣어야 열림, 읽기 전용)로 열리고, 그 이메일로 가입하면 문서함에서 원본이 열린다.
12
12
  */
13
13
  export function registerDocMailTools(server) {
14
14
  server.registerTool(
15
15
  'rootr_send_document',
16
16
  {
17
- title: 'Send a Rootr node to email addresses',
17
+ title: 'Send a Rootr node to email addresses (give them access to the original)',
18
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.',
19
+ 'Send any Rootr node (document, folder, table, spreadsheet, whiteboard, deck, form, issues, log, CRM, recording, ' +
20
+ 'page, signed document) to 1–20 email addresses. This is NOT a copy: each address gets access to the ORIGINAL ' +
21
+ 'node (and everything under it, if it is a folder) at `access` = read (view, default) | comment | write (edit). ' +
22
+ 'They always see the latest version; with write their edits land in the original. People with a Rootr account ' +
23
+ 'on that (verified) email open it from their inbox (rootr_list_doc_mail) with that access, even if they are not ' +
24
+ 'members of your workspace — they never see your workspace tree, members or search. People without an account ' +
25
+ 'get an email link where they must type their email address to read it (read-only). ' +
26
+ 'Needs WRITE on the node as a workspace member (the same bar as sharing); you can give at most your own level, ' +
27
+ 'never manage. Re-sending to the same address updates access/message instead of adding a second grant. ' +
28
+ 'Withdraw with rootr_revoke_doc_mail; change access with rootr_set_doc_mail_access. ' +
29
+ 'The result lists each recipient with `emailed` (false = the email failed; the access still stands). ' +
30
+ 'Only send to addresses the user asked for — this emails real people and opens the document to them.',
28
31
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
29
32
  inputSchema: {
30
33
  node: z.string().describe(`보낼 노드 id${NODE_TARGET_HINT}`),
31
34
  to: z.array(z.string()).min(1).max(20).describe('받는 사람 이메일 주소들(1~20개). 대소문자만 다른 주소는 같은 사람'),
32
35
  message: z.string().max(2000).optional().describe('보내며 함께 적을 말(메일과 문서함에 보인다, 2000자까지)'),
36
+ access: z.enum(['read', 'comment', 'write']).optional().describe('받는 사람이 원본에서 할 수 있는 것: read=보기(기본) · comment=댓글 · write=편집. 내 권한까지만'),
33
37
  workspace: z.string().optional().describe('경로로 줄 때 찾을 워크스페이스 id (생략하면 설정값)'),
34
38
  },
35
39
  },
36
- async ({ node, to, message, workspace }) => {
40
+ async ({ node, to, message, access, workspace }) => {
37
41
  try {
38
42
  const client = makeClient(workspace);
39
- return jsonResult(await client.sendNode(await resolveNodeId(client, node), { to, message }));
43
+ return jsonResult(await client.sendNode(await resolveNodeId(client, node), { to, message, access }));
40
44
  } catch (err) {
41
45
  return errorResult(err);
42
46
  }
@@ -51,7 +55,9 @@ export function registerDocMailTools(server) {
51
55
  "List the caller's document mailbox: box=inbox (documents sent TO the caller's account email — default) or " +
52
56
  'box=sent (documents the caller sent). Summaries only (title, type, sender, recipient, message, sentAt, ' +
53
57
  '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.',
58
+ 'Page with `cursor` = the previous `nextCursor`. Belongs to the person (their email), not to a workspace. ' +
59
+ 'Each item has `access` (READ/COMMENT/WRITE), `original` (live | trashed | deleted) and, for live received ones, ' +
60
+ '`nodeId`/`workspaceId` of the original.',
55
61
  annotations: { readOnlyHint: true },
56
62
  inputSchema: {
57
63
  box: z.enum(['inbox', 'sent']).optional().describe('inbox=받은 문서(기본), sent=보낸 문서'),
@@ -74,18 +80,21 @@ export function registerDocMailTools(server) {
74
80
  {
75
81
  title: 'Read a received or sent Rootr document',
76
82
  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.',
83
+ 'Open one document from the mailbox (id from rootr_list_doc_mail). Returns the summary plus the CURRENT content ' +
84
+ 'of the original, read-only, in the same shape a public page uses: `contentMd` for documents, `payload.kind` for ' +
85
+ 'the rest (database → `properties` + `rows`; spreadsheet → `data`; …), `children` (items inside — pass one as ' +
86
+ '`node` to read it), `access` (what the recipient may do), `openUrl` (the original in the Rootr editor). ' +
87
+ 'Opening a received one marks it read. Withdrawn → `revoked` true and no content; original deleted → ' +
88
+ '`originalDeleted` true. This works with any of the recipient\'s keys, even one bound to another workspace.',
81
89
  annotations: { readOnlyHint: false },
82
90
  inputSchema: {
83
91
  id: z.string().describe('문서함 항목 id (rootr_list_doc_mail 의 items[].id)'),
92
+ node: z.string().optional().describe('폴더(부모)를 받았을 때 그 아래 노드 id (앞 응답의 children[].id)'),
84
93
  },
85
94
  },
86
- async ({ id }) => {
95
+ async ({ id, node }) => {
87
96
  try {
88
- return jsonResult(await makeClient().getDocMail(id));
97
+ return jsonResult(await makeClient().getDocMail(id, { node }));
89
98
  } catch (err) {
90
99
  return errorResult(err);
91
100
  }
@@ -97,9 +106,9 @@ export function registerDocMailTools(server) {
97
106
  {
98
107
  title: 'Withdraw a sent Rootr document',
99
108
  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.',
109
+ 'Withdraw a document the caller sent (id from rootr_list_doc_mail box=sent). The recipient loses access to the ' +
110
+ 'original immediately (open editors are disconnected), sees only the title and "withdrawn", and the email link ' +
111
+ 'answers 410. Only the sender can do this; it cannot be undone (send it again instead).',
103
112
  annotations: { readOnlyHint: false, destructiveHint: true },
104
113
  inputSchema: {
105
114
  id: z.string().describe('보낸 문서함 항목 id'),
@@ -113,4 +122,117 @@ export function registerDocMailTools(server) {
113
122
  }
114
123
  },
115
124
  );
125
+
126
+ server.registerTool(
127
+ 'rootr_set_doc_mail_access',
128
+ {
129
+ title: 'Change what the recipient of a sent Rootr document can do',
130
+ description:
131
+ 'Change the access of one recipient of a document the caller sent (id from rootr_list_doc_mail box=sent): ' +
132
+ 'read (view) | comment | write (edit). Only the sender; at most the sender\'s own level on the node. Lowering it ' +
133
+ 'takes effect immediately. No email is sent.',
134
+ annotations: { readOnlyHint: false, destructiveHint: false },
135
+ inputSchema: {
136
+ id: z.string().describe('보낸 문서함 항목 id'),
137
+ access: z.enum(['read', 'comment', 'write']).describe('read=보기 · comment=댓글 · write=편집'),
138
+ },
139
+ },
140
+ async ({ id, access }) => {
141
+ try {
142
+ return jsonResult(await makeClient().setDocMailAccess(id, access));
143
+ } catch (err) {
144
+ return errorResult(err);
145
+ }
146
+ },
147
+ );
148
+
149
+ // ── DOC-MAIL-HOOK — 사람 단위 웹훅 ──────────────────────────────────────
150
+ server.registerTool(
151
+ 'rootr_add_doc_mail_webhook',
152
+ {
153
+ title: 'Get a webhook when documents arrive for me',
154
+ description:
155
+ "Register a webhook on the caller's own account (not a workspace) for their document mailbox. Rootr POSTs JSON to " +
156
+ '`url` on doc_mail.received (a document was sent to the account\'s verified email), doc_mail.revoked (a received one ' +
157
+ 'was withdrawn) and doc_mail.read (a recipient opened one the caller sent). The body has id, title, nodeType, sender, ' +
158
+ 'message, access, apiUrl (GET it with your key for the content) and appUrl — never the document content. Each request ' +
159
+ 'is signed: X-Rootr-Signature: sha256=HMAC_SHA256(secret, raw body). The `secret` is returned ONCE, here — store it. ' +
160
+ 'Only public http(s) URLs (private/loopback addresses are refused). Max 5 per account.',
161
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
162
+ inputSchema: {
163
+ url: z.string().describe('받을 주소(공인 http/https)'),
164
+ events: z.array(z.enum(['doc_mail.received', 'doc_mail.revoked', 'doc_mail.read'])).optional().describe('받을 사건(안 주면 셋 다)'),
165
+ name: z.string().max(100).optional().describe('알아보기 쉬운 이름'),
166
+ },
167
+ },
168
+ async ({ url, events, name }) => {
169
+ try {
170
+ return jsonResult(await makeClient().addDocMailWebhook({ url, events, name }));
171
+ } catch (err) {
172
+ return errorResult(err);
173
+ }
174
+ },
175
+ );
176
+
177
+ server.registerTool(
178
+ 'rootr_list_doc_mail_webhooks',
179
+ {
180
+ title: "List my document webhooks",
181
+ description: "List the caller's document-mailbox webhooks (url, events, active, last delivery status). Secrets are not shown.",
182
+ annotations: { readOnlyHint: true },
183
+ inputSchema: {},
184
+ },
185
+ async () => {
186
+ try {
187
+ return jsonResult(await makeClient().listDocMailWebhooks());
188
+ } catch (err) {
189
+ return errorResult(err);
190
+ }
191
+ },
192
+ );
193
+
194
+ server.registerTool(
195
+ 'rootr_remove_doc_mail_webhook',
196
+ {
197
+ title: 'Remove one of my document webhooks',
198
+ description: 'Delete a document-mailbox webhook of the caller (id from rootr_list_doc_mail_webhooks). Its delivery log goes with it.',
199
+ annotations: { readOnlyHint: false, destructiveHint: true },
200
+ inputSchema: { id: z.string().describe('웹훅 id') },
201
+ },
202
+ async ({ id }) => {
203
+ try {
204
+ return jsonResult(await makeClient().removeDocMailWebhook(id));
205
+ } catch (err) {
206
+ return errorResult(err);
207
+ }
208
+ },
209
+ );
210
+
211
+ server.registerTool(
212
+ 'rootr_test_doc_mail_webhook',
213
+ {
214
+ title: 'Send a test event to one of my document webhooks',
215
+ description:
216
+ 'Queue a signed doc_mail.test delivery to the webhook (re-enables it if it was turned off), then report the latest ' +
217
+ 'deliveries a few seconds later (status, HTTP code, error).',
218
+ annotations: { readOnlyHint: false, openWorldHint: true },
219
+ inputSchema: { id: z.string().describe('웹훅 id') },
220
+ },
221
+ async ({ id }) => {
222
+ try {
223
+ const client = makeClient();
224
+ const queued = await client.testDocMailWebhook(id);
225
+ let deliveries = null;
226
+ for (let i = 0; i < 8; i++) {
227
+ await new Promise((r) => setTimeout(r, 750));
228
+ deliveries = await client.docMailWebhookDeliveries(id);
229
+ const d = deliveries?.items?.find((x) => x.id === queued.deliveryId);
230
+ if (d && d.status !== 'PENDING') break;
231
+ }
232
+ return jsonResult({ ...queued, deliveries: deliveries?.items?.slice(0, 5) ?? [] });
233
+ } catch (err) {
234
+ return errorResult(err);
235
+ }
236
+ },
237
+ );
116
238
  }
@@ -26,7 +26,11 @@ import {
26
26
  const BLOCK_GUIDE =
27
27
  'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
28
28
  'You assemble a page from these block types:\n' +
29
- '· hero {title, subtitle?, align?:"left"|"center", size?:"normal"|"screen", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner. ' +
29
+ '· hero {title, subtitle?, align?:"left"|"center", size?:"normal"|"screen", background?:{kind:"color",color}|{kind:"image",attachmentId}|{kind:"video",attachmentId,posterAttachmentId?,portraitAttachmentId?,portraitPosterAttachmentId?}, button?:{label,target}} — the opening banner. ' +
30
+ 'background kind:"video" = a looping MOTION-GRAPHIC background (autoplay, muted, loop, inline, no controls; same dark shade and white text as an image; ' +
31
+ 'covers the full-screen hero too). posterAttachmentId (an image) is shown while loading and INSTEAD of the video for visitors who ask for reduced motion or whose browser blocks autoplay — ' +
32
+ 'make the poster the video\'s first frame. portraitAttachmentId (+ portraitPosterAttachmentId) is an optional PHONE/PORTRAIT cut (e.g. 1080×1920): ' +
33
+ 'a portrait screen plays it and a landscape screen plays the default one — only ONE file is downloaded. Without it, phones crop the middle strip of a 16:9 video. ' +
30
34
  'size:"screen" makes it a FULL-SCREEN landing hero: edge to edge and one viewport tall (minus the top name bar, capped at 1200px), big headline, content centred vertically ' +
31
35
  '(default "normal" = a compact banner inside the text column)\n' +
32
36
  '· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
@@ -57,6 +61,14 @@ const BLOCK_GUIDE =
57
61
  'needs on a short phone and nothing is clipped. Inside, take the frame height with `height:100%` (html and body already are 100%) ' +
58
62
  'and centre the content with flexbox. bleed:true + minHeight:"screen" = a full-screen section; stack several for a ' +
59
63
  'section-per-screen landing page.\n' +
64
+ '· video {attachmentId, posterAttachmentId?, caption?, aspect?:"16:9"|"9:16"|"1:1"|"4:5" (default "16:9"), bleed?:boolean} — a MOTION-GRAPHIC clip ' +
65
+ '(always autoplay, muted, loop, playsinline, no controls — it is a moving picture, not a talk with sound). bleed:true spans the full page width. ' +
66
+ 'Same poster / reduced-motion rule as the hero video background.\n' +
67
+ 'VIDEOS (hero video background, video block): upload the file first — POST /v1/attachments/upload?workspaceId=<ws>&nodeId=<page>&filename=a.mp4&mimeType=video/mp4 ' +
68
+ 'with the raw bytes as the body and Content-Type application/octet-stream (H.264 MP4 with +faststart plays everywhere incl. iPhone). ' +
69
+ 'The attachment must belong to the page\'s workspace (else 400 PAGE_MEDIA_INVALID; a poster must be an image). Videos on pages need the TEAM plan ' +
70
+ '(Free → 403 PLAN_UPGRADE_REQUIRED, same as slide videos). Read responses carry a short-lived `vt` playback signature on each video spot; ' +
71
+ 'the bare /raw address of a video answers 403 (hotlink protection) — never store `vt`, it is dropped on save.\n' +
60
72
  'LAYOUT: any block takes `width:"full"|"twoThirds"|"half"|"third"` — neighbouring blocks sit side by side (phones stack them). ' +
61
73
  'That is how a page becomes a DASHBOARD: a row of `chart:"kpi"` blocks at width "third", then charts at width "half". ' +
62
74
  'A hero with size:"screen" and a custom block with bleed:true always take the whole row — `width` is ignored (not stored) on them.\n' +
@@ -90,10 +102,15 @@ const BLOCK_GUIDE =
90
102
  'saved; it is never stored as a silently empty block. `column`/`labelColumn`/' +
91
103
  '`valueColumn`/`orderBy.column` are the column NAMES as shown in the table (not column ids). A table or form that ' +
92
104
  'sits in a PRIVATE subtree is silently skipped on the published page — that is deliberate, never a bug to work around.\n' +
93
- 'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only) or {kind:"node", nodeId} ' +
94
- 'for a document in the same workspace. Internal path strings are rejected. A node link renders as the in-app ' +
105
+ 'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only), {kind:"node", nodeId} ' +
106
+ 'for a document in the same workspace, or {kind:"block", blockId} to JUMP TO ANOTHER BLOCK ON THIS SAME PAGE ' +
107
+ '(e.g. a hero button "Book a free consult" → the booking block further down: smooth scroll in the same tab, no new tab — ' +
108
+ 'use this for landing pages opened in Instagram/Facebook in-app browsers). blockId must be the id of a block on this page ' +
109
+ '(else 400) — when you create the page, give the target block an explicit `id` (e.g. "booking") so you can point at it. ' +
110
+ 'Internal path strings are rejected. A url link opens in a new tab; a node link renders as the in-app ' +
95
111
  'document for members, as the public address on the published page, and is hidden entirely if that document is ' +
96
- 'not published — so link to nodes rather than pasting addresses.\n' +
112
+ 'not published — so link to nodes rather than pasting addresses. Inside a text block\'s markdown, `[label](#<blockId>)` ' +
113
+ 'is a same-page link to that block.\n' +
97
114
  'CONFIG: {theme, accentColor?, navEnabled?, metaPixelId?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
98
115
  'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent.' +
99
116
  ' 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. ' +
@@ -109,7 +126,8 @@ export function registerPageTools(server) {
109
126
  title: 'Create a Rootr page (block-assembled web page)',
110
127
  description:
111
128
  'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
112
- 'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
129
+ 'can then be published to the web at a public address (see rootr_publish_node — web publishing of pages needs ' +
130
+ 'the Team plan; on Free the page still works inside the workspace). Use this when someone asks ' +
113
131
  'for a DASHBOARD / report / KPI board over their tables, spreadsheets or logs (46 live chart kinds, numbers stay ' +
114
132
  'current), or for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
115
133
  'Omit `blocks` to start from a hero/text/footer skeleton. ' +
@@ -29,9 +29,13 @@ export function registerPublicTools(server) {
29
29
  description:
30
30
  'Publish ANY Rootr (루터) node at a public read-only URL (rootr.io/p/{slug}) — a DATABASE (visitors get a ' +
31
31
  'searchable table and can clone it into their own workspace), SPREADSHEET, WHITEBOARD, FORM (questions only), ' +
32
- 'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED or DOCUMENT. Use this instead of ' +
32
+ 'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED, DOCUMENT or PAGE. Use this instead of ' +
33
33
  'rootr_publish_document unless you specifically want the document-only extras (skins, edit proposals). ' +
34
34
  'FOLDER cannot be published. ' +
35
+ 'PAGE (the block-assembled web page) can be published only on the Team plan and up — a Free workspace gets ' +
36
+ '403 PLAN_UPGRADE_REQUIRED (nothing is changed; tell the user to upgrade the workspace to Team). Every other type ' +
37
+ 'publishes on Free as before. With `includeChildren` on Free, PAGE nodes underneath are skipped and the response ' +
38
+ 'says how many (`skippedPagesRequireTeam`). ' +
35
39
  'Set `includeChildren: true` to publish everything underneath as well — the WHOLE subtree, grandchildren ' +
36
40
  'included, each getting its own URL and listed on the parent page. Leave it off and only this one node goes ' +
37
41
  'out (its sub-page names do not even appear). Turning it back off, closing the parent, moving a child out, ' +
@@ -90,6 +94,7 @@ export function registerPublicTools(server) {
90
94
  'PLAN_UPGRADE_REQUIRED; a password-locked page is always hidden and cannot be re-exposed until unlocked); ' +
91
95
  '`{"design": {"layout": "board", "skin": "paper"}}` changes how a published TABLE looks — it does not ' +
92
96
  'have to be a table (cards, gallery, list, board, timeline, calendar, chart, ranking). ' +
97
+ '`{"isPublic": true}` on a PAGE needs the Team plan (Free → 403 PLAN_UPGRADE_REQUIRED); closing it works on any plan. ' +
93
98
  'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
94
99
  'edit proposals) on the older path. Owner-level action (MANAGE): use an account key (PAT).',
95
100
  annotations: { readOnlyHint: false, destructiveHint: false },
@@ -132,6 +137,8 @@ export function registerPublicTools(server) {
132
137
  '`publishable: false` means this type cannot be published at all (FOLDER). ' +
133
138
  '`closed: "PUBLIC_OWN_ROW_PAGE"` (with `isPublic: false` and a `closedReason`) means publishing is switched on but the ' +
134
139
  'address is closed right now — do NOT hand that URL out; tell the user why and how to reopen it. ' +
140
+ '`closed: "PAGE_PUBLISH_REQUIRES_TEAM"` means a PAGE was published but its workspace is on the Free plan, so ' +
141
+ 'visitors see a "not published" screen — upgrading to Team reopens the same URL without publishing again. ' +
135
142
  'Call this before publishing so you do not tell the user a page is new when it already had a URL.',
136
143
  annotations: { readOnlyHint: true, destructiveHint: false },
137
144
  inputSchema: {
@@ -42,10 +42,18 @@ const ACCESS_HINT =
42
42
  '`searchExcluded: true` (values stay out of the search index behind ask / AI explore / similar rows — not a read permission) and a `type: "vector"` ' +
43
43
  'property needs `dimensions` (an embedding column agents fill with rootr_set_row_vectors). ' +
44
44
  'A phone/email/name property may carry `dedupe: true` (warn when the same person is entered again) with ' +
45
- '`dedupeShow: ["진행상태","특이사항","콜 이력"]` (fields of the existing row the warning shows — a relation shows its latest 5 rows); ' +
45
+ '`dedupeShow: ["상태","메모","통화 기록"]` (fields of the existing row the warning shows — a relation shows its latest 5 rows) and ' +
46
+ '`dedupeCarry: { label: "재등록", auto: true, exclude: ["접수일"], mark: { "재등록 여부": true, "구분": { map: { "A": "A(재등록)" }, else: "재등록" } } }` ' +
47
+ '(same entry came back: copy the earlier row into the new one — see rootr_update_column); any property may carry ' +
48
+ '`validate: { kind: "pattern"|"range"|"date"|"birthdate", pattern?, flags?, negate?, min?, max?, message? }` (flags a wrong value on every new write, never blocks); ' +
46
49
  'a relation property may carry `relationFilter: [{ propId, op: "is"|"is_not_empty", values? }]` (which linked rows may be picked). ' +
50
+ 'The title property may carry `identifyProps: [{ propId: "연락처", mask: "last4" }, { propId: "지원일" }]` (shown next to the name ' +
51
+ 'wherever another table picks these rows, so same-name people can be told apart). ' +
47
52
  'A board view config may carry `cardProps: ["면접예정일","연락처"]` (fields on each card, rollups too) and any view `rowColors` ' +
48
53
  '(`[{ propId, op: "is"|"is_not_empty", values?, color }]`). ' +
54
+ 'Any property may carry `align: "left"|"center"|"right"` and `wrap: true`; a date property `highlightOverdue: true` ' +
55
+ '(past dates red — off by default), `allowTime: false` (no time) and `allowRange: false` (no end date). A table view ' +
56
+ 'config may carry `density: "compact"` and `frozenColumns: 1` (first N visible columns fixed while scrolling sideways). ' +
49
57
  'A DOCUMENT\'s `content` may embed a live ' +
50
58
  'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
51
59
  'linked to the real node after the whole tree is built (an unknown name rolls the whole tree back).';
package/lib/mcp.js CHANGED
@@ -25,7 +25,7 @@ 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
27
  import { registerDocMailTools } from './mcp-tools/doc-mail.js';
28
- import { withRateLimitSink } from './context.js';
28
+ import { withDocMailSink, withRateLimitSink } from './context.js';
29
29
  import { makeClient } from './mcp-tools/shared.js';
30
30
  import { t as tr } from './i18n.js';
31
31
 
@@ -77,6 +77,39 @@ function withQuotaWarnings(server) {
77
77
  }
78
78
 
79
79
 
80
+ /**
81
+ * DOC-MAIL-NOTIFY — 어떤 루터 도구를 부르든, 아직 알리지 않은 **새 받은 문서**가 있으면 응답 끝에 한 줄을 붙인다(창업자 지시
82
+ * 2026-10-20 — 클라이언트와 상관없이 루터를 쓰는 순간 알게). 도구가 부른 API 응답의 머리줄(`X-Rootr-Doc-Mail-New-Items`)을
83
+ * 그릇에 모아 쓴다 — 따로 묻는 호출이 없다. 서버가 키마다·채널(mcp)마다 한 번만 알려 준다(같은 문서를 두 번 알리지 않는다).
84
+ * 문서함 도구 자신(`*doc_mail*`)의 응답에는 붙이지 않는다 — 이미 문서함을 보고 있다.
85
+ */
86
+ function docMailNotice(items) {
87
+ if (!items?.length) return null;
88
+ const shown = items.slice(0, 5);
89
+ const list = shown.map((i) => tr(`「${i.title}」 — ${i.sender} (id: ${i.id})`, `"${i.title}" from ${i.sender} (id: ${i.id})`)).join(', ');
90
+ const more = items.length > shown.length ? tr(` 외 ${items.length - shown.length}건`, ` and ${items.length - shown.length} more`) : '';
91
+ return tr(
92
+ `\n\n📬 새로 받은 문서 (${items.length}): ${list}${more} — rootr_read_doc_mail {id} 로 읽으세요. 사용자에게 알려 주세요.`,
93
+ `\n\n📬 New documents received (${items.length}): ${list}${more} — read with rootr_read_doc_mail {id}. Tell the user.`,
94
+ );
95
+ }
96
+
97
+ function withDocMailNotices(server) {
98
+ const original = server.registerTool.bind(server);
99
+ server.registerTool = (name, spec, handler) =>
100
+ original(name, spec, async (...args) => {
101
+ const sink = { channel: 'mcp', items: [] };
102
+ const result = await withDocMailSink(sink, () => handler(...args));
103
+ if (/doc_mail/.test(name)) return result;
104
+ const notice = docMailNotice(sink.items);
105
+ if (!notice || !result || !Array.isArray(result.content)) return result;
106
+ const last = [...result.content].reverse().find((c) => c && c.type === 'text');
107
+ if (!last) return { ...result, content: [...result.content, { type: 'text', text: notice.trim() }] };
108
+ return { ...result, content: result.content.map((c) => (c === last ? { ...c, text: `${c.text}${notice}` } : c)) };
109
+ });
110
+ return server;
111
+ }
112
+
80
113
  /**
81
114
  * MEM-001 — **기억을 눈앞에 놓는다. 이 기능의 성패가 여기 달렸다.**
82
115
  *
@@ -225,10 +258,14 @@ export function buildMcpServer({ name, version }) {
225
258
  'rootr_request_signing_code first and ASK THE HUMAN for the 6 digits that arrive in their mail — ' +
226
259
  'that code is the only evidence a person (not an agent) pressed sign; ' +
227
260
  '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' +
261
+ 'Sending a document to someone by email (문서 보내기 · 문서함): rootr_send_document gives 1-20 email addresses ' +
262
+ 'access to the ORIGINAL node (not a copy) at access read|comment|write — they see live edits, and with write ' +
263
+ 'their edits land in the original; an account on that email opens it from its inbox, others read it via the ' +
264
+ 'email link after typing their address; rootr_list_doc_mail (box inbox|sent) / rootr_read_doc_mail / ' +
265
+ 'rootr_set_doc_mail_access / rootr_revoke_doc_mail cover the mailbox. Only send to addresses the user gave you. ' +
266
+ 'When any tool result ends with "📬 New documents received", tell the user right away (title and sender) and offer to ' +
267
+ 'open them with rootr_read_doc_mail. rootr_add_doc_mail_webhook makes Rootr POST doc_mail.received / revoked / read ' +
268
+ 'to the user\'s own URL (for their automations).\n\n' +
232
269
  'Scaffolding discipline — treat "set up a workspace for X" exactly like "build me this app": you ask a ' +
233
270
  'few clarifying questions, write it, compile it, run the tests, leave a README, and report. The user ' +
234
271
  'should only have to type that one sentence; everything else finishes inside the conversation. Never ask ' +
@@ -268,6 +305,7 @@ export function buildMcpServer({ name, version }) {
268
305
  // 한도 경고가 그 뒤에 붙는다 — 경고는 언제나 맨 끝에 있어야 눈에 띈다.
269
306
  withMemoryRecall(server);
270
307
  withQuotaWarnings(server);
308
+ withDocMailNotices(server);
271
309
 
272
310
  registerDocumentTools(server);
273
311
  registerTemplateTools(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.24.0",
3
+ "version": "0.26.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": {