rootr-cli 0.14.0 → 0.16.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/lib/client.js CHANGED
@@ -350,11 +350,19 @@ export class RootrClient {
350
350
  return this.request(`/nodes/${encodeURIComponent(id)}`, { method: 'DELETE' });
351
351
  }
352
352
 
353
+ /**
354
+ * UNDO-DELETE — 휴지통에 든 것을 제자리로 되살린다.
355
+ * 화면의 Ctrl+Z 와 같은 일을 하는 자리다. 지운 사람이 아니어도 쓰기 권한이면 된다.
356
+ */
357
+ async restoreNode(id) {
358
+ return this.request(`/nodes/${encodeURIComponent(id)}/restore`, { method: 'POST' });
359
+ }
360
+
353
361
  /** Rename and/or move a node. `parentId: null` moves it to the workspace root. */
354
- async updateNode(id, { name, icon, tags, parentId, position } = {}) {
362
+ async updateNode(id, { name, icon, tags, parentId, position, graphSyncOff } = {}) {
355
363
  return this.request(`/nodes/${encodeURIComponent(id)}`, {
356
364
  method: 'PATCH',
357
- body: { name, icon, tags, parentId, position },
365
+ body: { name, icon, tags, parentId, position, graphSyncOff },
358
366
  });
359
367
  }
360
368
 
@@ -443,6 +451,32 @@ export class RootrClient {
443
451
  });
444
452
  }
445
453
 
454
+ // ── DB-BUTTON — 표 위에 두는 "누르면 일하는 버튼" ────────────────────────
455
+ //
456
+ // 화면에서만 만들 수 있으면, 에이전트는 표를 지어 놓고도 "접수 버튼 하나 달아
457
+ // 줘"를 못 한다. 만들 수 있으면 눌러 줄 수도 있어야 한다.
458
+
459
+ /** 이 표의 버튼 목록. `myLevel` 로 내가 고칠 수 있는지도 함께 온다. */
460
+ async listDatabaseButtons(databaseId) {
461
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/buttons`);
462
+ }
463
+
464
+ /** 버튼 목록을 **통째로** 갈아끼운다(MANAGE 필요). 남길 버튼도 같이 보내야 한다. */
465
+ async setDatabaseButtons(databaseId, buttons) {
466
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/buttons`, {
467
+ method: 'PUT',
468
+ body: { buttons },
469
+ });
470
+ }
471
+
472
+ /** 버튼 한 번 누르기. `rowIds` 는 "고른 행"을 대상으로 하는 단계가 있을 때만 쓴다. */
473
+ async runDatabaseButton(databaseId, buttonId, { rowIds } = {}) {
474
+ return this.request(
475
+ `/databases/${encodeURIComponent(databaseId)}/buttons/${encodeURIComponent(buttonId)}/run`,
476
+ { method: 'POST', body: { rowIds } },
477
+ );
478
+ }
479
+
446
480
  // ── PUBLIC-DOCS — 발행한 문서에 올라온 변경 제안 ────────────────────────
447
481
 
