rootr-cli 0.18.0 → 0.20.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.
@@ -128,10 +128,11 @@ export function registerDatabaseTools(server) {
128
128
  'column order, so one call keeps the columns in exactly the order you list them. ' +
129
129
  'The FIRST property must be type "title" (the row label). Other types: text | number | select | multi_select | ' +
130
130
  'status | date | checkbox | url | email | phone | person | files | relation | formula | rollup | ' +
131
- 'created_time | last_edited_time | created_by | last_edited_by | unique_id. ' +
131
+ 'created_time | last_edited_time | created_by | last_edited_by | unique_id | vector. ' +
132
132
  'select/multi_select/status take options:[{name,color,group?}] (group ∈ todo|in_progress|done, status only); ' +
133
133
  'relation takes relationDatabaseId; formula takes formula ("{수량} * {단가}"); rollup takes ' +
134
- 'rollup:{relation,property?,function}; unique_id takes prefix ("TASK" → "TASK-42"). The last seven types are ' +
134
+ 'rollup:{relation,property?,function}; unique_id takes prefix ("TASK" → "TASK-42"); vector takes dimensions ' +
135
+ '(the length of the embeddings you will store with rootr_set_row_vectors). The last seven types are ' +
135
136
  'filled in by the server and take no row value. ' +
136
137
  'Views default to a single table view; view types are table | board | gallery | calendar | timeline | list — ' +
137
138
  'a "board" view needs config.groupBy (a select/status property id), a "timeline"/"calendar" view needs ' +
@@ -176,8 +177,14 @@ export function registerDatabaseTools(server) {
176
177
  'created_by',
177
178
  'last_edited_by',
178
179
  'unique_id',
180
+ 'vector',
179
181
  ])
180
182
  .describe('Column type; the FIRST property must be "title"'),
183
+ dimensions: z
184
+ .number()
185
+ .int()
186
+ .optional()
187
+ .describe('vector only: the length of every vector you will store, e.g. 1536 or 3072'),
181
188
  options: z
182
189
  .array(z.object({ name: z.string(), color: z.string(), group: z.string().optional() }))
183
190
  .optional()
@@ -466,7 +473,13 @@ export function registerDatabaseTools(server) {
466
473
  client.getDatabase(id),
467
474
  client.listRows(id, { withBody: includeBody === true }),
468
475
  ]);
469
- return jsonResult({ database, rows });
476
+ // 감사 ROUND-17 #9 — 좁혀진 롤업(상대 표가 "본인 행만"이거나 못 여는 표)은 값이 전체 합계가 아니다 — 모델이 전체로 옮겨 적지 않게 적는다
477
+ const narrowed = Array.isArray(database?.narrowedRollups) ? database.narrowedRollups : [];
478
+ const nameOf = new Map((database?.properties ?? []).map((p) => [p.id, p.name]));
479
+ const note = narrowed.length
480
+ ? `Rollup column(s) ${narrowed.map((id) => `"${nameOf.get(id) ?? id}"`).join(', ')} only count rows you can see (the linked table is "own rows only" or not readable) — not full totals.`
481
+ : undefined;
482
+ return jsonResult(note ? { database, rows, narrowedRollupsNote: note } : { database, rows });
470
483
  } catch (err) {
471
484
  return errorResult(err);
472
485
  }
@@ -863,6 +876,8 @@ export function registerDatabaseTools(server) {
863
876
  'A rollup may also read a FORMULA column in the linked table, and min/max work on date columns ' +
864
877
  '(that is how "last call date" is built). ' +
865
878
  'For unique_id pass prefix to get "TASK-42" style ids. ' +
879
+ 'For vector pass dimensions — an embedding column YOU fill with rootr_set_row_vectors and search with ' +
880
+ 'rootr_query_vectors (Rootr never embeds it for you; people see only "vector · N dims" in the grid). ' +
866
881
  'created_time / last_edited_time / created_by / last_edited_by / unique_id / formula / rollup are filled in by the server — ' +
867
882
  'they take no value and rootr_update_row will refuse to set them. ' +
868
883
  'Returns the created column WITH ITS id.',
@@ -892,8 +907,9 @@ export function registerDatabaseTools(server) {
892
907
  'created_by',
893
908
  'last_edited_by',
894
909
  'unique_id',
910
+ 'vector',
895
911
  ])
