rootr-cli 0.17.3 → 0.19.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.
@@ -1,8 +1,101 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  import { RootrApiError } from '../client.js';
4
+ import { t as tr } from '../i18n.js';
4
5
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../resolve.js';
5
- import { textResult, jsonResult, errorResult, makeClient, NODE_TARGET_HINT, NODE_URL_HINT } from './shared.js';
6
+ import { textResult, jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT } from './shared.js';
7
+
8
+ /**
9
+ * ISSUE-105 — 쓰기 응답의 `warnings` 를 도구 결과 글 끝에 붙인다. 서버는 HTML 주석(`<!-- … -->`)을 저장하면서 떨어뜨리는데
10
+ * 예전에는 200 만 돌려줘, 주석을 삽입 지점으로 쓰려던 에이전트가 다음 호출에서야(그것도 "다시 읽어라"로) 알았다.
11
+ */
12
+ function withWarnings(text, result) {
13
+ const warnings = result && Array.isArray(result.warnings) ? result.warnings.filter((w) => typeof w === 'string') : [];
14
+ return warnings.length ? `${text}\n${warnings.map((w) => `warning: ${w}`).join('\n')}` : text;
15
+ }
16
+
17
+ /** EMBED-API — 쓰기 도구 본문에서 표를 끼워 넣는 표기. 모델이 읽는 곳에 한 번씩 적는다. */
18
+ const EMBED_SYNTAX_HINT =
19
+ ' To show a LIVE table/spreadsheet/deck/board from this workspace inside the document, put `[[embed:/Path/To/Node]]` ' +
20
+ '(or `[[embed:<node id>]]`) alone on its own line — the editor renders the current rows, not a copy. Paths are ' +
21
+ 'converted to ids when saved; an unknown path is rejected with 400. See rootr_embed_node.';
22
+
23
+ /** 문서가 아닌 노드를 읽는 도구 — rootr_read 가 "Document not found" 대신 갈 곳을 말해 준다. */
24
+ const READ_TOOL_BY_TYPE = {
25
+ PRESENTATION: 'rootr_read_presentation',
26
+ DATABASE: 'rootr_read_database',
27
+ SPREADSHEET: 'rootr_read_spreadsheet',
28
+ WHITEBOARD: 'rootr_read_whiteboard',
29
+ FORM: 'rootr_read_form',
30
+ PAGE: 'rootr_read_page',
31
+ RECORDING: 'rootr_read_recording',
32
+ SIGNED: 'rootr_read_signed_document',
33
+ LOG: 'rootr_query_log_entries',
34
+ ISSUE_TRACKER: 'rootr_list_issues',
35
+ FOLDER: 'rootr_list',
36
+ };
37
+
38
+ /**
39
+ * ISSUE-127 — 주소·경로가 문서가 아닌 노드(덱·표 등)로 풀렸을 때 `/documents/:id` 는 "Document not found" 라고만 답한다.
40
+ * 공개 주소를 받은 에이전트에게는 "없다"로 읽히므로, 무엇이고 어느 도구로 읽는지를 말한다. 문서 조회가 실제로 404 일 때만.
41
+ */
42
+ function notADocumentError(err, resolved, target) {
43
+ const type = resolved?.node?.type;
44
+ if (!(err instanceof RootrApiError) || err.status !== 404 || !type || type === 'DOCUMENT') return null;
45
+ const tool = READ_TOOL_BY_TYPE[type];
46
+ return new RootrApiError(
47
+ tr(`이 대상은 문서가 아니라 ${type} 노드입니다: ${target}`, `This target is a ${type} node, not a document: ${target}`),
48
+ {
49
+ status: 404,
50
+ hint: tool
51
+ ? tr(`같은 대상을 ${tool} 에 넘기세요 (노드 id: ${resolved.id}).`, `Pass the same target to ${tool} (node id: ${resolved.id}).`)
52
+ : tr(`노드 id: ${resolved.id}`, `Node id: ${resolved.id}`),
53
+ },
54
+ );
55
+ }
56
+
57
+ /**
58
+ * ISSUE-127 — 남의 워크스페이스의 공개 문서. 주소를 받은 누구나 브라우저로 읽는 **공개 발행본**을 그대로 돌려준다.
59
+ * - 비밀번호가 걸린 문서는 "잠겨 있다"고만 답한다. 통과 수단(비밀번호·통과증 인자)은 넣지 않는다.
60
+ * - etag·노드 id 는 주지 않는다 — 고칠 수 있는 문서가 아니다(rootr_edit/write/append 는 이 되받기를 타지 않는다).
61
+ */
62
+ async function readPublishedCopy(client, slug, target) {
63
+ let pub;
64
+ try {
65
+ pub = await client.getPublicDocument(slug);
66
+ } catch (err) {
67
+ if (err instanceof RootrApiError && err.status === 401 && err.body && err.body.code === 'PUBLIC_DOC_PASSWORD') {
68
+ throw new RootrApiError(
69
+ tr(`이 공개 문서는 비밀번호로 잠겨 있어 읽을 수 없습니다: ${target}`, `This public document is password-locked and cannot be read: ${target}`),
70
+ {
71
+ status: 401,
72
+ hint: tr(
73
+ '이 도구로는 비밀번호를 넣을 수 없습니다. 브라우저에서 열거나, 문서가 있는 워크스페이스의 접근 권한을 받으세요.',
74
+ 'This tool cannot enter the password. Open it in a browser, or get access to the workspace the document lives in.',
75
+ ),
76
+ },
77
+ );
78
+ }
79
+ if (err instanceof RootrApiError && err.status === 404) requireResolved({ existed: false, target });
80
+ throw err;
81
+ }
82
+ if (!pub || typeof pub.content !== 'string') requireResolved({ existed: false, target });
83
+ const published = pub.publishedAt ? String(pub.publishedAt) : 'n/a';
84
+ return {
85
+ content: [
86
+ { type: 'text', text: pub.content },
87
+ {
88
+ type: 'text',
89
+ text: tr(
90
+ `공개 발행본 · 읽기 전용 — 연결된 워크스페이스에서 찾지 못해 공개 주소가 보여 주는 판을 읽었습니다. 제목: ${pub.name ?? ''} · 발행: ${published}\n` +
91
+ '발행 뒤에 고친 내용은 여기 없습니다. etag 를 주지 않습니다 — rootr_edit / rootr_write / rootr_append 로 고칠 수 없는 문서입니다.',
92
+ `Published public copy · read-only — not found in the connected workspace, so this is the version the public URL shows. Title: ${pub.name ?? ''} · published: ${published}\n` +
93
+ 'Edits made after publishing are not included. No etag is given — rootr_edit / rootr_write / rootr_append cannot change this document.',
94
+ ),
95
+ },
96
+ ],
97
+ };
98
+ }
6
99
 