448
482
  async listDocumentCommits(documentId, status = 'OPEN') {
@@ -760,10 +794,13 @@ export class RootrClient {
760
794
  * building a table by adding columns one at a time made the order depend on
761
795
  * the order of the calls, which is how migrated tables came out scrambled.
762
796
  */
763
- async createDatabase(workspace, { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType } = {}) {
797
+ async createDatabase(
798
+ workspace,
799
+ { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType, graphSync } = {},
800
+ ) {
764
801
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/databases`, {
765
802
  method: 'POST',
766
- body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType },
803
+ body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType, graphSync },
767
804
  });
768
805
  }
769
806
 
@@ -784,11 +821,11 @@ export class RootrClient {
784
821
  */
785
822
  async createDatabaseView(
786
823
  databaseId,
787
- { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths } = {},
824
+ { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale } = {},
788
825
  ) {
789
826
  return this.request(`/databases/${encodeURIComponent(databaseId)}/views`, {
790
827
  method: 'POST',
791
- body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths },
828
+ body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale },
792
829
  });
793
830
  }
794
831
 
@@ -1159,6 +1196,36 @@ export class RootrClient {
1159
1196
  });
1160
1197
  }
1161
1198
 
1199
+ // ---- Pages (block-assembled web pages) ----------------------------------
1200
+
1201
+ /** PAGE 노드를 한 번에 만든다(자리 + 블록 + 테마). */
1202
+ async createPage(workspaceId, { name, parentId, path, createParents, autoRename, icon, blocks, config } = {}) {
1203
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/pages`, {
1204
+ method: 'POST',
1205
+ body: { name, parentId, path, createParents, autoRename, icon, blocks, config },
1206
+ });
1207
+ }
1208
+
1209
+ /** 페이지 정본: 블록 배열 + 설정(테마·강조색). */
1210
+ async getPage(pageId) {
1211
+ return this.request(`/pages/${encodeURIComponent(pageId)}`);
1212
+ }
1213
+
1214
+ /** blocks를 주면 **통째로 교체**된다 — 남길 블록도 같이 보내야 한다. */
1215
+ async updatePage(pageId, { blocks, config } = {}) {
1216
+ return this.request(`/pages/${encodeURIComponent(pageId)}`, { method: 'PATCH', body: { blocks, config } });
1217
+ }
1218
+
1219
+ /** 맨 뒤에 덧붙이기 — 있는 블록은 그대로 둔다. */
1220
+ async appendPageBlocks(pageId, blocks) {
1221
+ return this.request(`/pages/${encodeURIComponent(pageId)}/blocks`, { method: 'POST', body: { blocks } });
1222
+ }
1223
+
1224
+ /** 라이브 블록이 지금 무엇을 보여 주는가(집계·행·차트 값). */
1225
+ async getPageLiveData(pageId) {
1226
+ return this.request(`/pages/${encodeURIComponent(pageId)}/live-data`);
1227
+ }
1228
+
1162
1229
  // ---- Presentations (slide decks) ----------------------------------------
1163
1230
 
1164
1231
  async createPresentation(workspaceId, { name, parentId, theme, slides, config, icon } = {}) {
@@ -1673,4 +1740,48 @@ export class RootrClient {
1673
1740
  }
1674
1741
  return data;
1675
1742
  }
1743
+
1744
+ // ── MEM-001 에이전트 메모리 ────────────────────────────────────────────────
1745
+ //
1746
+ // 판단은 부르는 쪽 클로드가 한다. 저장·회상·고치기·잊기 네 문을 연다.
1747
+
1748
+ /** 기억한다(워크스페이스 공유). 같은 기억이 있으면 서버가 갈아끼운다(replaced=true). */
1749
+ async remember(body) {
1750
+ return this.request(`/workspaces/${encodeURIComponent(this.workspace)}/memories`, {
1751
+ method: 'POST',
1752
+ body,
1753
+ });
1754
+ }
1755
+
1756
+ /** 꺼내 온다. 인자가 없으면 이 워크스페이스의 기억 전부. */
1757
+ async recall({ query, kind, limit } = {}) {
1758
+ const qs = new URLSearchParams();
1759
+ if (query) qs.set('query', query);
1760
+ if (kind) qs.set('kind', kind);
1761
+ if (limit) qs.set('limit', String(limit));
1762
+ const tail = qs.toString() ? `?${qs}` : '';
1763
+ return this.request(`/workspaces/${encodeURIComponent(this.workspace)}/memories${tail}`);
1764
+ }
1765
+
1766
+ /**
1767
+ * 고친다(2026-09-01) — id로 짚은 기억의 내용·종류·출처·관련을 바꾼다.
1768
+ * 생략한 칸은 안 바뀌고, 내용이 바뀌면 상태가 '유효'로 되돌아온다.
1769
+ * 화면이 같은 길을 쓴다.
1770
+ */
1771
+ async revise(memoryId, body) {
1772
+ return this.request(
1773
+ `/workspaces/${encodeURIComponent(this.workspace)}/memories/${encodeURIComponent(memoryId)}`,
1774
+ { method: 'PATCH', body },
1775
+ );
1776
+ }
1777
+
1778
+ /** 잊는다. 기본은 `폐기`로 내리기 — `hard`면 행을 실제로 지운다. */
1779
+ async forget(memoryId, { hard } = {}) {
1780
+ const tail = hard ? '?hard=true' : '';
1781
+ return this.request(
1782
+ `/workspaces/${encodeURIComponent(this.workspace)}/memories/${encodeURIComponent(memoryId)}${tail}`,
1783
+ { method: 'DELETE' },
1784
+ );
1785
+ }
1786
+
1676
1787
  }
@@ -135,7 +135,11 @@ export function registerDatabaseTools(server) {
135
135
  'Views default to a single table view; view types are table | board | gallery | calendar | timeline | list — ' +
136
136
  'a "board" view needs config.groupBy (a select/status property id), a "timeline"/"calendar" view needs ' +
137
137
  '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.',
138
+ '{ start: "YYYY-MM-DDTHH:mm" or "YYYY-MM-DD", end? }, multi_select is string[], relation is an array of row ids. ' +
139
+ 'COST NOTE: by default, every row added to this table (now or later) queues that table for graph extraction ' +
140
+ '(embeddings + LLM) — fine for a handful of rows, expensive for a bulk-loaded roster/log table. Pass ' +
141
+ 'graphSync:false for tables you are about to bulk-load; flip it back with ' +
142
+ '`rootr_update_node({ graphSyncOff: true|false })` any time after.',
139
143
  annotations: { readOnlyHint: false, destructiveHint: false },
140
144
  inputSchema: {
141
145
  name: z.string().describe('Table name, e.g. "업무 목록"'),
@@ -215,10 +219,18 @@ export function registerDatabaseTools(server) {
215
219
  'calculation sheet, "FORM" a per-row intake. Default "DOCUMENT". This is only the default — a single ' +
216
220
  'row can be made something else with rootr_create_row_page.',
217
221
  ),
222
+ graphSync: z
223
+ .boolean()
224
+ .optional()
225
+ .describe(
226
+ 'Default true. Set to false for tables you will bulk-load (rosters, logs, imports) — otherwise ' +
227
+ 'every row added queues this table for graph extraction (embeddings + LLM), one job per row. ' +
228
+ 'Flip it later with rootr_update_node({ graphSyncOff: true|false }).',
229
+ ),
218
230
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
219
231
  },
220
232
  },
221
- async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, workspace }) => {
233
+ async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, graphSync, workspace }) => {
222
234
  try {
223
235
  const ws = requireWorkspace(workspace);
224
236
  const client = makeClient(ws);
@@ -233,6 +245,7 @@ export function registerDatabaseTools(server) {
233
245
  views,
234
246
  rows,
235
247
  rowPageType,
248
+ graphSync,
236
249
  });
237
250
  return jsonResult(database);
238
251
  } catch (err) {
@@ -271,9 +284,17 @@ export function registerDatabaseTools(server) {
271
284
  'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
272
285
  '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
273
286
  ),
287
+ calendarScale: z
288
+ .enum(['week', 'month', 'year'])
289
+ .optional()
290
+ .describe(
291
+ 'calendar view only: which span the calendar opens on — week / month / year. ' +
292
+ 'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
293
+ 'Saved on the view, so everyone who opens it sees that span.',
294
+ ),
274
295
  },
