rootr-cli 0.14.1 → 0.17.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.
@@ -27,16 +27,22 @@ export function candidateClients(cfg = loadConfig()) {
27
27
  }
28
28
 
29
29
  /**
30
- * Collect every workspace the registered keys can see, and (when asked) which
31
- * of them a key can actually open — listing is not a capability probe: a
32
- * workspace key lists all of its owner's workspaces while being rejected by
33
- * every one but its own.
30
+ * Collect every workspace the registered keys can see, and which of them a
31
+ * key can actually open — listing is not a capability probe: a workspace key
32
+ * lists all of its owner's workspaces while being rejected by every one but
33
+ * its own. `GET /workspaces` now tags each entry with `accessible` (computed
34
+ * server-side from the calling key's own scope), so this reads straight off
35
+ * that field instead of firing one extra request per workspace. Older
36
+ * servers that predate the field fall back to the old per-workspace probe
37
+ * when `probe` is requested.
34
38
  */
35
39
  export async function surveyWorkspaces(candidates, { probe = false, probeLimit = 12 } = {}) {
36
40
  const seen = new Map();
37
41
  const reachable = new Set();
38
42
  let listed = 0;
39
43
  let failures = 0;
44
+ let sawAccessibleField = false;
45
+ const lists = [];
40
46
  for (const { client } of candidates) {
41
47
  let list = [];
42
48
  try {
@@ -46,19 +52,29 @@ export async function surveyWorkspaces(candidates, { probe = false, probeLimit =
46
52
  failures += 1;
47
53
  continue; // a revoked/foreign key shouldn't hide the others
48
54
  }
49
- for (const w of list) if (!seen.has(w.id)) seen.set(w.id, w);
50
- if (!probe) continue;
51
- for (const w of list.slice(0, probeLimit)) {
52
- if (reachable.has(w.id)) continue;
53
- try {
54
- await client.request(`/workspaces/${encodeURIComponent(w.id)}`);
55
- reachable.add(w.id);
56
- } catch {
57
- // scoped key rejected here; another key may still cover it
55
+ lists.push({ client, list });
56
+ for (const w of list) {
57
+ if (!seen.has(w.id)) seen.set(w.id, w);
58
+ if (typeof w.accessible === 'boolean') {
59
+ sawAccessibleField = true;
60
+ if (w.accessible) reachable.add(w.id);
61
+ }
62
+ }
63
+ }
64
+ if (!sawAccessibleField && probe) {
65
+ for (const { client, list } of lists) {
66
+ for (const w of list.slice(0, probeLimit)) {
67
+ if (reachable.has(w.id)) continue;
68
+ try {
69
+ await client.request(`/workspaces/${encodeURIComponent(w.id)}`);
70
+ reachable.add(w.id);
71
+ } catch {
72
+ // scoped key rejected here; another key may still cover it
73
+ }
58
74
  }
59
75
  }
60
76
  }
61
- return { workspaces: [...seen.values()], reachable, listed, failures };
77
+ return { workspaces: [...seen.values()], reachable, listed, failures, probed: sawAccessibleField || probe };
62
78
  }
63
79
 
64
80
  /**
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT, resolveOptionalNodeId } from './shared.js';
4
4
 
5
5
  /**
6
6
  * Shared context repeated (shortened) across tool descriptions so an agent
@@ -56,7 +56,9 @@ export function registerCrmTools(server) {
56
56
  'activities and tasks all live inside it. Optionally seed custom `pipelines` (name + ordered stages, each ' +
57
57
  'with an optional win probability/color) and/or custom company/contact/deal `fields`; sane defaults are ' +
58
58
  'used if omitted. ' +
59
- CRM_CONTEXT,
59
+ CRM_CONTEXT +
60
+ ' ' +
61
+ NODE_URL_HINT,
60
62
  annotations: { readOnlyHint: false, destructiveHint: false },
61
63
  inputSchema: {
62
64
  name: z.string().optional().describe('CRM node name'),
@@ -8,6 +8,7 @@ import {
8
8
  resolveNodeId,
9
9
  resolveOptionalNodeId,
10
10
  NODE_TARGET_HINT,
11
+ NODE_URL_HINT,
11
12
  } from './shared.js';
12
13
 
13
14
  /** Human text for the `databaseId` param — accepts a path just like the document tools. */
@@ -135,7 +136,12 @@ export function registerDatabaseTools(server) {
135
136
  'Views default to a single table view; view types are table | board | gallery | calendar | timeline | list — ' +
136
137
  'a "board" view needs config.groupBy (a select/status property id), a "timeline"/"calendar" view needs ' +
137
138
  'config.dateProp. Row `values` are keyed by column NAME (or id); date values are ' +
138
- '{ start: "YYYY-MM-DDTHH:mm" or "YYYY-MM-DD", end? }, multi_select is string[], relation is an array of row ids.',
139
+ '{ start: "YYYY-MM-DDTHH:mm" or "YYYY-MM-DD", end? }, multi_select is string[], relation is an array of row ids. ' +
140
+ 'COST NOTE: by default, every row added to this table (now or later) queues that table for graph extraction ' +
141
+ '(embeddings + LLM) — fine for a handful of rows, expensive for a bulk-loaded roster/log table. Pass ' +
142
+ 'graphSync:false for tables you are about to bulk-load; flip it back with ' +
143
+ '`rootr_update_node({ graphSyncOff: true|false })` any time after. ' +
144
+ NODE_URL_HINT,
139
145
  annotations: { readOnlyHint: false, destructiveHint: false },
140
146
  inputSchema: {
141
147
  name: z.string().describe('Table name, e.g. "업무 목록"'),
@@ -215,10 +221,18 @@ export function registerDatabaseTools(server) {
215
221
  'calculation sheet, "FORM" a per-row intake. Default "DOCUMENT". This is only the default — a single ' +
216
222
  'row can be made something else with rootr_create_row_page.',
217
223
  ),
224
+ graphSync: z
225
+ .boolean()
226
+ .optional()
227
+ .describe(
228
+ 'Default true. Set to false for tables you will bulk-load (rosters, logs, imports) — otherwise ' +
229
+ 'every row added queues this table for graph extraction (embeddings + LLM), one job per row. ' +
230
+ 'Flip it later with rootr_update_node({ graphSyncOff: true|false }).',
231
+ ),
218
232
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
219
233
  },
220
234
  },
221
- async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, workspace }) => {
235
+ async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, graphSync, workspace }) => {
222
236
  try {
223
237
  const ws = requireWorkspace(workspace);
224
238
  const client = makeClient(ws);
@@ -233,6 +247,7 @@ export function registerDatabaseTools(server) {
233
247
  views,
234
248
  rows,
235
249
  rowPageType,
250
+ graphSync,
236
251
  });
237
252
  return jsonResult(database);
238
253
  } catch (err) {
@@ -271,9 +286,17 @@ export function registerDatabaseTools(server) {
271
286
  'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
272
287
  '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
273
288
  ),
289
+ calendarScale: z
290
+ .enum(['week', 'month', 'year'])
291
+ .optional()
292
+ .describe(
293
+ 'calendar view only: which span the calendar opens on — week / month / year. ' +
294
+ 'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
295
+ 'Saved on the view, so everyone who opens it sees that span.',
296
+ ),
274
297
  },
275
298
  },
