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.
- package/README.ko.md +107 -3
- package/README.md +86 -4
- package/bin/rootr.js +688 -13
- package/lib/client.js +250 -26
- package/lib/credentials.js +20 -2
- package/lib/mcp-tools/ask.js +6 -1
- package/lib/mcp-tools/attachments.js +31 -11
- package/lib/mcp-tools/booking.js +403 -0
- package/lib/mcp-tools/databases.js +490 -20
- package/lib/mcp-tools/documents.js +183 -16
- package/lib/mcp-tools/forms.js +63 -1
- package/lib/mcp-tools/logs.js +1 -1
- package/lib/mcp-tools/misc.js +9 -3
- package/lib/mcp-tools/pages.js +31 -7
- package/lib/mcp-tools/presentations.js +36 -12
- package/lib/mcp-tools/public.js +4 -1
- package/lib/mcp-tools/recordings.js +39 -15
- package/lib/mcp-tools/shared.js +28 -0
- package/lib/mcp-tools/workspaces.js +155 -77
- package/lib/mcp.js +3 -0
- package/lib/resolve.js +43 -1
- package/package.json +1 -1
|
@@ -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")
|
|
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()
|
|
@@ -198,9 +205,32 @@ export function registerDatabaseTools(server) {
|
|
|
198
205
|
'show_original',
|
|
199
206
|
'percent_checked',
|
|
200
207
|
]),
|
|
208
|
+
where: z
|
|
209
|
+
.array(
|
|
210
|
+
z.object({
|
|
211
|
+
property: z.string(),
|
|
212
|
+
op: z.enum([
|
|
213
|
+
'is',
|
|
214
|
+
'is_not',
|
|
215
|
+
'contains',
|
|
216
|
+
'not_contains',
|
|
217
|
+
'is_empty',
|
|
218
|
+
'is_not_empty',
|
|
219
|
+
'gt',
|
|
220
|
+
'gte',
|
|
221
|
+
'lt',
|
|
222
|
+
'lte',
|
|
223
|
+
]),
|
|
224
|
+
value: z.string().optional(),
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
.optional()
|
|
228
|
+
.describe(
|
|
229
|
+
'Filter the LINKED rows before aggregating, e.g. [{ property: "결과", op: "is", value: "부재" }]',
|
|
230
|
+
),
|
|
201
231
|
})
|
|
202
232
|
.optional()
|
|
203
|
-
.describe('rollup only: what to gather through which relation'),
|
|
233
|
+
.describe('rollup only: what to gather through which relation (where? filters the linked rows first)'),
|
|
204
234
|
prefix: z.string().optional().describe('unique_id only: e.g. "TASK" → "TASK-42"'),
|
|
205
235
|
}),
|
|
206
236
|
)
|
|
@@ -443,7 +473,13 @@ export function registerDatabaseTools(server) {
|
|
|
443
473
|
client.getDatabase(id),
|
|
444
474
|
client.listRows(id, { withBody: includeBody === true }),
|
|
445
475
|
]);
|
|
446
|
-
|
|
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 });
|
|
447
483
|
} catch (err) {
|
|
448
484
|
return errorResult(err);
|
|
449
485
|
}
|
|
@@ -564,20 +600,32 @@ export function registerDatabaseTools(server) {
|
|
|
564
600
|
'Add MANY rows to a Rootr (루터) DATABASE in one call. Use this instead of calling rootr_add_row in a ' +
|
|
565
601
|
'loop — a loop leaves a half-filled table when one call fails, and burns the read quota. ' +
|
|
566
602
|
'All-or-nothing: if any row fails validation nothing is created, and the error says which index broke. ' +
|
|
567
|
-
'Column keys may be property NAMES or ids. Up to 500 rows per call
|
|
603
|
+
'Column keys may be property NAMES or ids. Up to 500 rows per call.\n' +
|
|
604
|
+
'Pass `upsertKey` (a column name or id) to make the call IDEMPOTENT: a row whose key already exists is ' +
|
|
605
|
+
'UPDATED instead of duplicated, so a retried or replayed batch cannot double-load. Use it for every ' +
|
|
606
|
+
'migration and every sync from an outside system — without it a retry after a partial failure silently ' +
|
|
607
|
+
'creates duplicates (a real 903-row incident). The key column must be a plain stored column ' +
|
|
608
|
+
'(text/number/date/select), not a formula, rollup or relation. Returns `created` and `updated`.',
|
|
568
609
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
569
610
|
inputSchema: {
|
|
570
611
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
571
612
|
rows: z
|
|
572
613
|
.array(z.record(z.any()))
|
|
573
614
|
.describe('Each entry is one row: { "칸 이름 또는 id": 값, … }'),
|
|
615
|
+
upsertKey: z
|
|
616
|
+
.string()
|
|
617
|
+
.optional()
|
|
618
|
+
.describe(
|
|
619
|
+
'Column (name or id) holding an external id. Rows whose key already exists are updated, not duplicated — ' +
|
|
620
|
+
'makes the call safe to retry.',
|
|
621
|
+
),
|
|
574
622
|
},
|
|
575
623
|
},
|
|
576
|
-
async ({ databaseId, rows }) => {
|
|
624
|
+
async ({ databaseId, rows, upsertKey }) => {
|
|
577
625
|
try {
|
|
578
626
|
const client = makeClient();
|
|
579
627
|
const id = await resolveNodeId(client, databaseId);
|
|
580
|
-
return jsonResult(await client.bulkCreateRows(id, rows));
|
|
628
|
+
return jsonResult(await client.bulkCreateRows(id, rows, upsertKey));
|
|
581
629
|
} catch (err) {
|
|
582
630
|
return errorResult(err);
|
|
583
631
|
}
|
|
@@ -813,11 +861,23 @@ export function registerDatabaseTools(server) {
|
|
|
813
861
|
'relation | formula | rollup | created_time | last_edited_time | created_by | last_edited_by | unique_id ' +
|
|
814
862
|
'("title" is not allowed: a database already has exactly one title column). ' +
|
|
815
863
|
'For select/multi_select/status pass options: [{ name, color, group? }] (group ∈ todo|in_progress|done, status only). ' +
|
|
816
|
-
'For relation pass relationDatabaseId (the node id of the table it points at)
|
|
864
|
+
'For relation pass relationDatabaseId (the node id of the table it points at), and reverseName to make it ' +
|
|
865
|
+
'TWO-WAY: the other table gets a matching column with that name, and filling either side fills the other. ' +
|
|
866
|
+
'Use it whenever both tables need the link (calls↔applicants) — a one-way column means aggregates on the ' +
|
|
867
|
+
'other side are silently wrong until someone backfills them by hand. ' +
|
|
817
868
|
'For formula pass formula, e.g. "{수량} * {단가}" or "if({금액} >= 5000, \\"큰 건\\", \\"작은 건\\")" — ' +
|
|
818
869
|
'available functions: if, concat, length, upper, lower, contains, abs, round, floor, ceil, min, max, dateBetween, now, empty. ' +
|
|
819
|
-
'For rollup pass rollup: { relation, property?, function }
|
|
870
|
+
'For rollup pass rollup: { relation, property?, function, where? } ' +
|
|
871
|
+
'(function ∈ count|count_values|sum|average|min|max|show_original|percent_checked). ' +
|
|
872
|
+
'`where` filters the LINKED rows before aggregating — [{ property, op, value? }] with ' +
|
|
873
|
+
'op ∈ is|is_not|contains|not_contains|is_empty|is_not_empty|gt|gte|lt|lte. That is how you build ' +
|
|
874
|
+
'"how many of this applicant\'s calls were no-answer": { relation: "콜", function: "count", ' +
|
|
875
|
+
'where: [{ property: "결과", op: "is", value: "부재" }] }. ' +
|
|
876
|
+
'A rollup may also read a FORMULA column in the linked table, and min/max work on date columns ' +
|
|
877
|
+
'(that is how "last call date" is built). ' +
|
|
820
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). ' +
|
|
821
881
|
'created_time / last_edited_time / created_by / last_edited_by / unique_id / formula / rollup are filled in by the server — ' +
|
|
822
882
|
'they take no value and rootr_update_row will refuse to set them. ' +
|
|
823
883
|
'Returns the created column WITH ITS id.',
|
|
@@ -847,8 +907,9 @@ export function registerDatabaseTools(server) {
|
|
|
847
907
|
'created_by',
|
|
848
908
|
'last_edited_by',
|
|
849
909
|
'unique_id',
|
|
910
|
+
'vector',
|
|
850
911
|
])
|
|
851
|
-
.describe('Column type'),
|
|
912
|
+
.describe('Column type ("vector" = an embedding column for agents — pass dimensions)'),
|
|
852
913
|
options: z
|
|
853
914
|
.array(z.object({ name: z.string(), color: z.string(), group: z.string().optional() }))
|
|
854
915
|
.optional()
|
|
@@ -857,6 +918,13 @@ export function registerDatabaseTools(server) {
|
|
|
857
918
|
.string()
|
|
858
919
|
.optional()
|
|
859
920
|
.describe('relation only: the node id of the database this column points at'),
|
|
921
|
+
reverseName: z
|
|
922
|
+
.string()
|
|
923
|
+
.optional()
|
|
924
|
+
.describe(
|
|
925
|
+
'relation only: make it TWO-WAY — the linked table gets a matching column with this name, and filling ' +
|
|
926
|
+
'one side fills the other. Needs write access to that table too.',
|
|
927
|
+
),
|
|
860
928
|
formula: z
|
|
861
929
|
.string()
|
|
862
930
|
.optional()
|
|
@@ -875,13 +943,52 @@ export function registerDatabaseTools(server) {
|
|
|
875
943
|
'show_original',
|
|
876
944
|
'percent_checked',
|
|
877
945
|
]),
|
|
946
|
+
where: z
|
|
947
|
+
.array(
|
|
948
|
+
z.object({
|
|
949
|
+
property: z.string(),
|
|
950
|
+
op: z.enum([
|
|
951
|
+
'is',
|
|
952
|
+
'is_not',
|
|
953
|
+
'contains',
|
|
954
|
+
'not_contains',
|
|
955
|
+
'is_empty',
|
|
956
|
+
'is_not_empty',
|
|
957
|
+
'gt',
|
|
958
|
+
'gte',
|
|
959
|
+
'lt',
|
|
960
|
+
'lte',
|
|
961
|
+
]),
|
|
962
|
+
value: z.string().optional(),
|
|
963
|
+
}),
|
|
964
|
+
)
|
|
965
|
+
.optional()
|
|
966
|
+
.describe(
|
|
967
|
+
'Filter the LINKED rows before aggregating, e.g. [{ property: "결과", op: "is", value: "부재" }]',
|
|
968
|
+
),
|
|
878
969
|
})
|
|
879
970
|
.optional()
|
|
880
971
|
.describe('rollup only: what to gather through which relation'),
|
|
881
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
|
+
),
|
|
882
989
|
},
|
|
883
990
|
},
|
|
884
|
-
async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix }) => {
|
|
991
|
+
async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix, searchExcluded, dimensions }) => {
|
|
885
992
|
try {
|
|
886
993
|
const client = makeClient();
|
|
887
994
|
const id = await resolveNodeId(client, databaseId);
|
|
@@ -893,6 +1000,8 @@ export function registerDatabaseTools(server) {
|
|
|
893
1000
|
formula,
|
|
894
1001
|
rollup,
|
|
895
1002
|
prefix,
|
|
1003
|
+
searchExcluded,
|
|
1004
|
+
dimensions,
|
|
896
1005
|
});
|
|
897
1006
|
return jsonResult({ property });
|
|
898
1007
|
} catch (err) {
|
|
@@ -906,9 +1015,16 @@ export function registerDatabaseTools(server) {
|
|
|
906
1015
|
{
|
|
907
1016
|
title: 'Rename a column / set its options',
|
|
908
1017
|
description:
|
|
909
|
-
'Rename ONE column of a Rootr (루터) DATABASE,
|
|
910
|
-
'
|
|
911
|
-
'
|
|
1018
|
+
'Rename ONE column of a Rootr (루터) DATABASE, replace the choices of a select/multi_select column, edit a ' +
|
|
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. ' +
|
|
1023
|
+
'Call rootr_read_database first to get the exact propertyId.\n' +
|
|
1024
|
+
'`reverseName` on a relation column pairs it with the linked table: that table gets a matching column with ' +
|
|
1025
|
+
'this name and filling either side fills the other. Pass an empty string to unpair (neither column is deleted). ' +
|
|
1026
|
+
'Use it to make an existing one-way link two-way rather than rebuilding the table — a one-way column leaves ' +
|
|
1027
|
+
'every aggregate on the other side wrong until somebody backfills it by hand.',
|
|
912
1028
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
913
1029
|
inputSchema: {
|
|
914
1030
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -918,13 +1034,51 @@ export function registerDatabaseTools(server) {
|
|
|
918
1034
|
.array(z.object({ name: z.string(), color: z.string() }))
|
|
919
1035
|
.optional()
|
|
920
1036
|
.describe('select/multi_select only: REPLACES the full choice list'),
|
|
1037
|
+
formula: z.string().optional().describe('formula only: the new expression'),
|
|
1038
|
+
rollup: z
|
|
1039
|
+
.record(z.any())
|
|
1040
|
+
.optional()
|
|
1041
|
+
.describe('rollup only: { relation, property?, function, where? } — replaces the whole config'),
|
|
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'),
|
|
1045
|
+
reverseName: z
|
|
1046
|
+
.string()
|
|
1047
|
+
.optional()
|
|
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
|
+
),
|
|
921
1058
|
},
|
|
922
1059
|
},
|
|
923
|
-
async ({ databaseId, propertyId, name, options }) => {
|
|
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
|
+
}
|
|
924
1070
|
try {
|
|
925
1071
|
const client = makeClient();
|
|
926
1072
|
const id = await resolveNodeId(client, databaseId);
|
|
927
|
-
const property = await client.updateColumn(id, propertyId, {
|
|
1073
|
+
const property = await client.updateColumn(id, propertyId, {
|
|
1074
|
+
name,
|
|
1075
|
+
options,
|
|
1076
|
+
formula,
|
|
1077
|
+
rollup,
|
|
1078
|
+
prefix,
|
|
1079
|
+
reverseName,
|
|
1080
|
+
searchExcluded,
|
|
1081
|
+
});
|
|
928
1082
|
return jsonResult({ property });
|
|
929
1083
|
} catch (err) {
|
|
930
1084
|
return errorResult(err);
|
|
@@ -1064,7 +1218,10 @@ export function registerDatabaseTools(server) {
|
|
|
1064
1218
|
description:
|
|
1065
1219
|
'Remove ONE column from a Rootr (루터) DATABASE. The values stored under it are NOT erased from the rows, ' +
|
|
1066
1220
|
'so re-adding a column is not enough to bring them back in the UI — confirm with the user before calling this. ' +
|
|
1067
|
-
'
|
|
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).',
|
|
1068
1225
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1069
1226
|
inputSchema: {
|
|
1070
1227
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -1123,14 +1280,20 @@ export function registerDatabaseTools(server) {
|
|
|
1123
1280
|
'dropped. Up to 12 buttons, 8 steps each.\n' +
|
|
1124
1281
|
'Steps run in order and the row a step created is what the NEXT step works on, which is how ' +
|
|
1125
1282
|
'"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
|
|
1283
|
+
'· {kind:"add_row", values?, database?, link?} — make a row, optionally pre-filled. Column keys may be ' +
|
|
1284
|
+
'NAMES or ids. `database` (a table id or tree path like "/01. 채용/콜 이력") puts the row in ANOTHER ' +
|
|
1285
|
+
'table — omit it for this one. `link: { relation, from: "selected"|"created" }` wires the new row to the ' +
|
|
1286
|
+
'row the person pressed on, where `relation` is the relation column IN THE TARGET table that points back ' +
|
|
1287
|
+
'here. That pair is how "press 전화 on an applicant → a call row already linked to them" becomes one ' +
|
|
1288
|
+
'press instead of switching tables and searching the person again.\n' +
|
|
1127
1289
|
'· {kind:"update_rows", target:"created"|"selected", values} — set values on the rows this button just ' +
|
|
1128
1290
|
'made, or on the rows the person ticked in the table.\n' +
|
|
1129
1291
|
'· {kind:"ai_fill", target, propId, prompt} — let AI write ONE cell. The prompt may quote the row with ' +
|
|
1130
1292
|
'{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going.\n' +
|
|
1131
1293
|
'· {kind:"open_row"} — open the row this button made (UI only; over the API it just reports the id).\n' +
|
|
1132
1294
|
'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
|
|
1295
|
+
'A button never grants permission: it runs as whoever presses it — writing into another table needs ' +
|
|
1296
|
+
'write access there too.',
|
|
1134
1297
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1135
1298
|
inputSchema: {
|
|
1136
1299
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -1168,7 +1331,8 @@ export function registerDatabaseTools(server) {
|
|
|
1168
1331
|
'Pass `rowIds` only when a step targets "selected" — that is the set of rows the person would have ' +
|
|
1169
1332
|
'ticked in the table; without it such a step reaches no rows and the server says so. ' +
|
|
1170
1333
|
'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
|
|
1334
|
+
'`openRowId` a final "open_row" step points at (with `openDatabaseId` when that row is in another ' +
|
|
1335
|
+
'table), `createdIn` telling which table each new row landed in, and — if the AI could not write a cell — `aiFailed` ' +
|
|
1172
1336
|
'with the reason (the rest of the button still ran). ' +
|
|
1173
1337
|
'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it.',
|
|
1174
1338
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
@@ -1191,4 +1355,310 @@ export function registerDatabaseTools(server) {
|
|
|
1191
1355
|
}
|
|
1192
1356
|
},
|
|
1193
1357
|
);
|
|
1358
|
+
|
|
1359
|
+
// ---- DB-AUTO: "값이 이렇게 되면 이 일을 해라" ----------------------------
|
|
1360
|
+
// 2026-09-18 클린베테랑 요청 4번. 버튼과 같은 단계 문법을 쓰되 사람이 안 눌러도 돈다.
|
|
1361
|
+
|
|
1362
|
+
server.registerTool(
|
|
1363
|
+
'rootr_list_database_automations',
|
|
1364
|
+
{
|
|
1365
|
+
title: 'List the automatic rules on a database',
|
|
1366
|
+
description:
|
|
1367
|
+
'Show the automatic rules of a Rootr (루터) DATABASE — "when a value becomes X, do Y". Call this before ' +
|
|
1368
|
+
'rootr_set_database_automations, which replaces the whole list.',
|
|
1369
|
+
annotations: { readOnlyHint: true },
|
|
1370
|
+
inputSchema: { databaseId: z.string().describe(DATABASE_TARGET_DESC) },
|
|
1371
|
+
},
|
|
1372
|
+
async ({ databaseId }) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const client = makeClient();
|
|
1375
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1376
|
+
return jsonResult(await client.listDatabaseAutomations(id));
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
return errorResult(err);
|
|
1379
|
+
}
|
|
1380
|
+
},
|
|
1381
|
+
);
|
|
1382
|
+
|
|
1383
|
+
server.registerTool(
|
|
1384
|
+
'rootr_set_database_automations',
|
|
1385
|
+
{
|
|
1386
|
+
title: 'Create or edit the automatic rules on a database',
|
|
1387
|
+
description:
|
|
1388
|
+
'Replace the WHOLE automatic-rule list of a Rootr (루터) DATABASE (needs MANAGE). Full replace, not a ' +
|
|
1389
|
+
'merge — send back the rules you want to KEEP. Up to 10 rules, 8 steps each.\n' +
|
|
1390
|
+
'A rule is { label, enabled?, on, when?, schedule?, steps } and the steps are the SAME ones a button ' +
|
|
1391
|
+
'uses (add_row / update_rows / ai_fill), so there is nothing new to learn. The row that woke the rule ' +
|
|
1392
|
+
'arrives in the steps as target "selected".\n' +
|
|
1393
|
+
'· on: ["updated"] | ["created"] | ["schedule"] (one or more).\n' +
|
|
1394
|
+
'· when: { property, op, value? } — op ∈ changed_to|changed|is|is_not|contains|gt|gte|lt|lte|is_empty|is_not_empty. ' +
|
|
1395
|
+
'Prefer `changed_to` for state transitions: "is" is true on EVERY later edit too, so a rule that creates ' +
|
|
1396
|
+
'follow-up rows would create them again and again.\n' +
|
|
1397
|
+
'· schedule: { at: "09:00", days?: [1,2,3,4,5] } — required when on includes "schedule". Korean time, ' +
|
|
1398
|
+
'once a day; `when` then picks WHICH rows to act on and may use @today / @today+7 in its value.\n' +
|
|
1399
|
+
'Typical uses from the field: 투입확정 → create three follow-up-call rows at +7/+30/+90 days; ' +
|
|
1400
|
+
'부재 3회 초과 → set the applicant to 보류; every morning at 09:00 → flag rows whose 재통화 예정일 is today.\n' +
|
|
1401
|
+
'Rules run as the person who SAVED them, never with more permission than that. A rule that a computed ' +
|
|
1402
|
+
'column (rollup/formula) triggers must use a value comparison, not changed/changed_to — the server ' +
|
|
1403
|
+
'refuses those instead of quietly never firing.',
|
|
1404
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1405
|
+
inputSchema: {
|
|
1406
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1407
|
+
automations: z
|
|
1408
|
+
.array(
|
|
1409
|
+
z.object({
|
|
1410
|
+
id: z.string().optional().describe('Keep an existing rule by passing its id; omit to create one'),
|
|
1411
|
+
label: z.string().describe('What this rule is for, e.g. "투입확정 해피콜 3건"'),
|
|
1412
|
+
enabled: z.boolean().optional().describe('false pauses it without deleting it'),
|
|
1413
|
+
on: z
|
|
1414
|
+
.array(z.enum(['created', 'updated', 'schedule']))
|
|
1415
|
+
.optional()
|
|
1416
|
+
.describe('When it wakes up. Defaults to ["updated"].'),
|
|
1417
|
+
when: z
|
|
1418
|
+
.object({
|
|
1419
|
+
property: z.string().describe('Column to watch (name or id)'),
|
|
1420
|
+
op: z.enum([
|
|
1421
|
+
'changed_to',
|
|
1422
|
+
'changed',
|
|
1423
|
+
'is',
|
|
1424
|
+
'is_not',
|
|
1425
|
+
'contains',
|
|
1426
|
+
'gt',
|
|
1427
|
+
'gte',
|
|
1428
|
+
'lt',
|
|
1429
|
+
'lte',
|
|
1430
|
+
'is_empty',
|
|
1431
|
+
'is_not_empty',
|
|
1432
|
+
]),
|
|
1433
|
+
value: z.string().optional(),
|
|
1434
|
+
})
|
|
1435
|
+
.optional()
|
|
1436
|
+
.describe('Omit to run on every matching event'),
|
|
1437
|
+
schedule: z
|
|
1438
|
+
.object({
|
|
1439
|
+
at: z.string().describe('"HH:MM", Korean time'),
|
|
1440
|
+
days: z.array(z.number()).optional().describe('0=Sun … 6=Sat; omit for every day'),
|
|
1441
|
+
})
|
|
1442
|
+
.optional(),
|
|
1443
|
+
steps: z
|
|
1444
|
+
.array(z.record(z.any()))
|
|
1445
|
+
.describe('Ordered steps — same syntax as a button (max 8; "open_row" is not allowed here)'),
|
|
1446
|
+
}),
|
|
1447
|
+
)
|
|
1448
|
+
.describe('The COMPLETE rule list for this table. [] removes every rule.'),
|
|
1449
|
+
},
|
|
1450
|
+
},
|
|
1451
|
+
async ({ databaseId, automations }) => {
|
|
1452
|
+
try {
|
|
1453
|
+
const client = makeClient();
|
|
1454
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1455
|
+
return jsonResult(await client.setDatabaseAutomations(id, automations));
|
|
1456
|
+
} catch (err) {
|
|
1457
|
+
return errorResult(err);
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1460
|
+
);
|
|
1461
|
+
|
|
1462
|
+
// ---- XLSX-FOLD: 시트 복제형 엑셀을 표 하나로 --------------------------
|
|
1463
|
+
|
|
1464
|
+
server.registerTool(
|
|
1465
|
+
'rootr_import_folded_sheets',
|
|
1466
|
+
{
|
|
1467
|
+
title: 'Import a sheet-per-record Excel file as ONE table',
|
|
1468
|
+
description:
|
|
1469
|
+
'Fold a workbook that keeps ONE SHEET PER RECORD (783 sheets, one per site; 1,281, one per interview) ' +
|
|
1470
|
+
'into a single Rootr (루터) DATABASE. Very common in small-company Excel, and until now it needed a ' +
|
|
1471
|
+
'hand-written parser.\n' +
|
|
1472
|
+
'· mode "vertical" — one sheet IS one row: label in the left cell, value to its right. Sheet name is kept ' +
|
|
1473
|
+
'as a column.\n' +
|
|
1474
|
+
'· mode "header" — every sheet repeats the same header row; all their data rows are merged and the sheet ' +
|
|
1475
|
+
'name becomes a column (that is the month, or the branch).\n' +
|
|
1476
|
+
'Omit mode and the server works it out from the file. ALWAYS call with dryRun:true first and show the ' +
|
|
1477
|
+
'person the detected mode, columns and the first rows — folding 783 sheets the wrong way is far more ' +
|
|
1478
|
+
'expensive to undo than to check. Pass upsertKey to make re-imports idempotent.',
|
|
1479
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1480
|
+
inputSchema: {
|
|
1481
|
+
workspaceId: z.string().describe('Workspace id'),
|
|
1482
|
+
xlsx: z.string().describe('The .xlsx file, base64-encoded'),
|
|
1483
|
+
dryRun: z.boolean().optional().describe('true = show what would happen, create nothing'),
|
|
1484
|
+
mode: z.enum(['vertical', 'header']).optional(),
|
|
1485
|
+
sheetNameColumn: z.string().optional().describe('Column name for the sheet name'),
|
|
1486
|
+
skipSheets: z.array(z.string()).optional().describe('Sheet names to ignore (템플릿·설명 sheets)'),
|
|
1487
|
+
headerRow: z.number().optional().describe('header mode: which row holds the headers (1-based)'),
|
|
1488
|
+
name: z.string().optional().describe('Name for the new table'),
|
|
1489
|
+
parentId: z.string().optional().describe('Folder to create it under'),
|
|
1490
|
+
databaseId: z.string().optional().describe('Load into an EXISTING table instead of creating one'),
|
|
1491
|
+
upsertKey: z.string().optional().describe('Column holding an external id, so re-imports update instead of duplicate'),
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
async ({ workspaceId, ...rest }) => {
|
|
1495
|
+
try {
|
|
1496
|
+
const client = makeClient();
|
|
1497
|
+
return jsonResult(await client.importFoldedSheets(workspaceId, rest));
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
return errorResult(err);
|
|
1500
|
+
}
|
|
1501
|
+
},
|
|
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
|
+
);
|
|
1194
1664
|
}
|