275
296
  },
276
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths }) => {
297
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale }) => {
277
298
  try {
278
299
  const client = makeClient();
279
300
  const id = await resolveNodeId(client, databaseId);
@@ -286,6 +307,7 @@ export function registerDatabaseTools(server) {
286
307
  filters,
287
308
  sort,
288
309
  colWidths,
310
+ calendarScale,
289
311
  });
290
312
  return jsonResult(view);
291
313
  } catch (err) {
@@ -350,6 +372,14 @@ export function registerDatabaseTools(server) {
350
372
  'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
351
373
  '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
352
374
  ),
375
+ calendarScale: z
376
+ .enum(['week', 'month', 'year'])
377
+ .optional()
378
+ .describe(
379
+ 'calendar view only: which span the calendar opens on — week / month / year. ' +
380
+ 'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
381
+ 'Saved on the view, so everyone who opens it sees that span.',
382
+ ),
353
383
  },
354
384
  },
355
385
  async ({
@@ -363,6 +393,7 @@ export function registerDatabaseTools(server) {
363
393
  filters,
364
394
  sort,
365
395
  colWidths,
396
+ calendarScale,
366
397
  }) => {
367
398
  try {
368
399
  const client = makeClient();
@@ -376,6 +407,7 @@ export function registerDatabaseTools(server) {
376
407
  filters,
377
408
  sort,
378
409
  colWidths,
410
+ calendarScale,
379
411
  });
380
412
  return jsonResult({ view });
381
413
  } catch (err) {
@@ -1048,4 +1080,113 @@ export function registerDatabaseTools(server) {
1048
1080
  }
1049
1081
  },
1050
1082
  );