276
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths }) => {
299
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale }) => {
277
300
  try {
278
301
  const client = makeClient();
279
302
  const id = await resolveNodeId(client, databaseId);
@@ -286,6 +309,7 @@ export function registerDatabaseTools(server) {
286
309
  filters,
287
310
  sort,
288
311
  colWidths,
312
+ calendarScale,
289
313
  });
290
314
  return jsonResult(view);
291
315
  } catch (err) {
@@ -350,6 +374,14 @@ export function registerDatabaseTools(server) {
350
374
  'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
351
375
  '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
352
376
  ),
377
+ calendarScale: z
378
+ .enum(['week', 'month', 'year'])
379
+ .optional()
380
+ .describe(
381
+ 'calendar view only: which span the calendar opens on — week / month / year. ' +
382
+ 'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
383
+ 'Saved on the view, so everyone who opens it sees that span.',
384
+ ),
353
385
  },
354
386
  },
355
387
  async ({
@@ -363,6 +395,7 @@ export function registerDatabaseTools(server) {
363
395
  filters,
364
396
  sort,
365
397
  colWidths,
398
+ calendarScale,
366
399
  }) => {
367
400
  try {
368
401
  const client = makeClient();
@@ -376,6 +409,7 @@ export function registerDatabaseTools(server) {
376
409
  filters,
377
410
  sort,
378
411
  colWidths,
412
+ calendarScale,
379
413
  });
380
414
  return jsonResult({ view });
381
415
  } catch (err) {
@@ -1048,4 +1082,113 @@ export function registerDatabaseTools(server) {
1048
1082
  }
1049
1083
  },
1050
1084
  );