896
- .describe('Column type'),
912
+ .describe('Column type ("vector" = an embedding column for agents — pass dimensions)'),
897
913
  options: z
898
914
  .array(z.object({ name: z.string(), color: z.string(), group: z.string().optional() }))
899
915
  .optional()
@@ -954,9 +970,25 @@ export function registerDatabaseTools(server) {
954
970
  .optional()
955
971
  .describe('rollup only: what to gather through which relation'),
956
972
  prefix: z.string().optional().describe('unique_id only: prefix for the number, e.g. "TASK" → "TASK-42"'),
973
+ dimensions: z
974
+ .number()
975
+ .int()
976
+ .min(1)
977
+ .max(4096)
978
+ .optional()
979
+ .describe('vector only: the length of every vector you will store, e.g. 1536 or 3072'),
980
+ searchExcluded: z
981
+ .boolean()
982
+ .optional()
983
+ .describe(
984
+ 'Exclude this column from the search index: its values never go into the index behind rootr_ask, AI explore ' +
985
+ 'and rootr_find_similar_rows (people still see them in the table). It is NOT a read permission — the in-app ' +
986
+ 'chat, button AI-fill steps, formula/rollup columns and MCP reads still see the values; to hide values from ' +
987
+ 'people, use an "own rows only" table or a private node. Table managers only.',
988
+ ),
957
989
  },
958
990
  },