1083
+
1084
+ // ── DB-BUTTON — 표 위에 두는 "누르면 일하는 버튼" ──────────────────────────
1085
+ //
1086
+ // 사람은 화면에서 버튼을 만들 수 있는데 에이전트는 못 만들면, "이 표에 접수 버튼
1087
+ // 하나 달아 줘"에서 멈춘다. 만들 수 있으면 눌러 줄 수도 있어야 한다.
1088
+
1089
+ server.registerTool(
1090
+ 'rootr_list_database_buttons',
1091
+ {
1092
+ title: 'List the action buttons on a database',
1093
+ description:
1094
+ 'List the action buttons pinned to the top of a Rootr (루터) DATABASE. Each button is a named, ' +
1095
+ 'ordered list of steps (add a row / set values / let AI fill a cell / open the new row) that runs ' +
1096
+ 'when someone presses it — the repeated setup a table owner would otherwise do by hand every time. ' +
1097
+ 'Returns `myLevel` too: editing the buttons needs MANAGE, pressing one only needs WRITE.',
1098
+ annotations: { readOnlyHint: true, destructiveHint: false },
1099
+ inputSchema: {
1100
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1101
+ },
1102
+ },
1103
+ async ({ databaseId }) => {
1104
+ try {
1105
+ const client = makeClient();
1106
+ const id = await resolveNodeId(client, databaseId);
1107
+ return jsonResult(await client.listDatabaseButtons(id));
1108
+ } catch (err) {
1109
+ return errorResult(err);
1110
+ }
1111
+ },
1112
+ );
1113
+
1114
+ server.registerTool(
1115
+ 'rootr_set_database_buttons',
1116
+ {
1117
+ title: 'Create or edit the action buttons on a database',
1118
+ description:
1119
+ 'Replace the WHOLE button list of a Rootr (루터) DATABASE (needs MANAGE). This is a full replace, not a ' +
1120
+ 'merge — call rootr_list_database_buttons first and send back the buttons you want to KEEP, or they are ' +
1121
+ 'dropped. Up to 12 buttons, 8 steps each.\n' +
1122
+ 'Steps run in order and the row a step created is what the NEXT step works on, which is how ' +
1123
+ '"add a row → have AI write its summary → open it" becomes one press:\n' +
1124
+ '· {kind:"add_row", values?} — make a row, optionally pre-filled. Column keys may be NAMES or ids.\n' +
1125
+ '· {kind:"update_rows", target:"created"|"selected", values} — set values on the rows this button just ' +
1126
+ 'made, or on the rows the person ticked in the table.\n' +
1127
+ '· {kind:"ai_fill", target, propId, prompt} — let AI write ONE cell. The prompt may quote the row with ' +
1128
+ '{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going.\n' +
1129
+ '· {kind:"open_row"} — open the row this button made (UI only; over the API it just reports the id).\n' +
1130
+ 'Values accept @today, @today+7 and @me — the server fills them in at press time, not now.\n' +
1131
+ 'A button never grants permission: it runs as whoever presses it.',
1132
+ annotations: { readOnlyHint: false, destructiveHint: true },
1133
+ inputSchema: {
1134
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1135
+ buttons: z
1136
+ .array(
1137
+ z.object({
1138
+ id: z.string().optional().describe('Keep an existing button by passing its id; omit to create one'),
1139
+ label: z.string().describe('Button text, e.g. "접수"'),
1140
+ icon: z.string().optional().describe('One emoji shown on the button'),
1141
+ steps: z
1142
+ .array(z.record(z.any()))
1143
+ .describe('Ordered steps — see the tool description for each kind (max 8)'),
1144
+ }),
1145
+ )
1146
+ .describe('The COMPLETE button list for this table. [] removes every button.'),
1147
+ },
1148
+ },
1149
+ async ({ databaseId, buttons }) => {
1150
+ try {
1151
+ const client = makeClient();
1152
+ const id = await resolveNodeId(client, databaseId);
1153
+ return jsonResult(await client.setDatabaseButtons(id, buttons));
1154
+ } catch (err) {
1155
+ return errorResult(err);
1156
+ }
1157
+ },
1158
+ );
1159
+
1160
+ server.registerTool(
1161
+ 'rootr_run_database_button',
1162
+ {
1163
+ title: 'Press an action button on a database',
1164
+ description:
1165
+ 'Press one button on a Rootr (루터) DATABASE and run its steps in order (needs WRITE). ' +
1166
+ 'Pass `rowIds` only when a step targets "selected" — that is the set of rows the person would have ' +
1167
+ 'ticked in the table; without it such a step reaches no rows and the server says so. ' +
1168
+ 'Returns `created` (the ARRAY of row ids it made, not a count), `updated` and `filled` counts, the ' +
1169
+ '`openRowId` a final "open_row" step points at, and — if the AI could not write a cell — `aiFailed` ' +
1170
+ 'with the reason (the rest of the button still ran). ' +
1171
+ 'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it.',
1172
+ annotations: { readOnlyHint: false, destructiveHint: false },
1173
+ inputSchema: {
1174
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1175
+ buttonId: z.string().describe('Button id from rootr_list_database_buttons'),
1176
+ rowIds: z
1177
+ .array(z.string())
1178
+ .optional()
1179
+ .describe('Rows for steps whose target is "selected". Omit for buttons that only add rows.'),
1180
+ },
1181
+ },
1182
+ async ({ databaseId, buttonId, rowIds }) => {
1183
+ try {
1184
+ const client = makeClient();
1185
+ const id = await resolveNodeId(client, databaseId);
1186
+ return jsonResult(await client.runDatabaseButton(id, buttonId, { rowIds }));
1187
+ } catch (err) {
1188
+ return errorResult(err);
1189
+ }
1190
+ },
1191
+ );
1051
1192
  }