1085
+
1086
+ // ── DB-BUTTON — 표 위에 두는 "누르면 일하는 버튼" ──────────────────────────
1087
+ //
1088
+ // 사람은 화면에서 버튼을 만들 수 있는데 에이전트는 못 만들면, "이 표에 접수 버튼
1089
+ // 하나 달아 줘"에서 멈춘다. 만들 수 있으면 눌러 줄 수도 있어야 한다.
1090
+
1091
+ server.registerTool(
1092
+ 'rootr_list_database_buttons',
1093
+ {
1094
+ title: 'List the action buttons on a database',
1095
+ description:
1096
+ 'List the action buttons pinned to the top of a Rootr (루터) DATABASE. Each button is a named, ' +
1097
+ 'ordered list of steps (add a row / set values / let AI fill a cell / open the new row) that runs ' +
1098
+ 'when someone presses it — the repeated setup a table owner would otherwise do by hand every time. ' +
1099
+ 'Returns `myLevel` too: editing the buttons needs MANAGE, pressing one only needs WRITE.',
1100
+ annotations: { readOnlyHint: true, destructiveHint: false },
1101
+ inputSchema: {
1102
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1103
+ },
1104
+ },
1105
+ async ({ databaseId }) => {
1106
+ try {
1107
+ const client = makeClient();
1108
+ const id = await resolveNodeId(client, databaseId);
1109
+ return jsonResult(await client.listDatabaseButtons(id));
1110
+ } catch (err) {
1111
+ return errorResult(err);
1112
+ }
1113
+ },
1114
+ );
1115
+
1116
+ server.registerTool(
1117
+ 'rootr_set_database_buttons',
1118
+ {
1119
+ title: 'Create or edit the action buttons on a database',
1120
+ description:
1121
+ 'Replace the WHOLE button list of a Rootr (루터) DATABASE (needs MANAGE). This is a full replace, not a ' +
1122
+ 'merge — call rootr_list_database_buttons first and send back the buttons you want to KEEP, or they are ' +
1123
+ 'dropped. Up to 12 buttons, 8 steps each.\n' +
1124
+ 'Steps run in order and the row a step created is what the NEXT step works on, which is how ' +
1125
+ '"add a row → have AI write its summary → open it" becomes one press:\n' +
1126
+ '· {kind:"add_row", values?} — make a row, optionally pre-filled. Column keys may be NAMES or ids.\n' +
1127
+ '· {kind:"update_rows", target:"created"|"selected", values} — set values on the rows this button just ' +
1128
+ 'made, or on the rows the person ticked in the table.\n' +
1129
+ '· {kind:"ai_fill", target, propId, prompt} — let AI write ONE cell. The prompt may quote the row with ' +
1130
+ '{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going.\n' +
1131
+ '· {kind:"open_row"} — open the row this button made (UI only; over the API it just reports the id).\n' +
1132
+ 'Values accept @today, @today+7 and @me — the server fills them in at press time, not now.\n' +
1133
+ 'A button never grants permission: it runs as whoever presses it.',
1134
+ annotations: { readOnlyHint: false, destructiveHint: true },
1135
+ inputSchema: {
1136
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1137
+ buttons: z
1138
+ .array(
1139
+ z.object({
1140
+ id: z.string().optional().describe('Keep an existing button by passing its id; omit to create one'),
1141
+ label: z.string().describe('Button text, e.g. "접수"'),
1142
+ icon: z.string().optional().describe('One emoji shown on the button'),
1143
+ steps: z
1144
+ .array(z.record(z.any()))
1145
+ .describe('Ordered steps — see the tool description for each kind (max 8)'),
1146
+ }),
1147
+ )
1148
+ .describe('The COMPLETE button list for this table. [] removes every button.'),
1149
+ },
1150
+ },
1151
+ async ({ databaseId, buttons }) => {
1152
+ try {
1153
+ const client = makeClient();
1154
+ const id = await resolveNodeId(client, databaseId);
1155
+ return jsonResult(await client.setDatabaseButtons(id, buttons));
1156
+ } catch (err) {
1157
+ return errorResult(err);
1158
+ }
1159
+ },
1160
+ );
1161
+
1162
+ server.registerTool(
1163
+ 'rootr_run_database_button',
1164
+ {
1165
+ title: 'Press an action button on a database',
1166
+ description:
1167
+ 'Press one button on a Rootr (루터) DATABASE and run its steps in order (needs WRITE). ' +
1168
+ 'Pass `rowIds` only when a step targets "selected" — that is the set of rows the person would have ' +
1169
+ 'ticked in the table; without it such a step reaches no rows and the server says so. ' +
1170
+ 'Returns `created` (the ARRAY of row ids it made, not a count), `updated` and `filled` counts, the ' +
1171
+ '`openRowId` a final "open_row" step points at, and — if the AI could not write a cell — `aiFailed` ' +
1172
+ 'with the reason (the rest of the button still ran). ' +
1173
+ 'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it.',
1174
+ annotations: { readOnlyHint: false, destructiveHint: false },
1175
+ inputSchema: {
1176
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1177
+ buttonId: z.string().describe('Button id from rootr_list_database_buttons'),
1178
+ rowIds: z
1179
+ .array(z.string())
1180
+ .optional()
1181
+ .describe('Rows for steps whose target is "selected". Omit for buttons that only add rows.'),
1182
+ },
1183
+ },
1184
+ async ({ databaseId, buttonId, rowIds }) => {
1185
+ try {
1186
+ const client = makeClient();
1187
+ const id = await resolveNodeId(client, databaseId);
1188
+ return jsonResult(await client.runDatabaseButton(id, buttonId, { rowIds }));
1189
+ } catch (err) {
1190
+ return errorResult(err);
1191
+ }
1192
+ },
1193
+ );
1051
1194
  }
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
 