959
- async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix }) => {
991
+ async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix, searchExcluded, dimensions }) => {
960
992
  try {
961
993
  const client = makeClient();
962
994
  const id = await resolveNodeId(client, databaseId);
@@ -968,6 +1000,8 @@ export function registerDatabaseTools(server) {
968
1000
  formula,
969
1001
  rollup,
970
1002
  prefix,
1003
+ searchExcluded,
1004
+ dimensions,
971
1005
  });
972
1006
  return jsonResult({ property });
973
1007
  } catch (err) {
@@ -982,8 +1016,10 @@ export function registerDatabaseTools(server) {
982
1016
  title: 'Rename a column / set its options',
983
1017
  description:
984
1018
  'Rename ONE column of a Rootr (루터) DATABASE, replace the choices of a select/multi_select column, edit a ' +
985
- 'formula or rollup config, or turn an existing relation column TWO-WAY. ' +
986
- 'The column TYPE cannot be changed — that would rewrite every row — so add a new column and move the values instead. ' +
1019
+ 'formula or rollup config, turn an existing relation column TWO-WAY, or exclude it from the search index ' +
1020
+ '(searchExcluded: true — managers only; values already indexed are removed before this returns). ' +
1021
+ 'The column TYPE and a vector column\'s dimensions cannot be changed — that would rewrite every row (or every stored vector) — ' +
1022
+ 'so passing type or dimensions is refused; add a new column and move the values instead. ' +
987
1023
  'Call rootr_read_database first to get the exact propertyId.\n' +
988
1024
  '`reverseName` on a relation column pairs it with the linked table: that table gets a matching column with ' +
989
1025
  'this name and filling either side fills the other. Pass an empty string to unpair (neither column is deleted). ' +
@@ -1004,13 +1040,33 @@ export function registerDatabaseTools(server) {
1004
1040
  .optional()
1005
1041
  .describe('rollup only: { relation, property?, function, where? } — replaces the whole config'),
1006
1042
  prefix: z.string().optional().describe('unique_id only: the new prefix'),
1043
+ type: z.string().optional().describe('Not changeable — passing it is refused with the reason'),
1044
+ dimensions: z.number().optional().describe('Not changeable (vector only) — passing it is refused with the reason'),
1007
1045
  reverseName: z
1008
1046
  .string()
1009
1047
  .optional()
1010
1048
  .describe('relation only: pair it two-way under this name in the linked table; "" unpairs'),
1049
+ searchExcluded: z
1050
+ .boolean()
1051
+ .optional()
1052
+ .describe(
1053
+ 'Exclude this column from the search index: its values never go into the index behind rootr_ask, AI explore ' +
1054
+ 'and rootr_find_similar_rows (people still see them in the table). It is NOT a read permission — the in-app ' +
1055
+ 'chat, button AI-fill steps, formula/rollup columns and MCP reads still see the values; to hide values from ' +
1056
+ 'people, use an "own rows only" table or a private node. Table managers only. false puts the column back into the index.',
1057
+ ),
1011
1058
  },
1012
1059
  },
1013
- async ({ databaseId, propertyId, name, options, formula, rollup, prefix, reverseName }) => {
1060
+ async ({ databaseId, propertyId, name, options, formula, rollup, prefix, reverseName, searchExcluded, type, dimensions }) => {
1061
+ // 조용히 무시하지 않는다 — 못 바꾸는 것을 넘기면 까닭과 할 일을 말한다(벡터 차원을 바꾸면 저장된 벡터가 전부 틀려진다).
1062
+ if (type !== undefined || dimensions !== undefined) {
1063
+ return errorResult(
1064
+ new Error(
1065
+ '칸 종류(type)와 벡터 차원(dimensions)은 바꿀 수 없습니다. 새 칸을 만들고 값을 옮겨 주세요.' +
1066
+ '\n힌트: 벡터 칸을 다른 차원으로 쓰려면 rootr_add_column(type "vector", dimensions 지정)으로 새 칸을 만든 뒤 벡터를 다시 넣으세요.',
1067
+ ),
1068
+ );
1069
+ }
1014
1070
  try {
1015
1071
  const client = makeClient();
1016
1072
  const id = await resolveNodeId(client, databaseId);
@@ -1021,6 +1077,7 @@ export function registerDatabaseTools(server) {
1021
1077
  rollup,
1022
1078
  prefix,
1023
1079
  reverseName,
1080
+ searchExcluded,
1024
1081
  });
1025
1082
  return jsonResult({ property });
1026
1083
  } catch (err) {
@@ -1161,7 +1218,10 @@ export function registerDatabaseTools(server) {
1161
1218
  description:
1162
1219
  'Remove ONE column from a Rootr (루터) DATABASE. The values stored under it are NOT erased from the rows, ' +
1163
1220
  'so re-adding a column is not enough to bring them back in the UI — confirm with the user before calling this. ' +
1164
- 'The title column cannot be deleted. Views grouped/dated by the column are cleared automatically.',
1221
+ 'Exception: values of a vector column are erased (see below). ' +
1222
+ 'The title column cannot be deleted. Views grouped/dated by the column are cleared automatically. ' +
1223
+ 'For a vector column the stored vectors are deleted with it (not kept for re-adding — re-add the column and ' +
1224
+ 'store the vectors again with rootr_set_row_vectors).',
1165
1225
  annotations: { readOnlyHint: false, destructiveHint: true },
1166
1226
  inputSchema: {
1167
1227
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -1440,4 +1500,165 @@ export function registerDatabaseTools(server) {
1440
1500
  }
1441
1501
  },
1442
1502
  );
1503
+
1504
+ // ── DB-SEARCH — vector similarity search across rows ──────────────
1505
+
1506
+ server.registerTool(
1507
+ 'rootr_find_similar_rows',
1508
+ {
1509
+ title: 'Find semantically similar rows',
1510
+ description:
1511
+ 'Find rows in a DATABASE that are semantically similar to a given row (duplicate reports, ' +
1512
+ 'a past answer to a similar inquiry, a similar experiment). Uses vector embeddings of the ' +
1513
+ 'row\'s columns — no keyword matching. Columns marked "exclude from search index" (searchExcluded) are never ' +
1514
+ 'indexed, and on an "own rows only" table you only get your own rows. Only rows that stand out from the ' +
1515
+ 'rest of the table are returned (the raw cosine of unrelated rows in one table is often 0.8+, so a fixed ' +
1516
+ 'cut-off lies): each row carries `similarityLevel` ("high" | "mid") and the raw `similarityScore`. ' +
1517
+ 'An empty list with `compared` > 0 means "nothing is especially similar", not an error. ' +
1518
+ '`state`: "ready", "indexing" (the row, or `pending` other rows of the table, are not indexed yet — the rows found so far are returned; retry in a few seconds), "no-text" (the row has ' +
1519
+ 'nothing to compare — it is empty or only its excluded columns are filled; retrying will not help) or ' +
1520
+ '"sync-off" (graph sync is turned off — `syncOffAt` says where to turn it back on: "node" = this table\'s menu, ' +
1521
+ '"folder" = the parent folder named in `syncOffFolder`, "workspace" = workspace settings › General).',
1522
+ annotations: { title: 'Find similar rows', readOnlyHint: true, openWorldHint: false },
1523
+ inputSchema: {
1524
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1525
+ rowId: z.string().describe('The row whose similar counterparts you want to find'),
1526
+ topK: z.number().int().min(1).max(20).optional().default(5)
1527
+ .describe('How many similar rows to return (default 5, max 20)'),
1528
+ },
1529
+ },
1530
+ async ({ databaseId, rowId, topK }) => {
1531
+ try {
1532
+ const client = makeClient();
1533
+ const id = await resolveNodeId(client, databaseId);
1534
+ const qs = `rowId=${encodeURIComponent(rowId)}&topK=${topK ?? 5}`;
1535
+ const result = await client.request(`/databases/${encodeURIComponent(id)}/rows/similar?${qs}`);
1536
+ return jsonResult(result);
1537
+ } catch (err) {
1538
+ return errorResult(err);
1539
+ }
1540
+ },
1541
+ );
1542
+
1543
+ // ── DB-VECTOR(기획 5단계) — 에이전트용 벡터 칸: 직접 넣고 직접 찾는다 ───────────
1544
+ //
1545
+ // 루터 표가 에이전트의 벡터 저장소가 된다. 행과 같은 권한을 탄다(본인 행만 표에서는 남의 행을 못 넣고 못 찾는다).
1546
+
1547
+ server.registerTool(
1548
+ 'rootr_set_row_vectors',
1549
+ {
1550
+ title: 'Store vectors in a vector column',
1551
+ description:
1552
+ 'Store YOUR OWN embeddings in a "vector" column of a Rootr (루터) DATABASE — up to 500 { rowId, vector } per ' +
1553
+ 'call; sending a row again replaces its vector. Every vector must have exactly the column\'s `dimensions` ' +
1554
+ '(create the column with rootr_add_column type "vector" + dimensions). Rootr does not embed anything here and ' +
1555
+ 'charges no AI credits. Rows you may not edit (own-rows-only tables, manager-locked column) are refused ' +
1556
+ 'with the reason — nothing is written if any item is invalid. To check which rows already hold a vector, read the ' +
1557
+ 'rows (rootr_list_rows / rootr_read_database: each row has `vectors` — column id → dimensions, empty when none). ' +
1558
+ 'To read one row\'s vector use rootr_get_row_vector; to remove it use rootr_delete_row_vector.',
1559
+ annotations: { readOnlyHint: false, destructiveHint: false },
1560
+ inputSchema: {
1561
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1562
+ column: z.string().describe('The vector column — its id or header name'),
1563
+ items: z
1564
+ .array(z.object({ rowId: z.string(), vector: z.array(z.number()) }))
1565
+ .min(1)
1566
+ .max(500)
1567
+ .describe('[{ rowId, vector: [..numbers..] }]'),
1568
+ },
1569
+ },
1570
+ async ({ databaseId, column, items }) => {
1571
+ try {
1572
+ const client = makeClient();
1573
+ const id = await resolveNodeId(client, databaseId);
1574
+ return jsonResult(await client.putVectors(id, column, items));
1575
+ } catch (err) {
1576
+ return errorResult(err);
1577
+ }
1578
+ },
1579
+ );
1580
+
1581
+ server.registerTool(
1582
+ 'rootr_query_vectors',
1583
+ {
1584
+ title: 'Nearest rows by vector',
1585
+ description:
1586
+ 'Find the rows of a DATABASE whose stored vector (in a "vector" column) is closest to the vector you pass — ' +
1587
+ 'cosine similarity, highest first, with each row\'s values and `score`. Use it as a vector store: embed your ' +
1588
+ 'query with the same model you used for rootr_set_row_vectors. `minScore` drops weak matches. Rows the caller ' +
1589
+ 'cannot see (own-rows-only tables) are never returned. Rows without a stored vector are skipped. ' +
1590
+ 'The column can hold at most 50,000 vectors — a bigger column is refused, not searched partially — and the scan has ' +
1591
+ 'no index, so latency grows with the number of rows in the column.',
1592
+ annotations: { readOnlyHint: true, openWorldHint: false },
1593
+ inputSchema: {
1594
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1595
+ column: z.string().describe('The vector column — its id or header name'),
1596
+ vector: z.array(z.number()).describe('Query vector — same length as the column\'s dimensions'),
1597
+ topK: z.number().int().min(1).max(100).optional().default(10).describe('How many rows (default 10, max 100)'),
1598
+ minScore: z.number().min(-1).max(1).optional().describe('Only rows with cosine ≥ this'),
1599
+ },
1600
+ },
1601
+ async ({ databaseId, column, vector, topK, minScore }) => {
1602
+ try {
1603
+ const client = makeClient();
1604
+ const id = await resolveNodeId(client, databaseId);
1605
+ return jsonResult(await client.queryVectors(id, column, { vector, topK, minScore }));
1606
+ } catch (err) {
1607
+ return errorResult(err);
1608
+ }
1609
+ },
1610
+ );
1611
+
1612
+ server.registerTool(
1613
+ 'rootr_get_row_vector',
1614
+ {
1615
+ title: 'Read one row\'s stored vector',
1616
+ description:
1617
+ 'Read the vector stored for ONE row in a "vector" column. Returns { rowId, propId, dimensions, vector }; ' +
1618
+ '`vector` is null when the row has no vector yet — so this is also the check "does this row hold a vector". ' +
1619
+ 'Rows you cannot see (own-rows-only tables) are refused.',
1620
+ annotations: { readOnlyHint: true, destructiveHint: false },
1621
+ inputSchema: {
1622
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1623
+ rowId: z.string().describe('Row id from rootr_list_rows / rootr_read_database'),
1624
+ column: z.string().describe('The vector column — its id or header name'),
1625
+ },
1626
+ },
1627
+ async ({ databaseId, rowId, column }) => {
1628
+ try {
1629
+ const client = makeClient();
1630
+ const id = await resolveNodeId(client, databaseId);
1631
+ return jsonResult(await client.getRowVector(id, rowId, column));
1632
+ } catch (err) {
1633
+ return errorResult(err);
1634
+ }
1635
+ },
1636
+ );
1637
+
1638
+ server.registerTool(
1639
+ 'rootr_delete_row_vector',
1640
+ {
1641
+ title: 'Remove one row\'s stored vector',
1642
+ description:
1643
+ 'Remove the vector stored for ONE row in a "vector" column. The row itself and its other values stay. ' +
1644
+ 'The vector is gone for queries from then on (rootr_query_vectors will not return it). Rows you cannot ' +
1645
+ 'edit (own-rows-only tables, manager-locked column) are refused with the reason.',
1646
+ annotations: { readOnlyHint: false, destructiveHint: true },
1647
+ inputSchema: {
1648
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1649
+ rowId: z.string().describe('Row id from rootr_list_rows / rootr_read_database'),
1650
+ column: z.string().describe('The vector column — its id or header name'),
1651
+ },
1652
+ },
1653
+ async ({ databaseId, rowId, column }) => {
1654
+ try {
1655
+ const client = makeClient();
1656
+ const id = await resolveNodeId(client, databaseId);
1657
+ await client.deleteRowVector(id, rowId, column);
1658
+ return jsonResult({ deleted: true, rowId });
1659
+ } catch (err) {
1660
+ return errorResult(err);
1661
+ }
1662
+ },
1663
+ );
1443
1664
  }
@@ -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}`),
@@ -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}`),