@@ -194,11 +194,17 @@ export function registerDocumentTools(server) {
194
194
  annotations: { readOnlyHint: true, destructiveHint: false },
195
195
  inputSchema: {
196
196
  query: z.string().describe('Search query text'),
197
+ // rootr_list와 같은 이유로 필요하다: 이것만 안 받으면 다중 워크스페이스
198
+ // 연결에서 정체 모를 오류가 난다(#99).
199
+ workspace: z
200
+ .string()
201
+ .optional()
202
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
197
203
  },
198
204
  },
199
- async ({ query }) => {
205
+ async ({ query, workspace }) => {
200
206
  try {
201
- const client = makeClient();
207
+ const client = makeClient(workspace);
202
208
  const results = await client.search(query);
203
209
  const rows = results.map((r) => {
204
210
  const label = r.path || r.name || r.nodeId;
@@ -218,13 +224,19 @@ export function registerDocumentTools(server) {
218
224
  title: 'Comment on a Rootr document',
219
225
  description:
220
226
  'Add a comment to a Rootr (루터) document (optionally anchored to a block, or as a reply via parentId). ' +
227
+ '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. ' +
228
+ 'Mentioning someone with @[userId] notifies them, and their notification opens that row and ' +
229
+ 'highlights this comment. ' +
221
230
  'Comments carry a user identity: the comment is attributed to the person who owns the key ' +
222
231
  '(account keys and workspace keys both carry an owner). A key with no owner cannot comment.',
223
232
  annotations: { readOnlyHint: false, destructiveHint: false },
224
233
  inputSchema: {
225
234
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
226
235
  body: z.string().describe('Comment text (markdown)'),
227
- blockId: z.string().optional().describe('Block id to anchor the comment to'),
236
+ blockId: z
237
+ .string()
238
+ .optional()
239
+ .describe('Block id to anchor the comment to — or a DATABASE ROW id to comment on that row'),
228
240
  parentId: z.string().optional().describe('Parent comment id, to reply in a thread'),
229
241
  },
230
242
  },
@@ -244,11 +256,16 @@ export function registerDocumentTools(server) {
244
256
  {
245
257
  title: 'List comments on a Rootr document',
246
258
  description:
247
- 'List the comment threads on a Rootr (루터) document, optionally filtered to one block. Works with any key.',
259
+ 'List the comment threads on a Rootr (루터) document, optionally filtered to one block. ' +
260
+ 'DB-ROW-COMMENT — for the comments on ONE ROW of a table, pass the DATABASE as `target` and the ' +
261
+ 'ROW id as `blockId`. Works with any key.',
248
262
  annotations: { readOnlyHint: true, destructiveHint: false },
249
263
  inputSchema: {
250
264
  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'),
265
+ blockId: z
266
+ .string()
267
+ .optional()
268
+ .describe('Only comments anchored to this block — or a DATABASE ROW id for that row\'s comments'),
252
269
  },
253
270
  },
254
271
  async ({ target, blockId }) => {
@@ -148,7 +148,8 @@ export function registerFormTools(server) {
148
148
  'rootr_read_form first and resend fields you want to keep (with their existing ids) so past responses ' +
149
149
  'stay attributable. Pass `targetDatabaseId: null` to unlink the mirrored DATABASE. ' +
150
150
  FIELDS_GUIDE,
151
- annotations: { readOnlyHint: false, destructiveHint: false },
151
+ // `fields`를 주면 필드 전체를 갈아끼운다 — 기존 id 를 안 보내면 응답 귀속이 끊긴다.
152
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
152
153
  inputSchema: {
153
154
  formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
154
155
  fields: z.array(fieldShape).optional().describe('COMPLETE fields array to set — omitted existing fields will be dropped'),
@@ -0,0 +1,185 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, textResult, errorResult, makeClient } from './shared.js';
4
+
5
+ /**
6
+ * MEM-001 — 에이전트 메모리.
7
+ *
8
+ * 기술적으로는 `rootr_add_row`/`rootr_list_rows`로도 되는 일이다. 그런데도 도구를
9
+ * 따로 파는 이유는 하나다 — **이름이 "기억하기"여야 클로드가 자기 메모리로 인식하고
10
+ * 쓴다.** "표에 행 추가"로는 아무도 안 부른다(계획서 §3.2).
11
+ *
12
+ * 경계 규칙을 도구 설명에 그대로 박아 둔다. 안 그러면 메모리가 "짧은 문서 더미"가
13
+ * 되면서 문서와 메모리 둘 다 망한다:
14
+ *
15
+ * 긴 건 문서로, 한 줄은 메모리로, 그리고 메모리는 반드시 문서를 가리킬 것.
16
+ */
17
+ export function registerMemoryTools(server) {
18
+ server.registerTool(
19
+ 'rootr_remember',
20
+ {
21
+ title: 'Remember a durable fact for this workspace',
22
+ description:
23
+ 'Save ONE short, present-tense fact that should change how you act next time in this Rootr (루터) ' +
24
+ 'workspace — a team convention, a decision and its reason, a gotcha you just hit, a person\'s ' +
25
+ 'preference. Memories are SHARED WITH THE WHOLE WORKSPACE, so everyone\'s assistant learns it too. ' +
26
+ 'Keep personal notes in your own memory, not here.\n\n' +
27
+ 'THE RULE: long things go in documents, one-liners go in memory, and a memory should point at a ' +
28
+ 'document. Save the lesson ("captures must run in the parent window with data URIs"), not the ' +
29
+ 'story — write the story as a document and pass its path as `source`.\n\n' +
30
+ 'Always pass `source` when the fact came from a document: that is what lets Rootr flag the memory ' +
31
+ 'as "needs checking" when that document changes. A memory with no source can never notice it went ' +
32
+ 'stale.\n\n' +
33
+ 'Saving the same fact twice is safe — Rootr replaces it instead of piling up duplicates.\n\n' +
34
+ 'EDITING: pass `memoryId` (the `id` from rootr_recall) to fix that exact memory in place — ' +
35
+ 'omit it to save a new one. Editing marks the memory 유효 again (a person or their assistant ' +
36
+ 'just re-confirmed it).',
37
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
38
+ inputSchema: {
39
+ memoryId: z
40
+ .string()
41
+ .optional()
42
+ .describe(
43
+ 'EDIT an existing memory instead of saving a new one — the `id` from rootr_recall. ' +
44
+ 'Only the fields you pass change; content edits set the status back to 유효.',
45
+ ),
46
+ content: z
47
+ .string()
48
+ .max(500)
49
+ .optional()
50
+ .describe(
51
+ 'The fact, as one present-tense line. Not a summary of a conversation — something that changes ' +
52
+ 'what you do next time. Required for a new memory; may be omitted with `memoryId` to edit ' +
53
+ 'only kind/source/related.',
54
+ ),
55
+ kind: z
56
+ .enum(['지시', '사람', '프로젝트', '참고'])
57
+ .optional()
58
+ .describe(
59
+ 'What kind of fact: 지시 = how the team wants work done, 사람 = someone\'s role/preference, ' +
60
+ '프로젝트 = ongoing work or constraints, 참고 = a pointer to an external resource',
61
+ ),
62
+ source: z
63
+ .string()
64
+ .optional()
65
+ .describe(
66
+ 'Where this came from: a document path starting with "/", or a node id. Strongly recommended — ' +
67
+ 'this is what makes the memory notice when it goes stale.',
68
+ ),
69
+ related: z
70
+ .array(z.string())
71
+ .optional()
72
+ .describe('What this memory is about — people, systems, customers. Used to group memories later.'),
73
+ workspace: z
74
+ .string()
75
+ .optional()
76
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
77
+ },
78
+ },
79
+ async ({ memoryId, content, kind, source, related, workspace }) => {
80
+ try {
81
+ const client = makeClient(workspace);
82
+ // memoryId가 왔으면 고치기, 없으면 새로 저장 — 같은 도구의 두 문.
83
+ if (memoryId) {
84
+ const result = await client.revise(memoryId, { content, kind, source, related });
85
+ const merged = result.mergedIds?.length
86
+ ? `\n같은 사실이 다른 기억(${result.mergedIds.length}건)에도 있어 폐기로 내렸습니다 — 목록은 그 행 하나뿐입니다.`
87
+ : '';
88
+ return textResult(
89
+ `고쳤습니다: ${result.content}\n상태: ${result.status}` +
90
+ (result.source ? ` · 출처: ${result.source}` : '') +
91
+ merged,
92
+ );
93
+ }
94
+ const result = await client.remember({ content, kind, source, related });
95
+ return textResult(
96
+ `${result.replaced ? '기억을 갈아끼웠습니다' : '기억했습니다'}: ${result.memory.content}\n` +
97
+ `상태: ${result.memory.status}` +
98
+ (result.memory.source ? ` · 출처: ${result.memory.source}` : '') +
99
+ (result.memory.sourceId
100
+ ? ''
101
+ : '\n(출처가 연결되지 않았습니다 — 이 기억은 나중에 낡아도 스스로 알아채지 못합니다.)'),
102
+ );
103
+ } catch (err) {
104
+ return errorResult(err);
105
+ }
106
+ },
107
+ );
108
+
109
+ server.registerTool(
110
+ 'rootr_recall',
111
+ {
112
+ title: 'Recall what this workspace already knows',
113
+ description:
114
+ 'Read the durable facts this Rootr (루터) workspace has learned — team conventions, decisions and ' +
115
+ 'their reasons, gotchas, people\'s preferences. Call this before you start real work in a ' +
116
+ 'workspace, and whenever you are about to make an assumption someone may have already settled.\n\n' +
117
+ 'Memories marked 확인필요 mean their source document changed — treat them as "was true, verify ' +
118
+ 'before relying on it".',
119
+ annotations: { readOnlyHint: true, destructiveHint: false },
120
+ inputSchema: {
121
+ query: z
122
+ .string()
123
+ .optional()
124
+ .describe('Optional keyword filter. Omit to get everything — the list is meant to be read whole.'),
125
+ kind: z.enum(['지시', '사람', '프로젝트', '참고']).optional().describe('Only this kind'),
126
+ limit: z.number().optional().describe('Max memories to return'),
127
+ workspace: z
128
+ .string()
129
+ .optional()
130
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
131
+ },
132
+ },
133
+ async ({ query, kind, limit, workspace }) => {
134
+ try {
135
+ const client = makeClient(workspace);
136
+ const result = await client.recall({ query, kind, limit });
137
+ if (!result.memories?.length) {
138
+ return textResult(
139
+ '아직 기억이 없습니다.\n' +
140
+ 'rootr_remember로 팀 규칙·결정·함정을 한 줄씩 남겨 두면, 다음 세션의 클로드가 그걸 읽고 시작합니다.',
141
+ );
142
+ }
143
+ return jsonResult(result);
144
+ } catch (err) {
145
+ return errorResult(err);
146
+ }
147
+ },
148
+ );
149
+
150
+ server.registerTool(
151
+ 'rootr_forget',
152
+ {
153
+ title: 'Retire a memory that is no longer true',
154
+ description:
155
+ 'Mark a memory as 폐기 (retired) — use this when a fact stopped being true, not when you merely ' +
156
+ 'disagree with it. Memory is meant to be pruned: a workspace with 500 stale memories is worse than ' +
157
+ 'one with 50 true ones.\n\n' +
158
+ 'Retiring keeps the row so the team can still see what used to be true and why it changed. Pass ' +
159
+ '`hard` only when the memory should never have been saved (wrong workspace, personal data that ' +
160
+ 'does not belong in a shared workspace).\n\n' +
161
+ 'If a fact merely CHANGED, call rootr_remember with the new wording instead — that replaces it.',
162
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
163
+ inputSchema: {
164
+ memoryId: z.string().describe('Memory id (the `id` from rootr_recall)'),
165
+ hard: z
166
+ .boolean()
167
+ .optional()
168
+ .describe('Delete the row outright instead of retiring it. Rarely right.'),
169
+ workspace: z
170
+ .string()
171
+ .optional()
172
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
173
+ },
174
+ },
175
+ async ({ memoryId, hard, workspace }) => {
176
+ try {
177
+ const client = makeClient(workspace);
178
+ const result = await client.forget(memoryId, { hard });
179
+ return textResult(result.removed ? '기억을 지웠습니다.' : '기억을 폐기 상태로 내렸습니다.');
180
+ } catch (err) {
181
+ return errorResult(err);
182
+ }
183
+ },
184
+ );
185
+ }