3
3
  import { RootrApiError } from '../client.js';
4
4
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../resolve.js';
5
- import { textResult, jsonResult, errorResult, makeClient, NODE_TARGET_HINT } from './shared.js';
5
+ import { textResult, jsonResult, errorResult, makeClient, NODE_TARGET_HINT, NODE_URL_HINT } from './shared.js';
6
6
 
7
7
  /** Registers the original 6 document tools: list/read/append/edit/write/search. */
8
8
  export function registerDocumentTools(server) {
@@ -147,7 +147,8 @@ export function registerDocumentTools(server) {
147
147
  description:
148
148
  'Replace the ENTIRE content of a Rootr document, or create a new document at a path if it does not exist. ' +
149
149
  'This is DESTRUCTIVE — it discards whatever is currently there. Read the document first and pass its etag ' +
150
- 'as ifMatch to avoid clobbering concurrent changes. Prefer rootr_append or rootr_edit for incremental changes.',
150
+ 'as ifMatch to avoid clobbering concurrent changes. Prefer rootr_append or rootr_edit for incremental changes. ' +
151
+ `When this creates a new document, the result text includes its address (same idea as: ${NODE_URL_HINT}).`,
151
152
  annotations: { readOnlyHint: false, destructiveHint: true },
152
153
  inputSchema: {
153
154
  target: z.string().describe('Document path (starting with "/", created if missing) or node id, or a full rootr.io URL'),
@@ -174,7 +175,8 @@ export function registerDocumentTools(server) {
174
175
  content,
175
176
  });
176
177
  const id = created.id || (created.node && created.node.id);
177
- return textResult(`created ${resolved.path} (id ${id})`);
178
+ const url = created.url || (created.node && created.node.url);
179
+ return textResult(`created ${resolved.path} (id ${id})${url ? ` — ${url}` : ''}`);
178
180
  }
179
181
  const { etag } = await client.putDocument(resolved.id, content, { ifMatch });
180
182
  return textResult(`wrote (etag ${etag || 'n/a'})`);
@@ -194,11 +196,17 @@ export function registerDocumentTools(server) {
194
196
  annotations: { readOnlyHint: true, destructiveHint: false },
195
197
  inputSchema: {
196
198
  query: z.string().describe('Search query text'),
199
+ // rootr_list와 같은 이유로 필요하다: 이것만 안 받으면 다중 워크스페이스
200
+ // 연결에서 정체 모를 오류가 난다(#99).
201
+ workspace: z
202
+ .string()
203
+ .optional()
204
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
197
205
  },
198
206
  },
199
- async ({ query }) => {
207
+ async ({ query, workspace }) => {
200
208
  try {
201
- const client = makeClient();
209
+ const client = makeClient(workspace);
202
210
  const results = await client.search(query);
203
211
  const rows = results.map((r) => {
204
212
  const label = r.path || r.name || r.nodeId;
@@ -218,13 +226,19 @@ export function registerDocumentTools(server) {
218
226
  title: 'Comment on a Rootr document',
219
227
  description:
220
228
  'Add a comment to a Rootr (루터) document (optionally anchored to a block, or as a reply via parentId). ' +
229
+ 'DB-ROW-COMMENT — to comment on a **row of a table**, pass the DATABASE as `target` and the ROW id as `blockId`; that is exactly what the row screen does, and the row will show it above its body. ' +
230
+ 'Mentioning someone with @[userId] notifies them, and their notification opens that row and ' +
231
+ 'highlights this comment. ' +
221
232
  'Comments carry a user identity: the comment is attributed to the person who owns the key ' +
222
233
  '(account keys and workspace keys both carry an owner). A key with no owner cannot comment.',
223
234
  annotations: { readOnlyHint: false, destructiveHint: false },
224
235
  inputSchema: {
225
236
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
226
237
  body: z.string().describe('Comment text (markdown)'),
227
- blockId: z.string().optional().describe('Block id to anchor the comment to'),
238
+ blockId: z
239
+ .string()
240
+ .optional()
241
+ .describe('Block id to anchor the comment to — or a DATABASE ROW id to comment on that row'),
228
242
  parentId: z.string().optional().describe('Parent comment id, to reply in a thread'),
229
243
  },
230
244
  },
@@ -244,11 +258,16 @@ export function registerDocumentTools(server) {
244
258
  {
245
259
  title: 'List comments on a Rootr document',
246
260
  description:
247
- 'List the comment threads on a Rootr (루터) document, optionally filtered to one block. Works with any key.',
261
+ 'List the comment threads on a Rootr (루터) document, optionally filtered to one block. ' +
262
+ '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.',
248
264
  annotations: { readOnlyHint: true, destructiveHint: false },
249
265
  inputSchema: {
250
266
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
251
- blockId: z.string().optional().describe('Only comments anchored to this block'),
267
+ blockId: z
268
+ .string()
269
+ .optional()
270
+ .describe('Only comments anchored to this block — or a DATABASE ROW id for that row\'s comments'),
252
271
  },
253
272
  },
254
273
  async ({ target, blockId }) => {
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT, resolveOptionalNodeId } from './shared.js';
4
4
 
5
5
  const fieldShape = z
6
6
  .object({
@@ -88,7 +88,9 @@ export function registerFormTools(server) {
88
88
  'Create a new FORM node (fillable intake) in a Rootr (루터) workspace, optionally seeded with field ' +
89
89
  'definitions. Use FORM for surveys/request queues collected from people outside the tree — set ' +
90
90
  '`targetDatabaseId` to also mirror each response into a DATABASE\'s rows. ' +
91
- FIELDS_GUIDE,
91
+ FIELDS_GUIDE +
92
+ ' ' +
93
+ NODE_URL_HINT,
92
94
  annotations: { readOnlyHint: false, destructiveHint: false },
93
95
  inputSchema: {
94
96
  name: z.string().optional().describe('Form name/title'),
@@ -148,7 +150,8 @@ export function registerFormTools(server) {
148
150
  'rootr_read_form first and resend fields you want to keep (with their existing ids) so past responses ' +
149
151
  'stay attributable. Pass `targetDatabaseId: null` to unlink the mirrored DATABASE. ' +
150
152
  FIELDS_GUIDE,
151
- annotations: { readOnlyHint: false, destructiveHint: false },
153
+ // `fields`를 주면 필드 전체를 갈아끼운다 — 기존 id 를 안 보내면 응답 귀속이 끊긴다.
154
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
152
155
  inputSchema: {
153
156
  formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
154
157
  fields: z.array(fieldShape).optional().describe('COMPLETE fields array to set — omitted existing fields will be dropped'),
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { textResult, jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
3
+ import { textResult, jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT } from './shared.js';
4
4
 
5
5
  /** Issue tracker tools: create tracker, list/create/get/update issues, comment. */
6
6
  export function registerIssueTools(server) {
@@ -10,7 +10,8 @@ export function registerIssueTools(server) {
10
10
  title: 'Create an issue tracker in a Rootr workspace',
11
11
  description:
12
12
  'Create a new GitHub-style issue tracker document in a Rootr (루터) workspace. Returns the tracker with ' +
13
- 'its id, plus its default labels/types — use rootr_create_issue afterward to add issues to it.',
13
+ 'its id, plus its default labels/types — use rootr_create_issue afterward to add issues to it. ' +
14
+ NODE_URL_HINT,
14
15
  annotations: { readOnlyHint: false, destructiveHint: false },
15
16
  inputSchema: {
16
17
  name: z.string().describe('Name of the issue tracker'),
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT, resolveOptionalNodeId } from './shared.js';
4
4
 
5
5
  /** LOG datastore tools: create/update-fields/add-entries/query-entries/stats. */
6
6
  export function registerLogTools(server) {
@@ -12,7 +12,8 @@ export function registerLogTools(server) {
12
12
  'Create a new typed LOG store (a structured, queryable table-like node) in a Rootr (루터) workspace. ' +
13
13
  'Each field has a name/type; type is one of string|int|float|bool|datetime|json|enum|level|relation. ' +
14
14
  'Relation fields additionally need target (a node id to relate to) and optional many/relation flags. ' +
15
- 'Use rootr_add_log_entries afterward to insert rows, and rootr_query_log_entries / rootr_log_stats to read them back.',
15
+ 'Use rootr_add_log_entries afterward to insert rows, and rootr_query_log_entries / rootr_log_stats to read them back. ' +
16
+ NODE_URL_HINT,
16
17
  annotations: { readOnlyHint: false, destructiveHint: false },
17
18
  inputSchema: {
18
19
  name: z.string().describe('Name of the LOG store'),
@@ -95,11 +95,12 @@ export function registerMiscTools(server) {
95
95
  {
96
96
  title: 'Rename / re-icon / move any node',
97
97
  description:
98
- 'Change a Rootr (루터) node\'s NAME, ICON, TAGS, or PARENT — works for every node type ' +
99
- '(DOCUMENT, FOLDER, DATABASE, SPREADSHEET, FORM, …). Until now icons could only be set when a node ' +
98
+ 'Change a Rootr (루터) node\'s NAME, ICON, TAGS, PARENT, or graph auto-extraction setting — works for every ' +
99
+ 'node type (DOCUMENT, FOLDER, DATABASE, SPREADSHEET, FORM, …). Until now icons could only be set when a node ' +
100
100
  'was CREATED, so matching a migrated tree to its Notion icons was impossible without recreating ' +
101
101
  'everything. Only the fields you pass change. `parentId: null` moves the node to the workspace root; ' +
102
- 'a node cannot be moved inside its own subtree.',
102
+ 'a node cannot be moved inside its own subtree. `graphSyncOff: true` stops rows/edits from queuing this ' +
103
+ 'node (and DATABASE tables especially) for graph extraction — useful right before a bulk load.',
103
104
  annotations: { readOnlyHint: false, destructiveHint: false },
104
105
  inputSchema: {
105
106
  target: z.string().describe('Node path (starting with "/") or node id'),
@@ -111,9 +112,16 @@ export function registerMiscTools(server) {
111
112
  .nullable()
112
113
  .optional()
113
114
  .describe('Move under this folder/document/database (id or path). null = workspace root.'),
115
+ graphSyncOff: z
116
+ .boolean()
117
+ .optional()
118
+ .describe(
119
+ 'true = stop queuing this node for graph extraction (embeddings + LLM) on every edit; false = ' +
120
+ 're-enable it (and backfill affected documents).',
121
+ ),
114
122
  },
115
123
  },
116
- async ({ target, name, icon, tags, parentId }) => {
124
+ async ({ target, name, icon, tags, parentId, graphSyncOff }) => {
117
125
  try {
118
126
  const client = makeClient();
119
127
  const id = requireResolved(await resolveTarget(client, target));
@@ -122,7 +130,7 @@ export function registerMiscTools(server) {
122
130
  if (typeof parentId === 'string' && parentId) {
123
131
  parent = requireResolved(await resolveTarget(client, parentId));
124
132
  }
125
- const node = await client.updateNode(id, { name, icon, tags, parentId: parent });
133
+ const node = await client.updateNode(id, { name, icon, tags, parentId: parent, graphSyncOff });
126
134
  return jsonResult(node);
127
135
  } catch (err) {
128
136
  return errorResult(err);
@@ -148,7 +156,36 @@ export function registerMiscTools(server) {
148
156
  const resolved = await resolveTarget(client, target);
149
157
  const id = requireResolved(resolved);
150
158
  await client.deleteNode(id);
151
- return jsonResult({ deleted: id, note: 'moved to trash, recoverable' });
159
+ return jsonResult({
160
+ deleted: id,
161
+ note: 'moved to trash, recoverable — rootr_restore_node puts it back',
162
+ });
163
+ } catch (err) {
164
+ return errorResult(err);
165
+ }
166
+ },
167
+ );
168
+
169
+ server.registerTool(
170
+ 'rootr_restore_node',
171
+ {
172
+ title: 'Restore a deleted node from the trash',
173
+ description:
174
+ 'Put a trashed Rootr (루터) document, folder or table back where it was — the undo for ' +
175
+ 'rootr_delete_node, and the same thing Ctrl+Z does in the app. Pass the node id you got back ' +
176
+ 'from the delete (a path will NOT find it: a trashed node is no longer on the tree). ' +
177
+ 'Children that went to the trash with it come back too. Needs WRITE on the node.',
178
+ annotations: { readOnlyHint: false, destructiveHint: false },
179
+ inputSchema: {
180
+ nodeId: z
181
+ .string()
182
+ .describe('Node id of the trashed item (from the rootr_delete_node result or a notification)'),
183
+ },
184
+ },
185
+ async ({ nodeId }) => {
186
+ try {
187
+ const client = makeClient();
188
+ return jsonResult(await client.restoreNode(nodeId));
152
189
  } catch (err) {
153
190
  return errorResult(err);
154
191
  }