7
100
  /** Registers the original 6 document tools: list/read/append/edit/write/search. */
8
101
  export function registerDocumentTools(server) {
@@ -57,7 +150,15 @@ export function registerDocumentTools(server) {
57
150
  title: 'Read a Rootr document',
58
151
  description:
59
152
  'Read the full markdown content of a Rootr (루터) document by path (starting with "/") or by node id. ' +
60
- 'Read a document before editing or appending to it so you know its current structure.',
153
+ 'Read a document before editing or appending to it so you know its current structure. The response\'s ' +
154
+ 'trailing "etag" line is the current ETag — pass it as ifMatch to rootr_write to guard against clobbering ' +
155
+ 'concurrent changes. Note: a successful rootr_edit can reformat the WHOLE document (e.g. normalizing "-" ' +
156
+ 'list markers to "*", or table cell spacing), not just the replaced snippet — re-read before another ' +
157
+ 'rootr_edit on the same document rather than reusing text copied from an earlier read. ' +
158
+ 'A published public URL (rootr.io/p/{slug}) also works: a document of the connected workspace reads normally; ' +
159
+ 'one from ANOTHER workspace comes back as its published copy, marked read-only with no etag (it cannot be ' +
160
+ 'edited from here), and a password-locked one is refused. Reading a public URL counts as one read against ' +
161
+ 'the plan\'s read limit (Free: 180/day), exactly like reading your own document.',
61
162
  annotations: { readOnlyHint: true, destructiveHint: false },
62
163
  inputSchema: {
63
164
  target: z.string().describe('Document path (e.g. "/notes/todo.md") or node id, or a full rootr.io URL'),
@@ -67,9 +168,22 @@ export function registerDocumentTools(server) {
67
168
  try {
68
169
  const client = makeClient();
69
170
  const resolved = await resolveTarget(client, target);
171
+ // ISSUE-127 — 이 워크스페이스에서 못 찾은 공개 주소(/p/{slug})는 공개 발행본으로 되받는다. 읽기 도구인 여기서만.
172
+ if (!resolved.existed && resolved.publicSlug) return await readPublishedCopy(client, resolved.publicSlug, target);
70
173
  const id = requireResolved(resolved);
71
- const { content } = await client.getDocumentMarkdown(id);
72
- return textResult(content);
174
+ let doc;
175
+ try {
176
+ doc = await client.getDocumentMarkdown(id);
177
+ } catch (err) {
178
+ throw notADocumentError(err, resolved, target) ?? err;
179
+ }
180
+ const { content, etag } = doc;
181
+ return {
182
+ content: [
183
+ { type: 'text', text: content },
184
+ { type: 'text', text: `etag: ${etag || 'n/a'}` },
185
+ ],
186
+ };
73
187
  } catch (err) {
74
188
  return errorResult(err);
75
189
  }
@@ -87,7 +201,7 @@ export function registerDocumentTools(server) {
87
201
  annotations: { readOnlyHint: false, destructiveHint: false },
88
202
  inputSchema: {
89
203
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
90
- content: z.string().describe('Markdown content to append'),
204
+ content: z.string().describe('Markdown content to append.' + EMBED_SYNTAX_HINT),
91
205
  underHeading: z
92
206
  .string()
93
207
  .optional()
@@ -103,7 +217,43 @@ export function registerDocumentTools(server) {
103
217
  const resolved = await resolveTarget(client, target);
104
218
  const id = requireResolved(resolved);
105
219
  const result = await client.appendDocument(id, { content, underHeading });
106
- return textResult(`appended (etag ${result.etag || 'n/a'})`);
220
+ return textResult(withWarnings(`appended (etag ${result.etag || 'n/a'})`, result));
221
+ } catch (err) {
222
+ return errorResult(err);
223
+ }
224
+ },
225
+ );
226
+
227
+ server.registerTool(
228
+ 'rootr_embed_node',
229
+ {
230
+ title: 'Embed a table (or any node) inside a Rootr document',
231
+ description:
232
+ 'Put a LIVE view of another node — a database/table, spreadsheet, presentation, whiteboard, form, recording, ' +
233
+ 'log store or issue tracker — inside a document, like the editor\'s "Embed" block (Notion\'s linked database). ' +
234
+ 'The document shows the node\'s current content, not a copy, so edits to the table appear in the document. ' +
235
+ 'Appends at the end of the document, or at the end of a heading\'s section. Same result as writing ' +
236
+ '`[[embed:/Path/To/Node]]` on its own line with rootr_append/rootr_write.',
237
+ annotations: { readOnlyHint: false, destructiveHint: false },
238
+ inputSchema: {
239
+ target: z.string().describe('Document to embed into — path (starting with "/"), node id, or a full rootr.io URL'),
240
+ node: z.string().describe('Node to embed: its id' + NODE_TARGET_HINT),
241
+ underHeading: z
242
+ .string()
243
+ .optional()
244
+ .describe('Heading to put it under, e.g. "## Data". Omit to add at the end. A missing heading is created at the end.'),
245
+ },
246
+ },
247
+ async ({ target, node, underHeading }) => {
248
+ try {
249
+ const client = makeClient();
250
+ const docId = requireResolved(await resolveTarget(client, target));
251
+ const nodeId = await resolveNodeId(client, node);
252
+ const res = await client.embedNode(docId, { node: nodeId, underHeading });
253
+ return textResult(
254
+ `embedded ${res.embedded} into the document as \`${res.marker}\` (etag ${res.etag || 'n/a'}). ` +
255
+ 'It renders as a live view in the editor.',
256
+ );
107
257
  } catch (err) {
108
258
  return errorResult(err);
109
259
  }
@@ -118,7 +268,12 @@ export function registerDocumentTools(server) {
118
268
  'Replace an exact snippet of text ("find") with new text ("replace") inside a Rootr document. ' +
119
269
  'Fails with a clear error if the snippet is missing or ambiguous (not unique), so read the document ' +
120
270
  'first to copy the exact text to find. Good for precise, targeted edits; use rootr_append instead when ' +
121
- 'you are only adding new content.',
271
+ 'you are only adding new content. A successful edit can reformat the WHOLE document as a side effect ' +
272
+ '(list markers and table spacing get normalized), so if you plan another rootr_edit right after this ' +
273
+ 'one, rootr_read the document again first instead of reusing text from before this edit. ' +
274
+ 'HTML comments (<!-- ... -->) are NOT kept when a document is saved, so do not use one as an insertion ' +
275
+ 'marker — the result carries a "warning:" line when that happens; to keep adding at a fixed place use ' +
276
+ 'rootr_append with underHeading.',
122
277
  annotations: { readOnlyHint: false, destructiveHint: false },
123
278
  inputSchema: {
124
279
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
@@ -132,8 +287,8 @@ export function registerDocumentTools(server) {
132
287
  const client = makeClient();
133
288
  const resolved = await resolveTarget(client, target);
134
289
  const id = requireResolved(resolved);
135
- await client.patchDocumentAnchor(id, { find, replace, replaceAll });
136
- return textResult('edited');
290
+ const result = await client.patchDocumentAnchor(id, { find, replace, replaceAll });
291
+ return textResult(withWarnings('edited', result));
137
292
  } catch (err) {
138
293
  return errorResult(err);
139
294
  }
@@ -152,7 +307,7 @@ export function registerDocumentTools(server) {
152
307
  annotations: { readOnlyHint: false, destructiveHint: true },
153
308
  inputSchema: {
154
309
  target: z.string().describe('Document path (starting with "/", created if missing) or node id, or a full rootr.io URL'),
155
- content: z.string().describe('Full new markdown content for the document'),
310
+ content: z.string().describe('Full new markdown content for the document.' + EMBED_SYNTAX_HINT),
156
311
  ifMatch: z
157
312
  .string()
158
313
  .optional()
@@ -164,6 +319,8 @@ export function registerDocumentTools(server) {
164
319
  const client = makeClient();
165
320
  const resolved = await resolveTarget(client, target);
166
321
  if (!resolved.existed) {
322
+ // ISSUE-127 — 만들 경로가 없는 대상(못 찾은 공개 주소 등)은 새 문서로 만들지 않는다(CLI `rootr write` 와 같은 답).
323
+ if (!resolved.path) requireResolved(resolved);
167
324
  if (!resolved.path) throw new RootrApiError(`대상을 찾을 수 없습니다: ${target}`);
168
325
  // creating with content IS the write — a follow-up PUT would just
169
326
  // double the version history and extraction work
@@ -176,10 +333,13 @@ export function registerDocumentTools(server) {
176
333
  });
177
334
  const id = created.id || (created.node && created.node.id);
178
335
  const url = created.url || (created.node && created.node.url);
179
- return textResult(`created ${resolved.path} (id ${id})${url ? ` — ${url}` : ''}`);
336
+ // 서버가 실제로 둔 경로를 말한다 — "본인 행만" 표 바로 밑처럼 이름에 꼬리가 붙는 자리에서는 요청한 경로와 다르다.
337
+ // 꼬리 붙은 이름은 그 이름 그대로 적어야 다시 닿으므로(경로 풀이는 정확한 이름만 본다), 요청한 경로를 되읊으면 다음 쓰기가 또 새 문서를 만든다.
338
+ const at = created.path || (created.node && created.node.path) || resolved.path;
339
+ return textResult(`created ${at} (id ${id})${url ? ` — ${url}` : ''}`);
180
340
  }
181
- const { etag } = await client.putDocument(resolved.id, content, { ifMatch });
182
- return textResult(`wrote (etag ${etag || 'n/a'})`);
341
+ const written = await client.putDocument(resolved.id, content, { ifMatch });
342
+ return textResult(withWarnings(`wrote (etag ${written.etag || 'n/a'})`, written));
183
343
  } catch (err) {
184
344
  return errorResult(err);
185
345
  }
@@ -230,7 +390,9 @@ export function registerDocumentTools(server) {
230
390
  'Mentioning someone with @[userId] notifies them, and their notification opens that row and ' +
231
391
  'highlights this comment. ' +
232
392
  'Comments carry a user identity: the comment is attributed to the person who owns the key ' +
233
- '(account keys and workspace keys both carry an owner). A key with no owner cannot comment.',
393
+ '(account keys and workspace keys both carry an owner). A key with no owner cannot comment. ' +
394
+ 'On an "own rows only" table you can only comment on rows you can see — another person\'s row answers not found. ' +
395
+ 'A reply (parentId) always joins its parent\'s thread: without blockId it inherits the parent\'s blockId, so a reply to a row comment is that row\'s comment too.',
234
396
  annotations: { readOnlyHint: false, destructiveHint: false },
235
397
  inputSchema: {
236
398
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
@@ -260,7 +422,8 @@ export function registerDocumentTools(server) {
260
422
  description:
261
423
  'List the comment threads on a Rootr (루터) document, optionally filtered to one block. ' +
262
424
  'DB-ROW-COMMENT — for the comments on ONE ROW of a table, pass the DATABASE as `target` and the ' +
263
- 'ROW id as `blockId`. Works with any key.',
425
+ 'ROW id as `blockId`. Works with any key. On an "own rows only" table you only get the threads of rows you can see ' +
426
+ '(the row author and table managers) — other rows\' threads are simply absent.',
264
427
  annotations: { readOnlyHint: true, destructiveHint: false },
265
428
  inputSchema: {
266
429
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
@@ -487,7 +650,11 @@ export function registerDocumentTools(server) {
487
650
  'rootr_document_public_status',
488
651
  {
489
652
  title: 'Public status of a Rootr document',
490
- description: 'Whether a Rootr (루터) document is published, and at which public slug/URL.',
653
+ description:
654
+ 'Whether a Rootr (루터) document is published, and at which public slug/URL. ' +
655
+ '`closed: "PUBLIC_OWN_ROW_PAGE"` (with `isPublic: false` and a `closedReason`) means publishing is switched on but the ' +
656
+ 'address is closed right now (the document came from a row of an "own rows only" table) — do NOT hand that URL out; ' +
657
+ 'tell the user why and how to reopen it.',
491
658
  annotations: { readOnlyHint: true, destructiveHint: false },
492
659
  inputSchema: {
493
660
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
@@ -338,7 +338,9 @@ export function registerFormTools(server) {
338
338
  'rootr_list_form_share_links',
339
339
  {
340
340
  title: 'List a Rootr form\'s public share links',
341
- description: 'List existing public share links/tokens issued for a Rootr (루터) FORM.',
341
+ description:
342
+ 'List existing public share links/tokens issued for a Rootr (루터) FORM. ' +
343
+ 'A link marked `closed: "PUBLIC_OWN_ROW_PAGE"` does not open right now (the form came from a row of an "own rows only" table) — do NOT hand it out.',
342
344
  annotations: { readOnlyHint: true, destructiveHint: false },
343
345
  inputSchema: {
344
346
  formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
@@ -355,4 +357,64 @@ export function registerFormTools(server) {
355
357
  }
356
358
  },
357
359
  );
360
+
361
+ // ---- FORM-MAP: 질문이 표의 어느 칸으로 들어가는가 ---------------------
362
+ // 2026-09-18 클린베테랑 요청 6번: 질문 이름과 칸 이름이 다르면 값이 조용히 빠졌고,
363
+ // 폼 10개 전부가 그래서 "응답은 들어오는데 행이 안 생기는" 상태였다.
364
+
365
+ server.registerTool(
366
+ 'rootr_form_mapping',
367
+ {
368
+ title: 'Show which table column each form question feeds',
369
+ description:
370
+ 'For a Rootr (루터) FORM linked to a DATABASE, show question → column for every question, and — the ' +
371
+ 'point of this tool — `unmatched`: the questions with NO column, whose answers are silently dropped ' +
372
+ 'when someone submits. Check this right after linking a form to a table or renaming anything: a ' +
373
+ 'mismatch produces no error anywhere, and the loss is usually found weeks later. ' +
374
+ '`how` is "manual" (someone chose it), "auto" (names matched) or "none" (dropped). ' +
375
+ '`lastError` carries why the most recent submission failed to reach the table, if it did.',
376
+ annotations: { readOnlyHint: true, destructiveHint: false },
377
+ inputSchema: {
378
+ formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
379
+ },
380
+ },
381
+ async ({ formId }) => {
382
+ try {
383
+ const client = makeClient();
384
+ formId = await resolveNodeId(client, formId);
385
+ return jsonResult(await client.formMapping(formId));
386
+ } catch (err) {
387
+ return errorResult(err);
388
+ }
389
+ },
390
+ );
391
+
392
+ server.registerTool(
393
+ 'rootr_set_form_mapping',
394
+ {
395
+ title: 'Wire form questions to table columns by hand',
396
+ description:
397
+ 'Pin which DATABASE column each question of a Rootr (루터) FORM writes into: { "<fieldId>": "<columnId>" }. ' +
398
+ 'Questions you leave out keep matching BY NAME, and passing null for one puts it back to automatic. ' +
399
+ 'Use this whenever the question wording differs from the column header ("성함" vs "이름") — otherwise ' +
400
+ 'that answer is dropped with no warning. Call rootr_form_mapping first for the ids and the current state. ' +
401
+ 'A field or column that does not exist is refused, not ignored.',
402
+ annotations: { readOnlyHint: false, destructiveHint: false },
403
+ inputSchema: {
404
+ formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
405
+ fieldMap: z
406
+ .record(z.string().nullable())
407
+ .describe('{ "<질문 id>": "<칸 id>" } — null puts that question back to name matching'),
408
+ },
409
+ },
410
+ async ({ formId, fieldMap }) => {
411
+ try {
412
+ const client = makeClient();
413
+ formId = await resolveNodeId(client, formId);
414
+ return jsonResult(await client.setFormMapping(formId, fieldMap));
415
+ } catch (err) {
416
+ return errorResult(err);
417
+ }
418
+ },
419
+ );
358
420
  }
@@ -50,7 +50,7 @@ export function registerLogTools(server) {
50
50
  description:
51
51
  'Update the field schema of an existing LOG store. WARNING: this REPLACES THE ENTIRE FIELD SET — ' +
52
52
  'you must resend every field you want to keep, not just the ones you are changing/adding. ' +
53
- 'Read the LOG store first (e.g. via rootr_read or a get-log call) to see its current fields before calling this.',
53
+ 'Look up its current fields before calling this — rootr_read does not return field definitions (the LOG store detail does).',
54
54
  annotations: { readOnlyHint: false, destructiveHint: true },
55
55
  inputSchema: {
56
56
  logId: z.string().describe(`LOG store node id${NODE_TARGET_HINT}`),
@@ -147,13 +147,16 @@ export function registerMiscTools(server) {
147
147
  'permanent delete. Use rootr_read/rootr_list first to confirm you have the right target.',
148
148
  annotations: { readOnlyHint: false, destructiveHint: true },
149
149
  inputSchema: {
150
- target: z.string().describe('Document/folder path (starting with "/") or node id'),
150
+ target: z.string().optional().describe('Document/folder path (starting with "/") or node id'),
151
+ nodeId: z.string().optional().describe('Alias for `target` — node id of the item to delete'),
151
152
  },
152
153
  },
153
- async ({ target }) => {
154
+ async ({ target, nodeId }) => {
154
155
  try {
156
+ const want = target ?? nodeId;
157
+ if (!want) throw new Error('target (or nodeId) is required');
155
158
  const client = makeClient();
156
- const resolved = await resolveTarget(client, target);
159
+ const resolved = await resolveTarget(client, want);
157
160
  const id = requireResolved(resolved);
158
161
  await client.deleteNode(id);
159
162
  return jsonResult({
@@ -203,6 +206,9 @@ export function registerMiscTools(server) {
203
206
  description:
204
207
  'Read the READ-ACK state of a Rootr (루터) node — who must read it, the due date, who has read or ' +
205
208
  'acknowledged, and (for a DATABASE or FOLDER) how far through the documents inside each person is. ' +
209
+ 'Each target has `completed`; on a DATABASE/FOLDER a person is done once they have read every document THEY can open ' +
210
+ '(`readCount`/`readTotal` per person — on an "own rows only" table that is their own row pages plus shared documents). ' +
211
+ 'If you cannot see every document inside, other people carry only `completed` (no counts). ' +
206
212
  'Use it to answer "누가 아직 안 읽었나" for a 규정/공지/교육자료. If the node itself has no requirement ' +
207
213
  'but an ancestor does, `ancestorRequirementId` names the node that carries it.',
208
214
  annotations: { readOnlyHint: true, destructiveHint: false },
@@ -25,7 +25,7 @@ import {
25
25
  */
26
26
  const BLOCK_GUIDE =
27
27
  'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
28
- 'You assemble a page from these 15 block types:\n' +
28
+ 'You assemble a page from these block types:\n' +
29
29
  '· hero {title, subtitle?, align?:"left"|"center", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner\n' +
30
30
  '· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
31
31
  '· features {columns?:1|2|3, items:[{title, body, icon?(emoji)}]} — "what it does" grid\n' +
@@ -38,13 +38,31 @@ const BLOCK_GUIDE =
38
38
  '· footer {links:[{label, target}], contact?, copyright?}\n' +
39
39
  '· liveStats {cards:[{label, icon?, source:{nodeId(DATABASE/LOG node), agg:"count"|"sum"|"avg"|"min"|"max", column?}}]} — numbers read LIVE from a table\n' +
40
40
  '· liveTable {source:{nodeId}, columns?:[name], rowLimit?(1-200), orderBy?:{column, dir:"asc"|"desc"}}\n' +
41
- '· liveChart {source:{nodeId}, chart:"bar"|"line"|"donut", labelColumn, valueColumn, agg:"count"|"sum"|"avg"}\n' +
41
+ '· liveChart {source:{nodeId, sheet?, range?}, chart, labelColumn, valueColumn?, agg?, …} — a LIVE chart (46 kinds, see CHARTS below)\n' +
42
42
  '· form {formNodeId(FORM node), headline?, submitLabel?} — visitors submit right on the page\n' +
43
43
  '· custom {html, height?(40-4000, default 260)} — free HTML/CSS, rendered inside a locked-down sandboxed iframe ' +
44
44
  '(no scripts, no same-origin, no network). Use it for a look the other blocks cannot make. Page theme colors are ' +
45
45
  'available inside as var(--pg-bg|text|muted|faint|card|line|accent|on-accent) and var(--pg-font). Scripts and ' +
46
46
  'on*= handlers are stripped on save — do not put behaviour here, only markup and CSS.\n' +
47
- 'LIVE/FORM BLOCKS: `source.nodeId` / `formNodeId` must be a node in the SAME workspace, and `column`/`labelColumn`/' +
47
+ 'LAYOUT: any block takes `width:"full"|"twoThirds"|"half"|"third"` — neighbouring blocks sit side by side (phones stack them). ' +
48
+ 'That is how a page becomes a DASHBOARD: a row of `chart:"kpi"` blocks at width "third", then charts at width "half".\n' +
49
+ 'CHARTS (liveChart): chart ∈ bar|barH|stackedBar|stackedBarH|percentBar|lollipop|dumbbell|bullet|waterfall|pareto|radar|polarBar|line|step|area|stackedArea|percentArea|combo|slope|stream|calendar|pie|donut|halfDonut|rose|funnel|treemap|sunburst|waffle|histogram|boxplot|heatmap|scatter|bubble|sankey|chord|network|tree|kpi|gauge|progress|ring|candlestick|gantt|wordCloud|pivot. ' +
50
+ 'source = a DATABASE, SPREADSHEET or LOG node (sheet: {nodeId, sheet?(name), range?:"A1:F200"} — first row = column names, formula results are read). ' +
51
+ 'Category charts: labelColumn (dates are bucketed: dateBucket day|week|month|quarter|year|weekday|hour, empty periods filled with 0) + ' +
52
+ 'valueColumn & agg (count|sum|avg|min|max|median|distinct — valueColumn is optional for count/distinct, which just count the matching rows per label) OR values:[{column?, agg, label?, as?:"bar"|"line"|"area", axis?:"left"|"right"}] (several series, combo/dual axis) ' +
53
+ 'OR seriesColumn (break down / stack by another column; heatmap, stream and pivot need it). sort auto|value-desc|value-asc|label-asc|label-desc, limit (+ others:true folds the rest into "Other"), cumulative:true. ' +
54
+ 'kpi|gauge|progress|ring: valueColumn+agg, optional dateColumn + period (today|7d|30d|90d|week|month|quarter|year) → value vs the previous same-length period + sparkline; goal or goalColumn. ' +
55
+ 'scatter|bubble: xColumn, yColumn, sizeColumn?, seriesColumn?. histogram|boxplot: valueColumn (+labelColumn groups for boxplot), bins?. ' +
56
+ 'sankey|chord|network: labelColumn (from) → targetColumn (to), value = agg. treemap|sunburst|tree: labelColumn + levels:[col,…]. ' +
57
+ 'candlestick: labelColumn + open/high/low/closeColumn. gantt: labelColumn, startColumn, endColumn?. bullet: values[0]=actual + goalColumn (or goal). wordCloud: labelColumn (+splitWords:true for free text). ' +
58
+ 'filters:[{column, op: is|is_not|contains|not_contains|gt|gte|lt|lte|empty|not_empty|last_days, value}] (values may be "@today-30"). ' +
59
+ 'Display: title, subtitle, height(160-1200), format auto|number|compact|percent|krw|usd|decimal1|decimal2, prefix, suffix, palette theme|vivid|pastel|ocean|sunset|forest|mono, goalLabel, thresholds:[{value,color}], showLabels, showLegend, smooth. ' +
60
+ 'rootr_read_page_live_data shows what each chart computed — a chart that could not be drawn says why (column_missing/no_rows/needs_number).\n' +
61
+ 'LIVE/FORM BLOCKS: `source.nodeId` / `formNodeId` must be a node in the SAME workspace that you can open — write it as ' +
62
+ 'the node id, a full "/folder/name" path, or a name that is the ONLY table/sheet/log (or form, for `formNodeId`) with ' +
63
+ 'that name. The server resolves it and stores the id (the response shows the id). A path or name it cannot resolve — ' +
64
+ 'missing, not readable by you, wrong node type, or a name shared by two nodes — is rejected with 400 and NOTHING is ' +
65
+ 'saved; it is never stored as a silently empty block. `column`/`labelColumn`/' +
48
66
  '`valueColumn`/`orderBy.column` are the column NAMES as shown in the table (not column ids). A table or form that ' +
49
67
  'sits in a PRIVATE subtree is silently skipped on the published page — that is deliberate, never a bug to work around.\n' +
50
68
  'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only) or {kind:"node", nodeId} ' +
@@ -65,8 +83,11 @@ export function registerPageTools(server) {
65
83
  description:
66
84
  'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
67
85
  'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
68
- 'for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
86
+ 'for a DASHBOARD / report / KPI board over their tables, spreadsheets or logs (46 live chart kinds, numbers stay ' +
87
+ 'current), or for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
69
88
  'Omit `blocks` to start from a hero/text/footer skeleton. ' +
89
+ 'In the response, `nodeId` is the page\'s node id — pass THAT as `pageId` to every later page tool ' +
90
+ '(and in REST paths like /v1/pages/:nodeId/live-data). The response `id` is an internal page-row id and is NOT accepted there (404). ' +
70
91
  BLOCK_GUIDE +
71
92
  ' ' +
72
93
  NODE_URL_HINT +
@@ -222,9 +243,12 @@ export function registerPageTools(server) {
222
243
  title: 'Read what a page\'s live blocks currently show',
223
244
  description:
224
245
  'Read the values a Rootr (루터) PAGE\'s live blocks (liveStats/liveTable/liveChart) are showing right now — ' +
225
- 'the aggregates, rows and chart points computed from their source tables. Use it to check that a live block ' +
226
- 'points at the right table and column: an empty result usually means the source is missing, is in a PRIVATE ' +
227
- 'subtree, or the column name does not match.',
246
+ 'the aggregates, rows and chart data computed from their source tables, spreadsheets or logs. Use it after ' +
247
+ 'building a dashboard to check every chart actually draws. Each chart carries `data` shaped by kind ' +
248
+ '(category {labels, series[]} · single {value, previous, goal, spark} · xy · distribution · tree · flow · ohlc · range). ' +
249
+ 'A chart that cannot draw says why: `{shape:"empty", reason:"column_missing"|"no_rows"|"needs_number", column}` — ' +
250
+ 'fix that column name or filter. A block missing from the result means its source was not found, is deleted, ' +
251
+ 'or sits in a PRIVATE subtree.',
228
252
  annotations: { readOnlyHint: true, destructiveHint: false },
229
253
  inputSchema: {
230
254
  pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
@@ -10,7 +10,7 @@ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, N
10
10
  const AUTHORING_GUIDE =
11
11
  'Authoring guide: give each slide ONE assertion-style title (a claim, e.g. "Latency dropped 40% after the cache fix" — ' +
12
12
  'not a topic label like "Latency"), plus blocks[] (bullet-like {id, heading?, body?, icon?} items) and, where useful, ' +
13
- 'a diagram ({type:"mermaid", code}) or images. Canvas is 16:9 (1280x720). Meaning must live in TEXT — ' +
13
+ 'a diagram ({type:"mermaid", code} — drawn on "content" slides) or images. Canvas is 16:9 (1280x720). Meaning must live in TEXT — ' +
14
14
  'title/blocks[].heading/blocks[].body/notes are what auto-connects into the knowledge graph; diagrams/code/html and the ' +
15
15
  'image pixels themselves are visual-only and are NOT indexed, so never put facts ONLY in a diagram or picture.\n' +
16
16
  'Images: `image` is the single primary/background image (cover, section, full-bleed); `images[]` holds additional ' +
@@ -19,8 +19,10 @@ const AUTHORING_GUIDE =
19
19
  'an https URL, or a data: URI — you cannot upload the file bytes through these MCP tools, only reference the resulting URL. ' +
20
20
  'To get a REAL image, call rootr_generate_image with an English prompt (say "no text"), then put the returned `url` ' +
21
21
  'into the slide image\'s `src`; rootr_remove_image_background cuts out an image\'s background. ' +
22
- '`placement` is a layout hint ("full"|"right"|"top"|...); x/y/w/h give freeform PPT-style placement in 1280x720 canvas ' +
23
- 'coords. ALWAYS add an `alt` caption to every image — alt text is part of the graph text spine, so a captioned image ' +
22
+ 'Set `placement` on the primary `image`: "full" = background (full-bleed on cover/section/closing, faint on content); ' +
23
+ '"right" / "top" = beside / above the blocks, content slides only; other values are ignored and a content slide without ' +
24
+ 'placement does not draw the image. For `images[]`, x/y/w/h give freeform PPT-style placement in 1280x720 canvas ' +
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 ' +
24
26
  'connects into the knowledge graph while an uncaptioned one does not.\n' +
25
27
  'Use kind to mark each slide\'s role: "cover" (deck title slide), "section" (chapter divider), "content" (body slide, ' +
26
28
  'the default), "closing" (last slide / call to action).\n' +
@@ -54,7 +56,13 @@ const imageShape = z
54
56
  .optional()
55
57
  .describe('Caption / alt text. Part of the knowledge-graph text spine — ALWAYS set this so the image is indexed.'),
56
58
  prompt: z.string().optional().describe('English AI-generation prompt, no text baked into the image (used when src is absent)'),
57
- placement: z.string().optional().describe('Layout hint: "full" | "right" | "top" | ...'),
59
+ placement: z
60
+ .string()
61
+ .optional()
62
+ .describe(
63
+ 'Where the primary `image` goes: "full" (background — full-bleed on cover/section/closing, faint on content) | ' +
64
+ '"right" | "top" (content slides only). Other values are ignored.',
65
+ ),
58
66
  x: z.number().optional().describe('Freeform placement X in 1280x720 canvas coords'),
59
67
  y: z.number().optional().describe('Freeform placement Y in 1280x720 canvas coords'),
60
68
  w: z.number().optional().describe('Freeform placement width in canvas coords'),
@@ -66,7 +74,14 @@ const slideShape = z
66
74
  .object({
67
75
  id: z.string().optional().describe('Unique slide id, e.g. "SLD-001". Auto-generated if omitted.'),
68
76
  kind: z.string().optional().describe('cover | section | content | closing'),
69
- layout: z.string().optional().describe('Layout hint, e.g. "2-col" | "kpi-grid" | "timeline"'),
77
+ layout: z
78
+ .string()
79
+ .optional()
80
+ .describe(
81
+ 'Block arrangement on content slides: "two-col" (needs 2+ blocks) | "kpi-grid" (alias "kpi"). ' +
82
+ 'Any other value silently falls back to the default arrangement. On cover/section slides it is printed as ' +
83
+ 'the small label above the title.',
84
+ ),
70
85
  title: z.string().optional().describe('Assertion-style title (the claim this slide makes) — text spine'),
71
86
  blocks: z
72
87
  .array(
@@ -82,7 +97,10 @@ const slideShape = z
82
97
  diagram: z
83
98
  .object({ type: z.literal('mermaid').optional(), code: z.string() })
84
99
  .optional()
85
- .describe('Mermaid diagram; visual only, not indexed — keep the facts in title/blocks too'),
100
+ .describe(
101
+ 'Mermaid diagram, drawn below the blocks on "content" slides (ignored on cover/section/closing and when the ' +
102
+ 'slide has baked html); visual only, not indexed — keep the facts in title/blocks too',
103
+ ),
86
104
  image: imageShape
87
105
  .optional()
88
106
  .describe('Single primary/background image (cover, section, full-bleed). See imageShape fields.'),
@@ -242,7 +260,12 @@ export function registerPresentationTools(server) {
242
260
  try {
243
261
  const client = makeClient();
244
262
  presentationId = await resolveNodeId(client, presentationId);
245
- const result = await client.appendPresentationSlides(presentationId, ensureSlideIds(slides));
263
+ // ISSUE-185 — do NOT mint ids here: numbering by this call's own
264
+ // array index always collides with the deck's existing slides
265
+ // (e.g. restamping "SLD-001" onto a deck that already has one).
266
+ // The server fills any omitted slide/block id, avoiding the deck's
267
+ // current ids (see PresentationsService.appendSlides).
268
+ const result = await client.appendPresentationSlides(presentationId, slides);
246
269
  return jsonResult(result);
247
270
  } catch (err) {
248
271
  return errorResult(err);
@@ -328,8 +351,8 @@ export function registerPresentationTools(server) {
328
351
  },
329
352
  async ({ prompt, nodeId, workspace }) => {
330
353
  try {
331
- const client = makeClient();
332
- const ws = requireWorkspace(workspace);
354
+ const ws = requireWorkspace(workspace); // ISSUE-61 — 워크스페이스를 먼저 정하고 그 키로(attachments.js 와 같은 결함)
355
+ const client = makeClient(ws);
333
356
  nodeId = await resolveOptionalNodeId(client, nodeId);
334
357
  const result = await client.generateImage(ws, { prompt, nodeId });
335
358
  return jsonResult(result);
@@ -357,8 +380,8 @@ export function registerPresentationTools(server) {
357
380
  },
358
381
  async ({ attachmentId, nodeId, workspace }) => {
359
382
  try {
360
- const client = makeClient();
361
- const ws = requireWorkspace(workspace);
383
+ const ws = requireWorkspace(workspace); // ISSUE-61 — 위와 같다
384
+ const client = makeClient(ws);
362
385
  nodeId = await resolveOptionalNodeId(client, nodeId);
363
386
  const result = await client.removeImageBackground(ws, { attachmentId, nodeId });
364
387
  return jsonResult(result);
@@ -405,7 +428,8 @@ export function registerPresentationTools(server) {
405
428
  description:
406
429
  'Render a typed diagram spec into a Rootr (루터) presentation slide — architecture, workflow, sequence, ' +
407
430
  'dataflow, or lifecycle. The drawing is baked into the slide, so it shows on screen AND in the PDF/PPTX ' +
408
- 'exports. Use this instead of the mermaid `diagram` field, which the slide viewer does not draw.\n' +
431
+ 'exports. Prefer this over the mermaid `diagram` field when the diagram has to look designed — a plain ' +
432
+ '"content" slide also draws its mermaid `diagram`, but with mermaid\'s default look.\n' +
409
433
  'Call rootr_diagram_schema first to get the exact shape for the family you want — the spec is validated ' +
410
434
  'fail-closed and a mismatch is rejected with the offending field named and a suggested fix, which you ' +
411
435
  'should apply and retry.\n' +
@@ -86,7 +86,8 @@ export function registerPublicTools(server) {
86
86
  '`{"password": "..."}` locks the page (the content is not sent at all until the password is entered — it is ' +
87
87
  'not hidden in the browser), `{"password": null}` unlocks; ' +
88
88
  '`{"expiresAt": "2026-12-31T00:00:00Z"}` auto-closes on that date, `null` clears it; ' +
89
- '`{"noIndex": true}` asks search engines to stay away; ' +
89
+ '`{"noIndex": true}` asks search engines to stay away (Team plan and up — a Free workspace gets 403 ' +
90
+ 'PLAN_UPGRADE_REQUIRED; a password-locked page is always hidden and cannot be re-exposed until unlocked); ' +
90
91
  '`{"design": {"layout": "board", "skin": "paper"}}` changes how a published TABLE looks — it does not ' +
91
92
  'have to be a table (cards, gallery, list, board, timeline, calendar, chart, ranking). ' +
92
93
  'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
@@ -129,6 +130,8 @@ export function registerPublicTools(server) {
129
130
  'currently open, when it expires, whether a password is set (the password itself is never returned), whether ' +
130
131
  'cloning is allowed, and whether the pages underneath went out with it. ' +
131
132
  '`publishable: false` means this type cannot be published at all (FOLDER). ' +
133
+ '`closed: "PUBLIC_OWN_ROW_PAGE"` (with `isPublic: false` and a `closedReason`) means publishing is switched on but the ' +
134
+ 'address is closed right now — do NOT hand that URL out; tell the user why and how to reopen it. ' +
132
135
  'Call this before publishing so you do not tell the user a page is new when it already had a URL.',
133
136
  annotations: { readOnlyHint: true, destructiveHint: false },
134
137
  inputSchema: {