rootr-cli 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -97,10 +97,17 @@ function hintFor(status, body) {
97
97
  }
98
98
 
99
99
  export class RootrClient {
100
- constructor({ apiKey, workspace, baseUrl, defaultHeaders }) {
100
+ constructor({ apiKey, workspace, baseUrl, publicBaseUrl, defaultHeaders }) {
101
101
  this.apiKey = apiKey;
102
102
  this.workspace = workspace;
103
103
  this.baseUrl = (baseUrl || '').replace(/\/+$/, '');
104
+ /**
105
+ * ISSUE-61 — 사람·문서에 건네는 주소(첨부 `/raw` 등)의 뿌리. 로컬 CLI 는 baseUrl 이 곧 밖에서
106
+ * 보이는 주소라 비워 둔다. 원격 커넥터는 API 를 127.0.0.1 로 되부르므로(baseUrl) 그 주소를
107
+ * 그대로 문서에 넣으면 사람 화면에서 그림이 깨진다 — 호스티드 전송 계층이 공개 주소를 따로 넘긴다.
108
+ * 요청을 보내는 데는 절대 쓰지 않는다(그건 언제나 baseUrl).
109
+ */
110
+ this.publicBaseUrl = (publicBaseUrl || '').replace(/\/+$/, '') || this.baseUrl;
104
111
  /** Sent on every request — the hosted transport forwards Accept-Language
105
112
  * here so API error text comes back in the caller's language. */
106
113
  this.defaultHeaders = defaultHeaders || {};
@@ -247,6 +254,32 @@ export class RootrClient {
247
254
  }
248
255
  }
249
256
 
257
+ /** Resolve a published `/p/{slug}` public URL to a node in the active workspace. */
258
+ async getNodeBySlug(slug) {
259
+ this._requireWorkspace();
260
+ try {
261
+ const data = await this.request(
262
+ `/workspaces/${encodeURIComponent(this.workspace)}/nodes/by-slug?slug=${encodeURIComponent(slug)}`,
263
+ );
264
+ return data;
265
+ } catch (err) {
266
+ if (err instanceof RootrApiError && err.status === 404) return null;
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * ISSUE-127 — 공개 발행본을 읽는다: `/p/{slug}` 화면이 받는 것과 같은 응답(`GET /public/docs/:slug`).
273
+ * 로그인 없이 누구나 읽는 경로라 키가 무엇이든 더 열리는 것이 없다 — 발행한 판만 주고, 발행을 내렸거나
274
+ * 만료·삭제된 것은 404, 비밀번호가 걸린 것은 401(`PUBLIC_DOC_PASSWORD`). 통과증(`pass`)은 일부러 받지 않는다.
275
+ */
276
+ async getPublicDocument(slug) {
277
+ // ISSUE-127 — 이 읽기는 요금제 조회 한도에 1회로 센다(내 문서 읽기와 같다). 계정 키(PAT)는 워크스페이스에 묶여 있지 않아
278
+ // 서버가 어느 워크스페이스의 한도로 셀지 모른다 — 지금 연결된 워크스페이스를 알려 준다(워크스페이스 키는 서버가 키에서 안다).
279
+ const qs = this.workspace ? `?workspaceId=${encodeURIComponent(this.workspace)}` : '';
280
+ return this.request(`/public/docs/${encodeURIComponent(slug)}${qs}`);
281
+ }
282
+
250
283
  async createNode({ type = 'DOCUMENT', path, createParents = true, autoRename = false, content }) {
251
284
  this._requireWorkspace();
252
285
  return this.request(`/workspaces/${encodeURIComponent(this.workspace)}/nodes`, {
@@ -307,10 +340,29 @@ export class RootrClient {
307
340
  returnResponse: true,
308
341
  });
309
342
  const etag = res.headers.get('etag') || res.headers.get('ETag') || undefined;
310
- return { etag };
343
+ // ISSUE-105 — 서버가 쓰기 응답에 실은 알림(HTML 주석이 저장 때 사라졌다 등)을 버리지 않는다.
344
+ let warnings;
345
+ try {
346
+ const text = await res.text();
347
+ warnings = text ? JSON.parse(text).warnings : undefined;
348
+ } catch {
349
+ warnings = undefined;
350
+ }
351
+ return { etag, ...(Array.isArray(warnings) && warnings.length ? { warnings } : {}) };
311
352
  }
312
353
 
313
354
  /** Safe, conflict-free append. */
355
+ /**
356
+ * EMBED-API — 문서에 노드(표·시트·발표자료…) 하나를 끼워 넣는다. `node` 는 id 든 경로든 된다
357
+ * (서버가 id 로 바꿔 `[[embed:<id>]]` 한 줄을 붙인다).
358
+ */
359
+ async embedNode(documentId, { node, underHeading, createIfMissing } = {}) {
360
+ return this.request(`/documents/${encodeURIComponent(documentId)}/embeds`, {
361
+ method: 'POST',
362
+ body: { node, underHeading, createIfMissing },
363
+ });
364
+ }
365
+
314
366
  async appendDocument(id, { content, underHeading, createIfMissing } = {}) {
315
367
  const res = await this.request(`/documents/${encodeURIComponent(id)}/append`, {
316
368
  method: 'POST',
@@ -458,6 +510,71 @@ export class RootrClient {
458
510
  return this.request(`/nodes/${encodeURIComponent(nodeId)}/public-status`);
459
511
  }
460
512
 
513
+ // ── BOOKING — 공개한 표에서 방문자가 일정을 잡게 한다 ──────────────────
514
+ //
515
+ // 예약은 **공개 주소 위에 서는 기능**이다. 그래서 켜기 전에 표가 발행돼 있어야 하고,
516
+ // 안 돼 있으면 서버가 "먼저 표를 웹에 공개해 주세요"로 거절한다.
517
+
518
+ /** 예약 설정(규칙·확정 방식·물어볼 칸·구글 캘린더)과 연결 상태를 본다. */
519
+ async getBooking(nodeId) {
520
+ return this.request(`/nodes/${encodeURIComponent(nodeId)}/booking`);
521
+ }
522
+
523
+ /**
524
+ * 예약 설정을 저장한다. 보낸 칸만 바뀌고 나머지는 그대로다.
525
+ * 날짜·메일 칸이 없으면 서버가 만들어 준다(승인제면 상태 칸까지).
526
+ */
527
+ async setBooking(nodeId, settings = {}) {
528
+ return this.request(`/nodes/${encodeURIComponent(nodeId)}/booking`, {
529
+ method: 'PUT',
530
+ body: settings,
531
+ });
532
+ }
533
+
534
+ /**
535
+ * 방문자에게 보이는 **빈 시간**. 공개 주소(slug)로 부르는 로그인 없는 길이라,
536
+ * 주인이 보는 것과 방문자가 보는 것이 같은지 여기서 바로 확인할 수 있다.
537
+ */
538
+ async bookingSlots(slug, { from, to, pass, type } = {}) {
539
+ const q = new URLSearchParams();
540
+ if (from) q.set('from', from);
541
+ if (to) q.set('to', to);
542
+ if (pass) q.set('pass', pass);
543
+ // 약속 종류마다 길이가 달라 빈 시간도 다르다 — 안 주면 첫 종류로 본다.
544
+ if (type) q.set('type', type);
545
+ return this.request(`/public/nodes/${encodeURIComponent(slug)}/booking/slots?${q.toString()}`);
546
+ }
547
+
548
+ // ── BOOKING-GCAL — 구글 캘린더 연결(사람 한 명의 것) ───────────────────
549
+
550
+ /** 내가 연결해 둔 구글 계정들. `available:false`면 서버에 구글 설정이 아직 없다. */
551
+ async googleCalendarConnections() {
552
+ return this.request('/integrations/google-calendar/connections');
553
+ }
554
+
555
+ /**
556
+ * 구글 동의 화면 주소를 받는다. **브라우저에서 한 번은 허용해야** 연결이 된다 —
557
+ * 구글이 그렇게만 허락한다. 허용하고 나면 그 뒤로는 명령줄에서 다 된다.
558
+ */
559
+ async googleCalendarAuthorizeUrl(returnTo = '/') {
560
+ return this.request('/integrations/google-calendar/authorize', {
561
+ method: 'POST',
562
+ body: { returnTo },
563
+ });
564
+ }
565
+
566
+ /** 그 연결로 볼 수 있는 캘린더들(막을 것·적을 것을 여기서 고른다). */
567
+ async googleCalendars(connectionId) {
568
+ return this.request(`/integrations/google-calendar/connections/${encodeURIComponent(connectionId)}/calendars`);
569
+ }
570
+
571
+ /** 연결을 끊는다. 구글 쪽 권한도 같이 거둔다. */
572
+ async disconnectGoogleCalendar(connectionId) {
573
+ return this.request(`/integrations/google-calendar/connections/${encodeURIComponent(connectionId)}`, {
574
+ method: 'DELETE',
575
+ });
576
+ }
577
+
461
578
  // ── DB-PERM-001 / DB-WATCH — 표에 거는 자물쇠와 알림 규칙 ──────────────
462
579
  //
463
580
  // 노션에 없는 것들이라, 에이전트가 표를 만들어 놓고도 "지원서는 서로 못 보게" 같은
@@ -623,10 +740,10 @@ export class RootrClient {
623
740
  }
624
741
 
625
742
  /** Create a brand-new workspace from a scaffold tree. Requires an account key (PAT) with workspaces:create. */
626
- async createWorkspaceScaffold({ name, intent, tree }) {
743
+ async createWorkspaceScaffold({ name, intent, tree, mode }) {
627
744
  return this.request('/scaffold/workspace', {
628
745
  method: 'POST',
629
- body: { name, intent, tree },
746
+ body: { name, intent, tree, mode },
630
747
  });
631
748
  }
632
749
 
@@ -651,18 +768,18 @@ export class RootrClient {
651
768
  * Check a designed tree BEFORE building it — the compiler pass of scaffolding.
652
769
  * Needs no workspace, creates nothing. Returns { score, counts, findings, summary }.
653
770
  */
654
- async scaffoldReview(tree) {
655
- return this.request('/scaffold/review', { method: 'POST', body: { tree } });
771
+ async scaffoldReview(tree, mode) {
772
+ return this.request('/scaffold/review', { method: 'POST', body: { tree, mode } });
656
773
  }
657
774
 
658
775
  /**
659
776
  * Check the workspace that was ACTUALLY built (not the tree you sent).
660
777
  * Optional rootPath narrows it to one subtree.
661
778
  */
662
- async scaffoldReviewBuilt(workspace, rootPath) {
779
+ async scaffoldReviewBuilt(workspace, rootPath, mode) {
663
780
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/review-built`, {
664
781
  method: 'POST',
665
- body: { rootPath },
782
+ body: { rootPath, mode },
666
783
  });
667
784
  }
668
785
 
@@ -679,10 +796,10 @@ export class RootrClient {
679
796
  }
680
797
 
681
798
  /** Apply a scaffold tree to an existing workspace. */
682
- async scaffoldApply(workspace, { rootPath, tree } = {}) {
799
+ async scaffoldApply(workspace, { rootPath, tree, mode } = {}) {
683
800
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/apply`, {
684
801
  method: 'POST',
685
- body: { rootPath, tree },
802
+ body: { rootPath, tree, mode },
686
803
  });
687
804
  }
688
805
 
@@ -749,13 +866,21 @@ export class RootrClient {
749
866
  async ask(workspaceId, question) {
750
867
  this._requireApiKey();
751
868
  const graphqlUrl = `${this.baseUrl.replace(/\/v1\/?$/, '')}/graphql`;
752
- const query =
753
- 'query($ws:String!,$q:String!){ask(workspaceId:$ws,question:$q){text citations{documentPath quote}}}';
754
- const data = await this.request(null, {
755
- url: graphqlUrl,
756
- method: 'POST',
757
- body: { query, variables: { ws: workspaceId, q: question } },
758
- });
869
+ // DB-SEARCH — 표의 행에서 온 출처는 행 제목·행 주소를 같이 받는다. 이 필드를 모르는 옛 서버에
870
+ // 붙으면 GraphQL 이 질문 전체를 거절하므로, 그때만 예전 모양으로 한 번 더 묻는다.
871
+ const ask = (fields) =>
872
+ this.request(null, {
873
+ url: graphqlUrl,
874
+ method: 'POST',
875
+ body: {
876
+ query: `query($ws:String!,$q:String!){ask(workspaceId:$ws,question:$q){text citations{${fields}}}}`,
877
+ variables: { ws: workspaceId, q: question },
878
+ },
879
+ });
880
+ let data = await ask('documentTitle documentPath quote rowId rowTitle url');
881
+ if (data && Array.isArray(data.errors) && data.errors.some((e) => /Cannot query field/.test(e.message || ''))) {
882
+ data = await ask('documentPath quote');
883
+ }
759
884
  if (data && Array.isArray(data.errors) && data.errors.length) {
760
885
  const msg = data.errors.map((e) => e.message || String(e)).join('; ');
761
886
  throw new RootrApiError(tr(`ask 오류: ${msg}`, `ask failed: ${msg}`), { body: data.errors });
@@ -960,7 +1085,11 @@ export class RootrClient {
960
1085
  const query = params.length ? `?${params.join('&')}` : '';
961
1086
  const data = await this.request(`/databases/${encodeURIComponent(databaseId)}/rows${query}`);
962
1087
  if (Array.isArray(data)) return data;
963
- if (data && Array.isArray(data.rows)) return data.rows;
1088
+ if (data && Array.isArray(data.rows)) {
1089
+ // 벡터 칸이 있는 표는 서버가 행마다 채워진 벡터 칸(칸 id → 차원)을 `vectors` 로 따로 준다 — 행에 붙여 MCP 에도 보인다.
1090
+ if (!data.vectors) return data.rows;
1091
+ return data.rows.map((r) => ({ ...r, vectors: data.vectors[r.id] ?? {} }));
1092
+ }
964
1093
  return [];
965
1094
  }
966
1095
 
@@ -1081,20 +1210,49 @@ export class RootrClient {
1081
1210
 
1082
1211
  async addColumn(
1083
1212
  databaseId,
1084
- { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix } = {},
1213
+ { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions } = {},
1085
1214
  ) {
1086
1215
  return this.request(`/databases/${encodeURIComponent(databaseId)}/columns`, {
1087
1216
  method: 'POST',
1088
1217
  // 계산 칸(수식·롤업·번호)은 설정이 곧 그 칸이라 같이 보내야 한다.
1089
1218
  // reverseName 은 상대 표에 짝 칸을 만든다(양방향 관계).
1090
- body: { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix },
1219
+ // searchExcluded 는 "검색 색인에서 제외"(표 관리자만).
1220
+ body: { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions },
1091
1221
  });
1092
1222
  }
1093
1223
 
1094
- async updateColumn(databaseId, propertyId, { name, options, formula, rollup, prefix, reverseName } = {}) {
1224
+ async updateColumn(databaseId, propertyId, { name, options, formula, rollup, prefix, reverseName, searchExcluded } = {}) {
1095
1225
  return this.request(
1096
1226
  `/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
1097
- { method: 'PATCH', body: { name, options, formula, rollup, prefix, reverseName } },
1227
+ { method: 'PATCH', body: { name, options, formula, rollup, prefix, reverseName, searchExcluded } },
1228
+ );
1229
+ }
1230
+
1231
+ // DB-VECTOR — 에이전트용 벡터 칸(칸 id 또는 이름).
1232
+ async putVectors(databaseId, column, items) {
1233
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/vectors/${encodeURIComponent(column)}`, {
1234
+ method: 'PUT',
1235
+ body: { items },
1236
+ });
1237
+ }
1238
+
1239
+ async queryVectors(databaseId, column, { vector, topK, minScore } = {}) {
1240
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/vectors/${encodeURIComponent(column)}/query`, {
1241
+ method: 'POST',
1242
+ body: { vector, topK, minScore },
1243
+ });
1244
+ }
1245
+
1246
+ async getRowVector(databaseId, rowId, column) {
1247
+ return this.request(
1248
+ `/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/vectors/${encodeURIComponent(column)}`,
1249
+ );
1250
+ }
1251
+
1252
+ async deleteRowVector(databaseId, rowId, column) {
1253
+ return this.request(
1254
+ `/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/vectors/${encodeURIComponent(column)}`,
1255
+ { method: 'DELETE' },
1098
1256
  );
1099
1257
  }
1100
1258
 
@@ -1361,7 +1519,8 @@ export class RootrClient {
1361
1519
  * redirect to the storage bucket isn't sent our API key.
1362
1520
  */
1363
1521
  async downloadAttachmentBytes(attachmentId) {
1364
- const url = this.attachmentRawUrl(attachmentId);
1522
+ // ISSUE-61 — 바이트는 우리가 직접 받는다: 공개 주소(publicBaseUrl)가 아니라 요청을 보내는 baseUrl 로.
1523
+ const url = `${this.baseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
1365
1524
  const res = await fetch(url);
1366
1525
  if (!res.ok) {
1367
1526
  throw new RootrApiError(tr(`오디오 다운로드 실패 (HTTP ${res.status})`, `Audio download failed (HTTP ${res.status})`), { status: res.status });
@@ -1490,7 +1649,25 @@ export class RootrClient {
1490
1649
 
1491
1650
  /** Public, unauthenticated URL an <img> tag (or a published /p/ document) can load directly. */
1492
1651
  attachmentRawUrl(attachmentId) {
1493
- return `${this.baseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
1652
+ return `${this.publicBaseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
1653
+ }
1654
+
1655
+ /**
1656
+ * ISSUE-222 — presigned-URL upload (option 1 from the feature request): the
1657
+ * caller PUTs the file bytes straight to S3 from wherever it has local disk
1658
+ * access, instead of inlining them into an MCP tool call as base64Data.
1659
+ * Mirrors the editor's presign→PUT→complete flow (`/attachments/presign`).
1660
+ */
1661
+ async presignAttachment(workspaceId, { filename, mimeType, size, nodeId } = {}) {
1662
+ return this.request('/attachments/presign', {
1663
+ method: 'POST',
1664
+ body: { workspaceId, filename, mimeType, size, nodeId },
1665
+ });
1666
+ }
1667
+
1668
+ /** Marks a presigned attachment READY after the bytes have been PUT to S3. */
1669
+ async completeAttachment(attachmentId) {
1670
+ return this.request(`/attachments/${encodeURIComponent(attachmentId)}/complete`, { method: 'POST' });
1494
1671
  }
1495
1672
 
1496
1673
  // ---- Spreadsheets ---------------------------------------------------------
@@ -1884,3 +2061,22 @@ export class RootrClient {
1884
2061
  }
1885
2062
 
1886
2063
  }
2064
+
2065
+ /**
2066
+ * `![alt](url)` — 문서에 그대로 붙여 넣을 그림 한 줄.
2067
+ *
2068
+ * alt 자리에는 파일 이름이 그대로 들어가는데, 이름에 `]` 가 있으면 거기서 대괄호가 닫혀 그림이
2069
+ * 아니라 깨진 글자가 되고(실측: 편집기에서 그림이 안 뜬다), `![가짜](http://…)` 같은 이름은
2070
+ * 남의 주소의 그림을 문서에 끼워 넣는다. 역슬래시로 피하는 방법은 편집기의 해석기가 역슬래시를
2071
+ * 글자 그대로 보여 줘서 쓰지 않는다 — 대괄호는 소괄호로, 역슬래시는 빗금으로, 줄바꿈·탭은
2072
+ * 빈칸으로 바꾼다(alt 는 설명 글이라 뜻이 달라지지 않는다). 저장되는 파일 이름은 건드리지 않는다.
2073
+ */
2074
+ export function imageMarkdown(alt, url) {
2075
+ const safe = String(alt ?? '')
2076
+ .replace(/\[/g, '(')
2077
+ .replace(/\]/g, ')')
2078
+ .replace(/\\/g, '/')
2079
+ .replace(/[\r\n\t]+/g, ' ')
2080
+ .trim();
2081
+ return `![${safe}](${url})`;
2082
+ }
@@ -15,6 +15,7 @@ import { t as tr } from './i18n.js';
15
15
  export function candidateClients(cfg = loadConfig()) {
16
16
  const out = [];
17
17
  const seen = new Set();
18
+ // (sessionKeys — 아래 rememberSessionKey 참고 — 는 맨 뒤에 붙는다.)
18
19
  if (cfg.apiKey) {
19
20
  seen.add(cfg.apiKey);
20
21
  out.push({ apiKey: cfg.apiKey, label: 'default', client: new RootrClient({ ...cfg, apiKey: cfg.apiKey }) });
@@ -24,9 +25,37 @@ export function candidateClients(cfg = loadConfig()) {
24
25
  seen.add(k.apiKey);
25
26
  out.push({ apiKey: k.apiKey, label: k.label || k.workspace || 'key', client: new RootrClient({ ...cfg, apiKey: k.apiKey }) });
26
27
  }
28
+ for (const apiKey of sessionKeys) {
29
+ if (seen.has(apiKey)) continue;
30
+ seen.add(apiKey);
31
+ out.push({ apiKey, label: 'session', ephemeral: true, client: new RootrClient({ ...cfg, apiKey }) });
32
+ }
27
33
  return out;
28
34
  }
29
35
 
36
+ /**
37
+ * ISSUE-221 — keys this (long-running, stdio MCP) process was handed through
38
+ * the ROOTR_API_KEY env var and has since switched away from.
39
+ *
40
+ * rootr_use_workspace swaps process.env.ROOTR_API_KEY to the key that opens
41
+ * the target workspace. If the key it replaces lived only in the environment
42
+ * (the usual MCP client config: `env: { ROOTR_API_KEY, ROOTR_WORKSPACE }`),
43
+ * nothing else remembers it — so after A → B the server could no longer open
44
+ * A again ("none of the registered keys opens it"), although it was started
45
+ * with exactly that key. Keep such keys for the life of the process so the way
46
+ * back stays open.
47
+ *
48
+ * In memory only: an env-supplied secret is never written to
49
+ * ~/.rootr/config.json behind the user's back (rememberWorkspace skips
50
+ * `ephemeral` candidates). Never called on the hosted path — that one returns
51
+ * before any switch happens, so tenants cannot see each other's keys here.
52
+ */
53
+ const sessionKeys = [];
54
+
55
+ export function rememberSessionKey(apiKey) {
56
+ if (apiKey && !sessionKeys.includes(apiKey)) sessionKeys.push(apiKey);
57
+ }
58
+
30
59
  /**
31
60
  * Collect every workspace the registered keys can see, and which of them a
32
61
  * key can actually open — listing is not a capability probe: a workspace key
@@ -155,6 +184,36 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
155
184
 
156
185
  if (target) {
157
186
  const scoped = lastRejection && lastRejection.status === 403;
187
+ // ISSUE-172/221 — when this (MCP server) process was started with
188
+ // ROOTR_API_KEY set, config.js makes that env var win over the DEFAULT key
189
+ // in ~/.rootr/config.json (see its ENV-WINS comment), now and after every
190
+ // restart. So for such a server the usual first remedy (`rootr config
191
+ // --api-key <PAT>`) never takes effect — the PAT has to go into the MCP
192
+ // server's own env config. Extra keys (`--add-key`) are different: the
193
+ // file is re-read on every call and they are tried as candidates, so that
194
+ // remedy works at once, without a restart. (Measured 2026-10-09 against a
195
+ // real server: the earlier note had both of these the wrong way round.)
196
+ const envPinned = !!process.env.ROOTR_API_KEY;
197
+ const remediesKo = envPinned
198
+ ? '조치 (둘 중 하나, 사용자가 직접):\n' +
199
+ '1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → MCP 서버 설정의 ROOTR_API_KEY 환경변수를 그 키로 바꾸고 ' +
200
+ '서버를 재시작 (이 서버는 ROOTR_API_KEY 환경변수의 키를 쓰므로, `rootr config --api-key` 로 파일에 적은 키는 ' +
201
+ '재시작해도 쓰이지 않습니다)\n' +
202
+ `2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\` ` +
203
+ '(재시작 없이, 등록한 뒤 바로 다시 전환하면 됩니다)'
204
+ : '조치 (둘 중 하나, 사용자가 직접):\n' +
205
+ '1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
206
+ `2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\``;
207
+ const remediesEn = envPinned
208
+ ? 'Fix (either one, done by you):\n' +
209
+ '1) Create an account key (PAT) under rootr.io > Settings > API keys → put it in the ROOTR_API_KEY env var of ' +
210
+ 'the MCP server config and restart the server (this server uses the key from ROOTR_API_KEY, so a key written ' +
211
+ 'with `rootr config --api-key` is not used, even after a restart)\n' +
212
+ `2) Create a key for that workspace → \`rootr config --add-key <key> --workspace ${target.id}\` ` +
213
+ '(no restart needed — switch again right after registering it)'
214
+ : 'Fix (either one, done by you):\n' +
215
+ '1) Create an account key (PAT) under rootr.io > Settings > API keys → `rootr config --api-key <key>` (works everywhere)\n' +
216
+ `2) Create a key for that workspace → \`rootr config --add-key <key> --workspace ${target.id}\``;
158
217
  return {
159
218
  error: tr(
160
219
  `"${target.name || target.id}" (${target.id}) 는 접근 권한 목록에는 있지만, 등록된 키로는 열 수 없습니다` +
@@ -162,17 +221,13 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
162
221
  (scoped
163
222
  ? '원인: 지금 등록된 키가 다른 워크스페이스 전용 키입니다 (워크스페이스 키는 자기 워크스페이스 밖에서 403).\n'
164
223
  : `원인: ${lastRejection ? lastRejection.message : '알 수 없음'}\n`) +
165
- '조치 (둘 중 하나, 사용자가 직접):\n' +
166
- '1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
167
- `2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\``,
224
+ remediesKo,
168
225
  `"${target.name || target.id}" (${target.id}) is on your access list, but none of the registered keys opens it` +
169
226
  `${target.role ? ` (your role: ${target.role})` : ''}.\n` +
170
227
  (scoped
171
228
  ? 'Cause: the registered key belongs to a different workspace (a workspace key gets 403 outside its own).\n'
172
229
  : `Cause: ${lastRejection ? lastRejection.message : 'unknown'}\n`) +
173
- 'Fix (either one, done by you):\n' +
174
- '1) Create an account key (PAT) under rootr.io > Settings > API keys → `rootr config --api-key <key>` (works everywhere)\n' +
175
- `2) Create a key for that workspace → \`rootr config --add-key <key> --workspace ${target.id}\``,
230
+ remediesEn,
176
231
  ),
177
232
  };
178
233
  }
@@ -204,7 +259,9 @@ export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
204
259
  /** Persist the switch: remember the workspace and which key covers it. */
205
260
  export function rememberWorkspace({ workspace, candidate }, cfg = loadConfig()) {
206
261
  saveConfig({ workspace: workspace.id });
207
- if (candidate && candidate.apiKey !== cfg.apiKey) {
262
+ // `ephemeral` = a key that only ever lived in this process's environment (see
263
+ // rememberSessionKey) — it stays out of the config file.
264
+ if (candidate && candidate.apiKey !== cfg.apiKey && !candidate.ephemeral) {
208
265
  upsertKey({ apiKey: candidate.apiKey, workspace: workspace.id, label: candidate.label });
209
266
  }
210
267
  }
@@ -28,7 +28,12 @@ export function registerAskTools(server) {
28
28
  const client = makeClient(ws);
29
29
  const result = await client.ask(ws, question);
30
30
  const citations = Array.isArray(result.citations) ? result.citations : [];
31
- const citationLines = citations.map((c) => `- ${c.documentPath}: ${c.quote}`);
31
+ // DB-SEARCH — 표의 행 출처는 "표 › 행 (주소)" — 표 이름만 주면 찾아 놓고도 그 행을 못 연다.
32
+ const citationLines = citations.map((c) =>
33
+ c.rowId
34
+ ? `- ${c.documentTitle || c.documentPath} › ${c.rowTitle || '(untitled row)'}${c.url ? ` (${c.url})` : ''}: ${c.quote}`
35
+ : `- ${c.documentPath}${c.url ? ` (${c.url})` : ''}: ${c.quote}`,
36
+ );
32
37
  const text = [
33
38
  result.text || '(no answer text)',
34
39
  '',