drafted 1.7.17 → 1.7.19
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/agent-instructions/global.md +50 -0
- package/cli/drafted.mjs +12 -4
- package/install-mcp.sh +761 -34
- package/mcp/server.mjs +491 -64
- package/package.json +15 -3
- package/src/shared/constants.mjs +7 -5
- package/src/shared/excalidraw.mjs +101 -0
package/mcp/server.mjs
CHANGED
|
@@ -10,15 +10,17 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { execFile } from 'child_process';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { readFileSync, existsSync, realpathSync } from 'fs';
|
|
13
|
+
import { readFileSync, existsSync, realpathSync, writeFileSync, mkdirSync, unlinkSync, appendFileSync } from 'fs';
|
|
14
14
|
import { join, dirname, basename, extname, resolve } from 'path';
|
|
15
|
-
import { homedir } from 'os';
|
|
15
|
+
import { homedir, platform, release as osRelease, arch as osArch } from 'os';
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
17
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
18
18
|
import { z } from 'zod';
|
|
19
19
|
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
|
|
20
20
|
import WebSocket from 'ws';
|
|
21
21
|
import { LAYERS } from '../src/shared/constants.mjs';
|
|
22
|
+
import { emptyExcalidrawScene, excalidrawSceneFromMermaid, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
|
|
23
|
+
import { UMAMI_EVENTS, trackUmamiEvent } from '../server/lib/umami.mjs';
|
|
22
24
|
|
|
23
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
26
|
|
|
@@ -41,8 +43,8 @@ const PACKAGE_VERSION = (() => {
|
|
|
41
43
|
const requestState = new AsyncLocalStorage();
|
|
42
44
|
const standaloneState = { sessionId: null, projectId: null, projectMeta: null };
|
|
43
45
|
|
|
44
|
-
// Per-MCP-session sticky state. Keyed by
|
|
45
|
-
// or '__stdio__' for the long-lived stdio process. Holds active-project,
|
|
46
|
+
// Per-MCP-session sticky state. Keyed by the Drafted session id bound to
|
|
47
|
+
// this MCP instance, or '__stdio__' for the long-lived stdio process. Holds active-project,
|
|
46
48
|
// loaded-skill, and cached-org state that must persist across HTTP request
|
|
47
49
|
// boundaries so a `project open` followed by `frame.read` in a separate
|
|
48
50
|
// request still has an active project.
|
|
@@ -63,6 +65,7 @@ function getOrCreateSessionState(sid) {
|
|
|
63
65
|
cachedOrgId: null,
|
|
64
66
|
cachedOrgIdTime: 0,
|
|
65
67
|
wsSessionId: null,
|
|
68
|
+
pendingDeviceCode: null,
|
|
66
69
|
};
|
|
67
70
|
sessionStates.set(key, s);
|
|
68
71
|
}
|
|
@@ -99,6 +102,7 @@ export function runWithRequestState(initial, fn) {
|
|
|
99
102
|
// Stdio mode uses the `mcpServer` singleton (built once at module load).
|
|
100
103
|
|
|
101
104
|
export function createMcpServer() {
|
|
105
|
+
trackUmamiEvent(UMAMI_EVENTS.MCP_CONNECTED, { source: 'mcp' });
|
|
102
106
|
const server = new McpServer({
|
|
103
107
|
name: 'drafted',
|
|
104
108
|
version: PACKAGE_VERSION,
|
|
@@ -112,6 +116,9 @@ SKILLS: Drafted has a skill library -- reusable agent instructions stored as SKI
|
|
|
112
116
|
|
|
113
117
|
BREADCRUMBS: When a frame you write or read corresponds to a file in the user's codebase (a component spec, wireframe for a route, design doc for a module), leave a comment in that code file using the canonical token "drafted:<frameId>" wrapped in the file's comment syntax (e.g. "// drafted:abc-123..." for JS/TS, "# drafted:abc-123..." for Python/YAML, "<!-- drafted:abc-123... -->" for HTML/Markdown). Project-level references use "drafted-project:<projectId>" in the project README or CLAUDE.md. Future agents grepping for "drafted:" will discover the link and can pull the frame via read(<frameId>). One line per related frame. Skip for throwaway or exploratory frames.
|
|
114
118
|
|
|
119
|
+
CONTEXT RULES (follow these before every action):
|
|
120
|
+
- WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use wiki(action="search") with relevant keywords.
|
|
121
|
+
- LAYER CONTEXT: Before reading or mutating a frame, read all anchored frames in the same layer. Anchored frames are per-layer required reading (style guides, design systems, conventions). The server enforces this mechanically for writes/edits/deletes/moves — but proactively reading anchored frames before any frame operation prevents wasted work.
|
|
115
122
|
IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.`,
|
|
116
123
|
});
|
|
117
124
|
|
|
@@ -129,11 +136,11 @@ const layerKeys = Object.keys(LAYERS);
|
|
|
129
136
|
|
|
130
137
|
const TOOL_ANNOTATIONS = {
|
|
131
138
|
// Auth — initiates external browser / email flows
|
|
132
|
-
auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL
|
|
139
|
+
auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL immediately and starts background approval polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval.' },
|
|
133
140
|
|
|
134
141
|
// Projects
|
|
135
142
|
project: { title: 'Projects', readOnlyHint: false, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html', description: 'Manage projects: list (start here), open (switch active project), create, update, move to another org.' },
|
|
136
|
-
get_org: { title: 'Organization', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Get the active organization (action="get", default) or switch to a different org (action="switch", orgId=...). Use switch when you need wiki/skill work in an org that has no projects — opening a project also switches, but is unavailable in empty orgs.' },
|
|
143
|
+
get_org: { title: 'Organization', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Get the active organization (action="get", default), Google Drive availability, or switch to a different org (action="switch", orgId=...). Use switch when you need wiki/skill work in an org that has no projects — opening a project also switches, but is unavailable in empty orgs. When googleDrive.connected is true, strongly prefer Google Workspace frames for documents, sheets, and slides.' },
|
|
137
144
|
|
|
138
145
|
// Templates
|
|
139
146
|
template: { title: 'Templates', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage project templates: list, create, update, delete, fork.' },
|
|
@@ -143,7 +150,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
143
150
|
|
|
144
151
|
// Frames — filesystem
|
|
145
152
|
ls: { title: 'List frames', readOnlyHint: true, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html' },
|
|
146
|
-
frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: false, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, or
|
|
153
|
+
frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: false, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, search, or restore frame versions in the ACTIVE PROJECT. Dispatch by `action`. Use `ls` to browse, `rm` to delete.' },
|
|
147
154
|
rm: { title: 'Delete frame', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
148
155
|
// batch: { title: 'Batch operations', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
149
156
|
|
|
@@ -193,7 +200,18 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
|
193
200
|
if (ann.widgetUri) {
|
|
194
201
|
config._meta = { 'ui': { resourceUri: ann.widgetUri } };
|
|
195
202
|
}
|
|
196
|
-
return server.registerTool(name, config,
|
|
203
|
+
return server.registerTool(name, config, async (...args) => {
|
|
204
|
+
const state = getState();
|
|
205
|
+
const previousTool = state.currentTool;
|
|
206
|
+
state.currentTool = name;
|
|
207
|
+
trackUmamiEvent(UMAMI_EVENTS.MCP_TOOL_CALLED, { tool: name, projectId: state.projectId || undefined, source: 'mcp' });
|
|
208
|
+
reportInstallationEvent(UMAMI_EVENTS.DRAFTED_MCP_REQUEST, { tool: name });
|
|
209
|
+
try {
|
|
210
|
+
return await cb(...args);
|
|
211
|
+
} finally {
|
|
212
|
+
state.currentTool = previousTool;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
197
215
|
}
|
|
198
216
|
|
|
199
217
|
// ── ChatGPT Apps SDK widgets ──────────────────────────────────────
|
|
@@ -255,20 +273,147 @@ registerAppResource(
|
|
|
255
273
|
// ── Config ────────────────────────────────────────────────────────
|
|
256
274
|
|
|
257
275
|
const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
|
|
276
|
+
const PENDING_AUTH_FILE = process.env.DRAFTED_PENDING_AUTH_FILE || `${AUTH_FILE}.pending`;
|
|
258
277
|
|
|
259
278
|
function getServerUrl() {
|
|
260
279
|
if (process.env.DRAFTED_SERVER) return process.env.DRAFTED_SERVER.replace(/\/$/, '');
|
|
280
|
+
if (getState().publicUrl) return getState().publicUrl.replace(/\/$/, '');
|
|
281
|
+
if (process.env.DRAFTED_PUBLIC_URL) return process.env.DRAFTED_PUBLIC_URL.replace(/\/$/, '');
|
|
282
|
+
if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, '');
|
|
283
|
+
if (process.env.APP_URL) return process.env.APP_URL.replace(/\/$/, '');
|
|
261
284
|
// Read from config.json next to auth.json (written by install-mcp.sh)
|
|
262
285
|
try {
|
|
263
286
|
const cfgPath = join(homedir(), '.drafted', 'config.json');
|
|
264
287
|
if (existsSync(cfgPath)) {
|
|
265
288
|
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
266
289
|
if (cfg.server) return cfg.server.replace(/\/$/, '');
|
|
290
|
+
if (cfg.publicUrl) return cfg.publicUrl.replace(/\/$/, '');
|
|
267
291
|
}
|
|
268
292
|
} catch { /* fall through */ }
|
|
269
293
|
return `http://localhost:${process.env.DRAFTED_PORT || 3477}`;
|
|
270
294
|
}
|
|
271
295
|
|
|
296
|
+
function getInstallInfo() {
|
|
297
|
+
if (process.env.DRAFTED_TELEMETRY === '0') return null;
|
|
298
|
+
try {
|
|
299
|
+
const installPath = join(homedir(), '.drafted', 'install.json');
|
|
300
|
+
if (!existsSync(installPath)) return null;
|
|
301
|
+
const info = JSON.parse(readFileSync(installPath, 'utf8'));
|
|
302
|
+
if (info.telemetry === false) return null;
|
|
303
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(info.installId || ''))) return null;
|
|
304
|
+
return info;
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
function osFamily() {
|
|
312
|
+
const p = platform();
|
|
313
|
+
if (p === 'darwin') return 'macos';
|
|
314
|
+
if (p === 'win32') return 'windows';
|
|
315
|
+
if (p === 'linux') return 'linux';
|
|
316
|
+
return 'unknown';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function normalizedArch() {
|
|
320
|
+
const a = osArch();
|
|
321
|
+
return ['x64', 'arm64', 'arm', 'ia32'].includes(a) ? a : 'unknown';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function mcpMode() {
|
|
325
|
+
return process.argv.includes('--http') ? 'http' : 'stdio';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function reportInstallationEvent(event, extra = {}) {
|
|
329
|
+
const info = getInstallInfo();
|
|
330
|
+
if (!info) return;
|
|
331
|
+
fetch(`${getServerUrl()}/api/installations/report`, {
|
|
332
|
+
method: 'POST',
|
|
333
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': `Drafted MCP/${PACKAGE_VERSION}` },
|
|
334
|
+
body: JSON.stringify({
|
|
335
|
+
installId: info.installId,
|
|
336
|
+
event,
|
|
337
|
+
schemaVersion: 1,
|
|
338
|
+
cliVersion: PACKAGE_VERSION,
|
|
339
|
+
osFamily: osFamily(),
|
|
340
|
+
osVersion: osRelease().slice(0, 60),
|
|
341
|
+
arch: normalizedArch(),
|
|
342
|
+
nodeVersion: process.version,
|
|
343
|
+
mcpMode: mcpMode(),
|
|
344
|
+
source: 'mcp',
|
|
345
|
+
...extra,
|
|
346
|
+
}),
|
|
347
|
+
}).catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function classifyMcpError(error) {
|
|
351
|
+
const msg = String(error?.message || error || '').toLowerCase();
|
|
352
|
+
const causeCode = String(error?.cause?.code || error?.code || '').toLowerCase();
|
|
353
|
+
if (causeCode) return causeCode.slice(0, 80);
|
|
354
|
+
if (msg.includes('fetch failed')) return 'fetch_failed';
|
|
355
|
+
if (msg.includes('failed to fetch')) return 'fetch_failed';
|
|
356
|
+
if (msg.includes('network')) return 'network_error';
|
|
357
|
+
if (msg.includes('timeout') || msg.includes('timed out')) return 'timeout';
|
|
358
|
+
const httpMatch = msg.match(/http\s+(\d{3})/);
|
|
359
|
+
if (httpMatch) return `http_${httpMatch[1]}`;
|
|
360
|
+
if (msg.includes('unauthorized') || msg.includes('401')) return 'auth_401';
|
|
361
|
+
if (msg.includes('forbidden') || msg.includes('403')) return 'auth_403';
|
|
362
|
+
return 'tool_error';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function scrubLogValue(value) {
|
|
366
|
+
if (typeof value !== 'string') return value;
|
|
367
|
+
return value
|
|
368
|
+
.replace(/([?&](?:token|code|dci|session|auth|password|secret)=)[^&\s]+/gi, '$1[Filtered]')
|
|
369
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi, '$1[Filtered]')
|
|
370
|
+
.replace(/(gc_session=)[^;\s]+/gi, '$1[Filtered]')
|
|
371
|
+
.slice(0, 500);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function serverOriginForLog() {
|
|
375
|
+
try { return new URL(getServerUrl()).origin; } catch { return 'invalid_server_url'; }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function writeMcpClientLog(tool, error) {
|
|
379
|
+
const entry = {
|
|
380
|
+
ts: new Date().toISOString(),
|
|
381
|
+
level: 'error',
|
|
382
|
+
component: 'drafted-mcp-client',
|
|
383
|
+
version: PACKAGE_VERSION,
|
|
384
|
+
tool: String(tool || 'unknown').slice(0, 80),
|
|
385
|
+
errorCode: classifyMcpError(error),
|
|
386
|
+
message: scrubLogValue(error?.message || String(error)),
|
|
387
|
+
causeCode: scrubLogValue(error?.cause?.code || error?.code || ''),
|
|
388
|
+
causeMessage: scrubLogValue(error?.cause?.message || ''),
|
|
389
|
+
server: serverOriginForLog(),
|
|
390
|
+
osFamily: osFamily(),
|
|
391
|
+
nodeVersion: process.version,
|
|
392
|
+
mcpMode: mcpMode(),
|
|
393
|
+
};
|
|
394
|
+
const line = `[Drafted MCP] ${JSON.stringify(entry)}\n`;
|
|
395
|
+
try { console.error(line.trimEnd()); } catch { /* ignore */ }
|
|
396
|
+
try {
|
|
397
|
+
const logPath = join(homedir(), '.drafted', 'mcp-client.log');
|
|
398
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
399
|
+
appendFileSync(logPath, line, { mode: 0o600 });
|
|
400
|
+
} catch { /* best effort */ }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function reportMcpToolError(tool, error) {
|
|
404
|
+
writeMcpClientLog(tool, error);
|
|
405
|
+
reportInstallationEvent(UMAMI_EVENTS.DRAFTED_MCP_ERROR, {
|
|
406
|
+
tool: String(tool || 'unknown').slice(0, 80),
|
|
407
|
+
errorCode: classifyMcpError(error),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function wikiBrowserUrl(path = '') {
|
|
412
|
+
const normalized = normalizeWikiPath(path || '');
|
|
413
|
+
if (!normalized) return `${getServerUrl()}/wiki`;
|
|
414
|
+
return `${getServerUrl()}/wiki/${normalized.split('/').map(encodeURIComponent).join('/')}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
272
417
|
function getBootstrapSessionId() {
|
|
273
418
|
try {
|
|
274
419
|
if (existsSync(AUTH_FILE)) {
|
|
@@ -279,6 +424,87 @@ function getBootstrapSessionId() {
|
|
|
279
424
|
return null;
|
|
280
425
|
}
|
|
281
426
|
|
|
427
|
+
function persistAuthSession(data) {
|
|
428
|
+
mkdirSync(dirname(AUTH_FILE), { recursive: true });
|
|
429
|
+
writeFileSync(AUTH_FILE, JSON.stringify({
|
|
430
|
+
sessionId: data.sessionId,
|
|
431
|
+
userId: data.userId || null,
|
|
432
|
+
orgId: data.orgId || null,
|
|
433
|
+
server: getServerUrl(),
|
|
434
|
+
updatedAt: new Date().toISOString(),
|
|
435
|
+
}, null, 2), { mode: 0o600 });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function persistPendingDeviceCode(data) {
|
|
439
|
+
if (!data?.deviceCode) return;
|
|
440
|
+
const pending = {
|
|
441
|
+
...data,
|
|
442
|
+
server: getServerUrl(),
|
|
443
|
+
createdAt: Date.now(),
|
|
444
|
+
expiresAt: Date.now() + (Number(data.expiresIn || 900) * 1000),
|
|
445
|
+
};
|
|
446
|
+
getSessionState().pendingDeviceCode = pending;
|
|
447
|
+
try {
|
|
448
|
+
mkdirSync(dirname(PENDING_AUTH_FILE), { recursive: true });
|
|
449
|
+
writeFileSync(PENDING_AUTH_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
|
|
450
|
+
} catch { /* best effort; in-memory state still works for long-lived stdio */ }
|
|
451
|
+
schedulePendingAuthPoll(2000);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function clearPendingDeviceCode() {
|
|
455
|
+
stopPendingAuthPoll();
|
|
456
|
+
getSessionState().pendingDeviceCode = null;
|
|
457
|
+
try { if (existsSync(PENDING_AUTH_FILE)) unlinkSync(PENDING_AUTH_FILE); } catch { /* ignore */ }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function getPendingDeviceCode() {
|
|
461
|
+
const inMemory = getSessionState().pendingDeviceCode;
|
|
462
|
+
if (inMemory?.deviceCode) return inMemory;
|
|
463
|
+
try {
|
|
464
|
+
if (!existsSync(PENDING_AUTH_FILE)) return null;
|
|
465
|
+
const pending = JSON.parse(readFileSync(PENDING_AUTH_FILE, 'utf8'));
|
|
466
|
+
if (!pending?.deviceCode) return null;
|
|
467
|
+
if (pending.server && pending.server !== getServerUrl()) return null;
|
|
468
|
+
if (pending.expiresAt && Date.now() > Number(pending.expiresAt)) {
|
|
469
|
+
clearPendingDeviceCode();
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
getSessionState().pendingDeviceCode = pending;
|
|
473
|
+
return pending;
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
let pendingAuthPollTimer = null;
|
|
481
|
+
|
|
482
|
+
function stopPendingAuthPoll() {
|
|
483
|
+
if (pendingAuthPollTimer) clearTimeout(pendingAuthPollTimer);
|
|
484
|
+
pendingAuthPollTimer = null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function schedulePendingAuthPoll(delayMs = 2000) {
|
|
488
|
+
stopPendingAuthPoll();
|
|
489
|
+
pendingAuthPollTimer = setTimeout(async () => {
|
|
490
|
+
pendingAuthPollTimer = null;
|
|
491
|
+
const pending = getPendingDeviceCode();
|
|
492
|
+
if (!pending?.deviceCode) return;
|
|
493
|
+
const approved = await consumePendingDeviceCode();
|
|
494
|
+
if (approved) {
|
|
495
|
+
try { connectAgentWs().catch(() => {}); } catch { /* ignore */ }
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const expiresAt = Number(pending.expiresAt || 0);
|
|
499
|
+
if (expiresAt && Date.now() >= expiresAt) {
|
|
500
|
+
clearPendingDeviceCode();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
schedulePendingAuthPoll(2000);
|
|
504
|
+
}, delayMs);
|
|
505
|
+
if (typeof pendingAuthPollTimer.unref === 'function') pendingAuthPollTimer.unref();
|
|
506
|
+
}
|
|
507
|
+
|
|
282
508
|
function getAuthHeaders() {
|
|
283
509
|
const sid = getState().sessionId || getBootstrapSessionId();
|
|
284
510
|
if (sid) return { Cookie: `gc_session=${sid}` };
|
|
@@ -304,6 +530,8 @@ async function cloneSession() {
|
|
|
304
530
|
}
|
|
305
531
|
|
|
306
532
|
async function ensureSession() {
|
|
533
|
+
if (getState().sessionId) return;
|
|
534
|
+
await consumePendingDeviceCode();
|
|
307
535
|
if (getState().sessionId) return;
|
|
308
536
|
await cloneSession();
|
|
309
537
|
}
|
|
@@ -335,10 +563,13 @@ async function api(method, path, body, _retried) {
|
|
|
335
563
|
const res = await fetch(url, opts);
|
|
336
564
|
const text = await res.text();
|
|
337
565
|
|
|
338
|
-
// Session expired after server restart
|
|
566
|
+
// Session expired after server restart, or a browser approval just completed
|
|
567
|
+
// for auth(action="get_link"). First try to consume any pending device code,
|
|
568
|
+
// then fall back to cloning the saved browser session, and retry once.
|
|
339
569
|
if (res.status === 401 && !_retried) {
|
|
340
570
|
getState().sessionId = null;
|
|
341
|
-
await
|
|
571
|
+
const approved = await consumePendingDeviceCode();
|
|
572
|
+
if (!approved) await cloneSession();
|
|
342
573
|
return api(method, path, body, true);
|
|
343
574
|
}
|
|
344
575
|
|
|
@@ -398,6 +629,22 @@ function ok(text, opts) {
|
|
|
398
629
|
return result;
|
|
399
630
|
}
|
|
400
631
|
|
|
632
|
+
function summarizeSkillForSearch(skill) {
|
|
633
|
+
if (!skill || typeof skill !== 'object') return skill;
|
|
634
|
+
return {
|
|
635
|
+
id: skill.id,
|
|
636
|
+
orgId: skill.orgId ?? null,
|
|
637
|
+
name: skill.name,
|
|
638
|
+
slug: skill.slug,
|
|
639
|
+
description: skill.description,
|
|
640
|
+
tags: skill.tags || [],
|
|
641
|
+
triggerPatterns: skill.triggerPatterns || [],
|
|
642
|
+
version: skill.version,
|
|
643
|
+
updatedAt: skill.updatedAt,
|
|
644
|
+
fileCount: Array.isArray(skill.files) ? skill.files.length : undefined,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
401
648
|
// Build the structuredContent shape that the frame-preview widget reads.
|
|
402
649
|
// Tools that produce or return a frame (read/write/edit) call this so the
|
|
403
650
|
// model and widget see the same metadata view.
|
|
@@ -545,6 +792,7 @@ if (!globalThis.__draftedAgentWsBootstrapped) {
|
|
|
545
792
|
}
|
|
546
793
|
|
|
547
794
|
function err(error) {
|
|
795
|
+
reportMcpToolError(getState().currentTool, error);
|
|
548
796
|
return { content: [{ type: 'text', text: error.message || String(error) }], isError: true };
|
|
549
797
|
}
|
|
550
798
|
|
|
@@ -694,7 +942,7 @@ function applyHashlineOps(content, operations) {
|
|
|
694
942
|
let lines = content.split('\n');
|
|
695
943
|
const lineToHash = {};
|
|
696
944
|
for (let i = 0; i < lines.length; i++) {
|
|
697
|
-
lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0,
|
|
945
|
+
lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0, 12);
|
|
698
946
|
}
|
|
699
947
|
const sorted = [...operations].reverse();
|
|
700
948
|
for (const op of sorted) {
|
|
@@ -744,10 +992,49 @@ function runCLI(command, args = [], options = {}) {
|
|
|
744
992
|
|
|
745
993
|
// ── Login tools ─────────────────────────────────────────────────────
|
|
746
994
|
|
|
747
|
-
//
|
|
748
|
-
|
|
995
|
+
// Per-session pending device code — auth(action=get_link) stores it, and
|
|
996
|
+
// auth(action=login) or a later tool call can consume it.
|
|
997
|
+
//
|
|
998
|
+
// This prevents the "I already authed" loop after get_link: once the user
|
|
999
|
+
// finishes the browser step, the next tool call can exchange the approved
|
|
1000
|
+
// device code for a Drafted session automatically.
|
|
1001
|
+
//
|
|
1002
|
+
// SCOPE: This `auth` tool is for the stdio path only — when drafted-mcp runs
|
|
1003
|
+
// as a local Node process (npm-installed `drafted-mcp` for self-hosters,
|
|
1004
|
+
// Cursor/VS Code one-click installs that use stdio). The Claude Code plugin
|
|
1005
|
+
// has used HTTP MCP since v1.7.17 and authenticates via Clerk OAuth at the
|
|
1006
|
+
// MCP protocol layer (handled by Claude Code itself, see src/middleware/oauth.ts).
|
|
1007
|
+
// HTTP-mode callers never reach this tool because Clerk gates all /mcp tool
|
|
1008
|
+
// calls before authentication. Don't add new auth flows here — add them to
|
|
1009
|
+
// src/auth/clerk.ts instead.
|
|
1010
|
+
|
|
1011
|
+
async function consumePendingDeviceCode() {
|
|
1012
|
+
const pending = getPendingDeviceCode();
|
|
1013
|
+
if (!pending?.deviceCode) return false;
|
|
749
1014
|
|
|
750
|
-
|
|
1015
|
+
try {
|
|
1016
|
+
const res = await fetch(`${getServerUrl()}/auth/device/token`, {
|
|
1017
|
+
method: 'POST',
|
|
1018
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1019
|
+
body: JSON.stringify({ deviceCode: pending.deviceCode }),
|
|
1020
|
+
});
|
|
1021
|
+
if (!res.ok) return false;
|
|
1022
|
+
const data = await res.json();
|
|
1023
|
+
if (data.status === 'approved' && data.sessionId) {
|
|
1024
|
+
persistAuthSession(data);
|
|
1025
|
+
getState().sessionId = data.sessionId;
|
|
1026
|
+
clearPendingDeviceCode();
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
if (data.status === 'expired') {
|
|
1030
|
+
clearPendingDeviceCode();
|
|
1031
|
+
}
|
|
1032
|
+
} catch { /* ignore and fall through */ }
|
|
1033
|
+
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL immediately (use for SSH/headless/tmux where a browser may not open) and starts background polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval. If get_link was called first, login reuses that pending code instead of opening a new browser.', {
|
|
751
1038
|
action: z.enum(['get_link', 'login']).describe('Operation to perform.'),
|
|
752
1039
|
}, async ({ action }) => {
|
|
753
1040
|
try {
|
|
@@ -755,7 +1042,7 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
|
|
|
755
1042
|
const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
|
|
756
1043
|
if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
|
|
757
1044
|
const data = await codeRes.json();
|
|
758
|
-
|
|
1045
|
+
persistPendingDeviceCode(data);
|
|
759
1046
|
return ok(data.verificationUrl);
|
|
760
1047
|
}
|
|
761
1048
|
if (action === 'login') {
|
|
@@ -775,9 +1062,9 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
|
|
|
775
1062
|
let deviceCode, verificationUrl, expiresIn;
|
|
776
1063
|
let reusingPending = false;
|
|
777
1064
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1065
|
+
const pending = getPendingDeviceCode();
|
|
1066
|
+
if (pending) {
|
|
1067
|
+
({ deviceCode, verificationUrl, expiresIn } = pending);
|
|
781
1068
|
reusingPending = true;
|
|
782
1069
|
} else {
|
|
783
1070
|
const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
|
|
@@ -813,16 +1100,8 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
|
|
|
813
1100
|
const data = await res.json();
|
|
814
1101
|
|
|
815
1102
|
if (data.status === 'approved') {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
mkdirSync(dirname(AUTH_FILE), { recursive: true });
|
|
819
|
-
writeFileSync(AUTH_FILE, JSON.stringify({
|
|
820
|
-
sessionId: data.sessionId,
|
|
821
|
-
userId: data.userId || null,
|
|
822
|
-
orgId: data.orgId || null,
|
|
823
|
-
server: getServerUrl(),
|
|
824
|
-
updatedAt: new Date().toISOString(),
|
|
825
|
-
}, null, 2));
|
|
1103
|
+
persistAuthSession(data);
|
|
1104
|
+
clearPendingDeviceCode();
|
|
826
1105
|
|
|
827
1106
|
getState().sessionId = null;
|
|
828
1107
|
await cloneSession();
|
|
@@ -1171,7 +1450,7 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
|
|
|
1171
1450
|
const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
|
|
1172
1451
|
const entries = listing.entries || [];
|
|
1173
1452
|
const frames = entries.filter(e => e.type === 'frame');
|
|
1174
|
-
const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md') && !f.path.endsWith('/instructions/context.md'));
|
|
1453
|
+
const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md') && !f.path.endsWith('/instructions/context.md') && !f.path.endsWith('/instructions/AGENTS.md') && !f.path.endsWith('/AGENTS.md'));
|
|
1175
1454
|
const frameCount = realFrames.length;
|
|
1176
1455
|
if (frameCount > 0) {
|
|
1177
1456
|
throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
|
|
@@ -1209,6 +1488,32 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
|
|
|
1209
1488
|
} catch (error) { return err(error); }
|
|
1210
1489
|
});
|
|
1211
1490
|
|
|
1491
|
+
|
|
1492
|
+
async function getGoogleDriveAvailability() {
|
|
1493
|
+
try {
|
|
1494
|
+
const status = await api('GET', '/api/google/status');
|
|
1495
|
+
return {
|
|
1496
|
+
connected: !!status?.connected,
|
|
1497
|
+
syncEnabled: !!status?.syncEnabled,
|
|
1498
|
+
driveRootFolderId: status?.driveRootFolderId || null,
|
|
1499
|
+
driveRootFolderName: status?.driveRootFolderName || null,
|
|
1500
|
+
workspaceFramesAvailable: !!status?.connected,
|
|
1501
|
+
preference: status?.connected
|
|
1502
|
+
? 'Strongly prefer Google Workspace frames for docs, sheets, and slides in this org.'
|
|
1503
|
+
: 'Google Drive is not connected; use normal Drafted frames.',
|
|
1504
|
+
};
|
|
1505
|
+
} catch {
|
|
1506
|
+
return {
|
|
1507
|
+
connected: false,
|
|
1508
|
+
syncEnabled: false,
|
|
1509
|
+
driveRootFolderId: null,
|
|
1510
|
+
driveRootFolderName: null,
|
|
1511
|
+
workspaceFramesAvailable: false,
|
|
1512
|
+
preference: 'Google Drive status unavailable; use normal Drafted frames unless a Google Workspace frame succeeds.',
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1212
1517
|
tool('get_org', {
|
|
1213
1518
|
action: z.enum(['get', 'switch']).optional().describe('Default: "get" returns active org and member info. Use "switch" with orgId to change the active org without opening a project.'),
|
|
1214
1519
|
orgId: z.string().optional().describe('[switch] target org ID to switch to. Must be one of the orgs the user is a member of.'),
|
|
@@ -1229,7 +1534,8 @@ tool('get_org', {
|
|
|
1229
1534
|
const me = await api('GET', '/auth/me');
|
|
1230
1535
|
const orgs = (await api('GET', '/api/orgs')).orgs || [];
|
|
1231
1536
|
const activeOrg = (orgs || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name })).find(o => o.id === me?.orgId) || null;
|
|
1232
|
-
|
|
1537
|
+
const googleDrive = await getGoogleDriveAvailability();
|
|
1538
|
+
return ok({ switched: true, activeOrg, googleDrive, note: 'Active org switched. Wiki and skill calls now target this org. Active project cleared — open a project (or stay org-scoped for wiki/skill). If googleDrive.connected is true, prefer Google Workspace frames for docs, sheets, and slides.' });
|
|
1233
1539
|
}
|
|
1234
1540
|
|
|
1235
1541
|
// Source of truth = THIS MCP session's bound org (what mutations will actually
|
|
@@ -1242,6 +1548,8 @@ tool('get_org', {
|
|
|
1242
1548
|
const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
|
1243
1549
|
const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
|
|
1244
1550
|
|
|
1551
|
+
const googleDrive = await getGoogleDriveAvailability();
|
|
1552
|
+
|
|
1245
1553
|
let members = [];
|
|
1246
1554
|
if (sessionOrgId) {
|
|
1247
1555
|
try {
|
|
@@ -1253,27 +1561,34 @@ tool('get_org', {
|
|
|
1253
1561
|
activeOrg,
|
|
1254
1562
|
orgs,
|
|
1255
1563
|
members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
|
|
1564
|
+
googleDrive,
|
|
1256
1565
|
mcpVersion: PACKAGE_VERSION,
|
|
1257
|
-
note: "activeOrg is the org bound to THIS MCP session — what mutations will actually target. To switch orgs without creating a project, call get_org(action=\"switch\", orgId=\"...\"). Browser tabs and other MCP sessions for the same user can be on different orgs.",
|
|
1566
|
+
note: "activeOrg is the org bound to THIS MCP session — what mutations will actually target. To switch orgs without creating a project, call get_org(action=\"switch\", orgId=\"...\"). Browser tabs and other MCP sessions for the same user can be on different orgs. If googleDrive.connected is true, strongly prefer Google Workspace frames for docs, sheets, and slides.",
|
|
1258
1567
|
});
|
|
1259
1568
|
} catch (error) { return err(error); }
|
|
1260
1569
|
});
|
|
1261
1570
|
|
|
1262
1571
|
// ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
|
|
1263
1572
|
|
|
1264
|
-
tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Write — content or
|
|
1265
|
-
action: z.enum(['read', 'write', 'edit', 'mv', 'anchor', 'search']).describe('Operation to perform.'),
|
|
1573
|
+
tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), write_excalidraw (native editable Excalidraw diagram), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Write — content, file, or Google Workspace frame:** Provide `content` (HTML/markdown/text), `file_path` (absolute path to a local file), `googleType` (`google-doc`, `google-sheet`, `google-slide`), OR `write_excalidraw` with `excalidraw_data` or `mermaid`. Call get_org first; when `googleDrive.connected` is true, strongly prefer Google Workspace frames for business artifacts: use `google-doc` for memos/reports/briefs/SOPs/proposals, `google-sheet` for tables/trackers/budgets/research matrices/models, and `google-slide` for decks/presentation outlines. For inline content, filename extension matters: use `.html` for complete HTML documents and `.md` for Markdown. Never place a full HTML document in a `.md` or extensionless frame. For a new Google file, pass `googleType` and optional `title`; for an existing Google file, pass `googleType` plus `url` or `googleId`. For binary files (images, PDFs), use `file_path` — uploaded to storage and displayed as an asset frame.\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
|
|
1574
|
+
action: z.enum(['read', 'write', 'write_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform.'),
|
|
1266
1575
|
path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
|
|
1267
1576
|
lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
|
|
1268
|
-
content: z.string().optional().describe('[write] HTML/markdown/text. Mutually exclusive with file_path.'),
|
|
1577
|
+
content: z.string().optional().describe('[write] HTML/markdown/text. Mutually exclusive with file_path. Use a .html path for complete HTML documents and a .md path for Markdown.'),
|
|
1578
|
+
excalidraw_data: z.any().optional().describe('[write_excalidraw] Excalidraw scene JSON object or JSON string. Defaults to an empty scene.'),
|
|
1579
|
+
mermaid: z.string().optional().describe('[write_excalidraw] Mermaid source to convert into an editable Excalidraw scene.'),
|
|
1269
1580
|
file_path: z.string().optional().describe('[write] absolute path to a local file to upload. Mutually exclusive with content.'),
|
|
1581
|
+
googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] Create or attach a native Google Workspace frame. Use with title to create new, or url/googleId to attach existing.'),
|
|
1582
|
+
title: z.string().optional().describe('[write + googleType] Title for a new Google Doc/Sheet/Slide. Defaults to filename from path.'),
|
|
1583
|
+
url: z.string().optional().describe('[write + googleType] Existing Google Doc/Sheet/Slide URL to attach.'),
|
|
1584
|
+
googleId: z.string().optional().describe('[write + googleType] Existing Google file ID to attach.'),
|
|
1270
1585
|
autoSize: z.boolean().optional().describe('[write] measure HTML content and size frame to fit. Content only, not file_path.'),
|
|
1271
1586
|
width: z.number().optional().describe('[write] explicit width in pixels. Overrides layer default. Ignored if autoSize=true.'),
|
|
1272
1587
|
height: z.number().optional().describe('[write] explicit height in pixels. Overrides layer default. Ignored if autoSize=true.'),
|
|
1273
1588
|
color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
|
|
1274
1589
|
operations: z.array(z.object({
|
|
1275
1590
|
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
1276
|
-
lineHash: z.string().describe('
|
|
1591
|
+
lineHash: z.string().describe('Hash of the target line (from read output). Use the full hash to avoid ambiguity in large frames.'),
|
|
1277
1592
|
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
1278
1593
|
})).optional().describe('[edit] hashline edit operations'),
|
|
1279
1594
|
from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
|
|
@@ -1283,6 +1598,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1283
1598
|
query: z.string().optional().describe('[search] term to match against frame names'),
|
|
1284
1599
|
projectId: z.string().optional().describe('[search] limit to a specific project (optional)'),
|
|
1285
1600
|
limit: z.number().optional().describe('[search] max results (default 50, max 200)'),
|
|
1601
|
+
versionId: z.string().optional().describe('[read_version|restore_version] version id'),
|
|
1602
|
+
reason: z.string().optional().describe('[restore_version] reason recorded on the snapshot of current content'),
|
|
1286
1603
|
}, async (args) => {
|
|
1287
1604
|
try {
|
|
1288
1605
|
const { action } = args;
|
|
@@ -1330,28 +1647,55 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1330
1647
|
});
|
|
1331
1648
|
}
|
|
1332
1649
|
case 'write': {
|
|
1333
|
-
const { path, content, file_path, autoSize, width, height, color } = args;
|
|
1650
|
+
const { path, content, file_path, autoSize, width, height, color, googleType, title, url, googleId } = args;
|
|
1334
1651
|
if (!path) throw new Error('path required for action=write');
|
|
1335
|
-
|
|
1336
|
-
if (
|
|
1652
|
+
const writeSources = [content != null, !!file_path, !!googleType].filter(Boolean).length;
|
|
1653
|
+
if (writeSources > 1) throw new Error('Provide only one of content, file_path, or googleType');
|
|
1654
|
+
if (writeSources === 0) throw new Error('Provide content, file_path, or googleType');
|
|
1337
1655
|
const skillErr = await checkProjectSkills(getState().projectId);
|
|
1338
1656
|
if (skillErr) return err(new Error(skillErr));
|
|
1339
1657
|
const anchorErr = await checkAnchors(parseLayer(path));
|
|
1340
1658
|
if (anchorErr) return err(new Error(anchorErr));
|
|
1341
1659
|
const parts = path.replace(/^\/+/, '').split('/');
|
|
1342
1660
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
1661
|
+
if (googleType) {
|
|
1662
|
+
const projectId = getState().projectId;
|
|
1663
|
+
if (!projectId) throw new Error('Open a project first with project(action="open") before creating Google Workspace frames');
|
|
1664
|
+
const label = basename(parts[2], extname(parts[2])) || parts[2];
|
|
1665
|
+
const body = {
|
|
1666
|
+
projectId,
|
|
1667
|
+
layer: parts[0],
|
|
1668
|
+
lane: parts[1],
|
|
1669
|
+
label,
|
|
1670
|
+
type: googleType,
|
|
1671
|
+
width,
|
|
1672
|
+
height,
|
|
1673
|
+
};
|
|
1674
|
+
const result = url || googleId
|
|
1675
|
+
? await api('POST', '/api/google/workspace/frame', { ...body, url, googleId })
|
|
1676
|
+
: await api('POST', '/api/google/workspace/create-frame', { ...body, title: title || label });
|
|
1677
|
+
return ok(withProject(withFrameBreadcrumb({
|
|
1678
|
+
...result,
|
|
1679
|
+
path,
|
|
1680
|
+
label,
|
|
1681
|
+
contentType: 'text/html',
|
|
1682
|
+
sourceType: googleType,
|
|
1683
|
+
}, { hint: true })), {
|
|
1684
|
+
structuredContent: frameStructuredContent({ ...result, path, label, contentType: 'text/html' }, projectCtx),
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1343
1687
|
let body;
|
|
1344
1688
|
if (file_path) {
|
|
1345
1689
|
const resolved = resolve(file_path);
|
|
1346
1690
|
if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
|
|
1347
1691
|
const ext = extname(resolved).toLowerCase();
|
|
1348
|
-
const TEXT_EXTS = ['.html', '.htm', '.
|
|
1692
|
+
const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
|
|
1349
1693
|
if (TEXT_EXTS.includes(ext)) {
|
|
1350
1694
|
body = { content: readFileSync(resolved, 'utf8') };
|
|
1351
1695
|
if (autoSize) body.autoSize = true;
|
|
1352
1696
|
} else {
|
|
1353
1697
|
const buffer = readFileSync(resolved);
|
|
1354
|
-
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf' };
|
|
1698
|
+
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.m4v': 'video/x-m4v' };
|
|
1355
1699
|
body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
|
|
1356
1700
|
}
|
|
1357
1701
|
} else {
|
|
@@ -1367,6 +1711,28 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1367
1711
|
_meta: body.content ? { frameHtml: body.content } : undefined,
|
|
1368
1712
|
});
|
|
1369
1713
|
}
|
|
1714
|
+
case 'write_excalidraw': {
|
|
1715
|
+
const { path, excalidraw_data, mermaid, width, height, color } = args;
|
|
1716
|
+
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
1717
|
+
if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
|
|
1718
|
+
const skillErr = await checkProjectSkills(getState().projectId);
|
|
1719
|
+
if (skillErr) return err(new Error(skillErr));
|
|
1720
|
+
const anchorErr = await checkAnchors(parseLayer(path));
|
|
1721
|
+
if (anchorErr) return err(new Error(anchorErr));
|
|
1722
|
+
const parts = path.replace(/^\/+/, '').split('/');
|
|
1723
|
+
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
1724
|
+
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
1725
|
+
const scene = mermaid ? await excalidrawSceneFromMermaid(mermaid) : (excalidraw_data ?? emptyExcalidrawScene());
|
|
1726
|
+
const body = { content: stringifyExcalidrawScene(scene) };
|
|
1727
|
+
if (width) body.width = width;
|
|
1728
|
+
if (height) body.height = height;
|
|
1729
|
+
if (color) body.color = color;
|
|
1730
|
+
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
|
|
1731
|
+
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
1732
|
+
structuredContent: frameStructuredContent(result, projectCtx),
|
|
1733
|
+
_meta: { frameHtml: body.content },
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1370
1736
|
case 'edit': {
|
|
1371
1737
|
const { path, operations } = args;
|
|
1372
1738
|
if (!path) throw new Error('path required for action=edit');
|
|
@@ -1412,6 +1778,27 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1412
1778
|
if (skillErr) return err(new Error(skillErr));
|
|
1413
1779
|
return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
|
|
1414
1780
|
}
|
|
1781
|
+
|
|
1782
|
+
case 'versions': {
|
|
1783
|
+
const { path } = args;
|
|
1784
|
+
if (!path) throw new Error('path required for action=versions');
|
|
1785
|
+
const result = await api('GET', `/api/fs/versions?path=${encodeURIComponent(path)}`);
|
|
1786
|
+
return ok(withProject(result));
|
|
1787
|
+
}
|
|
1788
|
+
case 'read_version': {
|
|
1789
|
+
const { versionId } = args;
|
|
1790
|
+
if (!versionId) throw new Error('versionId required for action=read_version');
|
|
1791
|
+
const result = await api('GET', `/api/fs/versions/${versionId}`);
|
|
1792
|
+
return ok(withProject(result));
|
|
1793
|
+
}
|
|
1794
|
+
case 'restore_version': {
|
|
1795
|
+
const { versionId, reason } = args;
|
|
1796
|
+
if (!versionId) throw new Error('versionId required for action=restore_version');
|
|
1797
|
+
const skillErr = await checkProjectSkills(getState().projectId);
|
|
1798
|
+
if (skillErr) return err(new Error(skillErr));
|
|
1799
|
+
const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
|
|
1800
|
+
return ok(withProject(result));
|
|
1801
|
+
}
|
|
1415
1802
|
case 'search': {
|
|
1416
1803
|
const { query, projectId, limit = 50 } = args;
|
|
1417
1804
|
if (!query) throw new Error('query required for action=search');
|
|
@@ -1587,7 +1974,7 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
|
|
|
1587
1974
|
|
|
1588
1975
|
// Handle frame operations via the batch API
|
|
1589
1976
|
if (frameOps.length > 0) {
|
|
1590
|
-
const TEXT_EXTS = ['.html', '.htm', '.
|
|
1977
|
+
const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
|
|
1591
1978
|
const resolvedOps = frameOps.map(op => {
|
|
1592
1979
|
if (op.tool === 'write' && op.file_path) {
|
|
1593
1980
|
const resolved = resolve(op.file_path);
|
|
@@ -1805,7 +2192,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1805
2192
|
]).describe('Operation to perform.'),
|
|
1806
2193
|
query: z.string().optional().describe('[search] term to match against name/description/content'),
|
|
1807
2194
|
tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
|
|
1808
|
-
scope: z.enum(['all', 'org', 'global']).optional().describe('[search] scope (default: all)'),
|
|
2195
|
+
scope: z.enum(['all', 'org', 'global']).optional().describe('[search|list] library scope (default: all for search; when provided to list, lists the library instead of project/org attachments)'),
|
|
1809
2196
|
limit: z.number().optional().describe('[search] max results (default 25, max 100)'),
|
|
1810
2197
|
skill: z.string().optional().describe('[load] skill ID (UUID) or slug'),
|
|
1811
2198
|
skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
|
|
@@ -1829,10 +2216,14 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1829
2216
|
const endpoint = query ? '/api/skills/search' : '/api/skills';
|
|
1830
2217
|
const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`);
|
|
1831
2218
|
const cap = Math.min(Math.max(1, limit || 25), 100);
|
|
1832
|
-
if (Array.isArray(result?.skills)
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2219
|
+
if (Array.isArray(result?.skills)) {
|
|
2220
|
+
if (result.skills.length > cap) {
|
|
2221
|
+
result.totalAvailable = result.skills.length;
|
|
2222
|
+
result.truncated = true;
|
|
2223
|
+
result.skills = result.skills.slice(0, cap);
|
|
2224
|
+
}
|
|
2225
|
+
result.skills = result.skills.map(summarizeSkillForSearch);
|
|
2226
|
+
result.note = 'Search/list returns skill summaries only. Use skill(action="load", skill="<slug-or-id>") to read full SKILL.md content and supporting file list.';
|
|
1836
2227
|
}
|
|
1837
2228
|
return ok(result);
|
|
1838
2229
|
}
|
|
@@ -1846,6 +2237,18 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1846
2237
|
return ok(result);
|
|
1847
2238
|
}
|
|
1848
2239
|
case 'list': {
|
|
2240
|
+
if (args.scope || args.tags?.length) {
|
|
2241
|
+
const params = new URLSearchParams();
|
|
2242
|
+
params.set('scope', args.scope || 'all');
|
|
2243
|
+
if (args.tags?.length) params.set('tags', args.tags.join(','));
|
|
2244
|
+
const result = await api('GET', `/api/skills?${params.toString()}`);
|
|
2245
|
+
if (Array.isArray(result?.skills)) {
|
|
2246
|
+
result.skills = result.skills.map(summarizeSkillForSearch);
|
|
2247
|
+
result.note = 'Library list returns skill summaries only. Use skill(action="load", skill="<slug-or-id>") to read full SKILL.md content and supporting file list.';
|
|
2248
|
+
}
|
|
2249
|
+
return ok(result);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
1849
2252
|
// Prefer the explicit projectId param; otherwise the active project;
|
|
1850
2253
|
// otherwise fall back to org-attached skills so list works in
|
|
1851
2254
|
// empty-org / wiki-only sessions where there's no project to bind to.
|
|
@@ -1951,7 +2354,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
1951
2354
|
frontmatter: z.any().optional().describe('[write] frontmatter object'),
|
|
1952
2355
|
operations: z.array(z.object({
|
|
1953
2356
|
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
1954
|
-
lineHash: z.string().describe('
|
|
2357
|
+
lineHash: z.string().describe('Hash of the target line (from read output)'),
|
|
1955
2358
|
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
1956
2359
|
})).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
|
|
1957
2360
|
from: z.string().optional().describe('[mv] source path'),
|
|
@@ -2006,6 +2409,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2006
2409
|
title: p.title,
|
|
2007
2410
|
type: p.type,
|
|
2008
2411
|
id: p.id,
|
|
2412
|
+
url: wikiBrowserUrl(p.path),
|
|
2009
2413
|
}));
|
|
2010
2414
|
return ok({ tree });
|
|
2011
2415
|
}
|
|
@@ -2021,8 +2425,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2021
2425
|
}
|
|
2022
2426
|
return ok({
|
|
2023
2427
|
tree: [
|
|
2024
|
-
...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d })),
|
|
2025
|
-
...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id })),
|
|
2428
|
+
...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d, url: wikiBrowserUrl(d) })),
|
|
2429
|
+
...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) })),
|
|
2026
2430
|
],
|
|
2027
2431
|
});
|
|
2028
2432
|
}
|
|
@@ -2033,14 +2437,14 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2033
2437
|
const prefix = parent + '/';
|
|
2034
2438
|
for (const p of pages) {
|
|
2035
2439
|
if (p.path === parent) {
|
|
2036
|
-
children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
|
|
2440
|
+
children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
|
|
2037
2441
|
} else if (p.path.startsWith(prefix)) {
|
|
2038
2442
|
const rest = p.path.slice(prefix.length);
|
|
2039
2443
|
if (rest.includes('/')) {
|
|
2040
2444
|
const sub = rest.split('/')[0];
|
|
2041
|
-
if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub }); }
|
|
2445
|
+
if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub, url: wikiBrowserUrl(parent + '/' + sub) }); }
|
|
2042
2446
|
} else {
|
|
2043
|
-
children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
|
|
2447
|
+
children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
|
|
2044
2448
|
}
|
|
2045
2449
|
}
|
|
2046
2450
|
}
|
|
@@ -2055,12 +2459,12 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2055
2459
|
.filter(p => p.updatedAt)
|
|
2056
2460
|
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
|
2057
2461
|
.slice(0, Math.max(1, recentLimit))
|
|
2058
|
-
.map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt }));
|
|
2462
|
+
.map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt, url: wikiBrowserUrl(p.path) }));
|
|
2059
2463
|
return ok({ pages: recent });
|
|
2060
2464
|
}
|
|
2061
2465
|
|
|
2062
2466
|
// ── read ────────────────────────────────────────────────────
|
|
2063
|
-
// Returns content in hashline format (`
|
|
2467
|
+
// Returns content in hashline format (`LINE+ID|content`) so the
|
|
2064
2468
|
// agent can produce hashline edit operations. Mirrors frame.read.
|
|
2065
2469
|
case 'read': {
|
|
2066
2470
|
const { path: readPath, lines: readLines } = args;
|
|
@@ -2088,6 +2492,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2088
2492
|
lastEditedBy: page.updatedBy,
|
|
2089
2493
|
lastEditedAt: page.updatedAt,
|
|
2090
2494
|
backlinkCount,
|
|
2495
|
+
url: wikiBrowserUrl(page.path),
|
|
2091
2496
|
});
|
|
2092
2497
|
}
|
|
2093
2498
|
|
|
@@ -2096,7 +2501,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2096
2501
|
const { query: searchQuery, limit: searchLimit = 25 } = args;
|
|
2097
2502
|
if (!searchQuery) throw new Error('query required for action=search');
|
|
2098
2503
|
const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
|
|
2099
|
-
const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title }));
|
|
2504
|
+
const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
|
|
2100
2505
|
return ok({ hits });
|
|
2101
2506
|
}
|
|
2102
2507
|
|
|
@@ -2131,13 +2536,13 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2131
2536
|
title: 'Log',
|
|
2132
2537
|
content: entry + '\n',
|
|
2133
2538
|
});
|
|
2134
|
-
return ok(withOrg({ appended: true, created: true, pageId: created.id }));
|
|
2539
|
+
return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
|
|
2135
2540
|
}
|
|
2136
2541
|
|
|
2137
2542
|
// Append to existing log
|
|
2138
2543
|
const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
|
|
2139
2544
|
await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
|
|
2140
|
-
return ok(withOrg({ appended: true }));
|
|
2545
|
+
return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
|
|
2141
2546
|
}
|
|
2142
2547
|
|
|
2143
2548
|
// ── health ──────────────────────────────────────────────────
|
|
@@ -2160,17 +2565,39 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2160
2565
|
try {
|
|
2161
2566
|
const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
2162
2567
|
const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body);
|
|
2163
|
-
return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true }));
|
|
2568
|
+
return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path) }));
|
|
2164
2569
|
} catch {
|
|
2165
2570
|
const result = await api('POST', '/api/wiki/pages', body);
|
|
2166
|
-
return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true }));
|
|
2571
|
+
return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path) }));
|
|
2167
2572
|
}
|
|
2168
2573
|
}
|
|
2169
2574
|
|
|
2170
2575
|
// ── edit ────────────────────────────────────────────────────
|
|
2171
2576
|
// Hashlines come from `read` (which now formats content as
|
|
2172
|
-
// `
|
|
2577
|
+
// `LINE+ID|content`). The server applies the ops via the same
|
|
2173
2578
|
// hashline algorithm — no client-side re-hashing, no algorithm drift.
|
|
2579
|
+
case 'write_excalidraw': {
|
|
2580
|
+
const { path, excalidraw_data, mermaid, width, height, color } = args;
|
|
2581
|
+
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
2582
|
+
if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
|
|
2583
|
+
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2584
|
+
if (skillErr) return err(new Error(skillErr));
|
|
2585
|
+
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2586
|
+
if (anchorErr) return err(new Error(anchorErr));
|
|
2587
|
+
const parts = path.replace(/^\/+/, '').split('/');
|
|
2588
|
+
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
2589
|
+
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
2590
|
+
const scene = mermaid ? await excalidrawSceneFromMermaid(mermaid) : (excalidraw_data ?? emptyExcalidrawScene());
|
|
2591
|
+
const body = { content: stringifyExcalidrawScene(scene) };
|
|
2592
|
+
if (width) body.width = width;
|
|
2593
|
+
if (height) body.height = height;
|
|
2594
|
+
if (color) body.color = color;
|
|
2595
|
+
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
|
|
2596
|
+
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
2597
|
+
structuredContent: frameStructuredContent(result, projectCtx),
|
|
2598
|
+
_meta: { frameHtml: body.content },
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2174
2601
|
case 'edit': {
|
|
2175
2602
|
const { path: editPath, operations: editOps } = args;
|
|
2176
2603
|
if (!editPath) throw new Error('path required for action=edit');
|
|
@@ -2178,7 +2605,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2178
2605
|
const normalized = normalizeWikiPath(editPath);
|
|
2179
2606
|
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
2180
2607
|
const result = await api('POST', `/api/wiki/pages/${page.id}/edit`, { operations: editOps });
|
|
2181
|
-
return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied }));
|
|
2608
|
+
return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path) }));
|
|
2182
2609
|
}
|
|
2183
2610
|
|
|
2184
2611
|
// ── mv ──────────────────────────────────────────────────────
|
|
@@ -2199,7 +2626,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2199
2626
|
|
|
2200
2627
|
// Server-side cascade: /move rewrites referrers in one transaction
|
|
2201
2628
|
const moved = await api('PATCH', `/api/wiki/pages/${page.id}/move`, { path: toPath });
|
|
2202
|
-
return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0 }));
|
|
2629
|
+
return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path) }));
|
|
2203
2630
|
}
|
|
2204
2631
|
|
|
2205
2632
|
// ── rm ──────────────────────────────────────────────────────
|
|
@@ -2251,8 +2678,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2251
2678
|
return ok(withOrg({
|
|
2252
2679
|
created: result.created?.length ?? 0,
|
|
2253
2680
|
updated: result.updated?.length ?? 0,
|
|
2254
|
-
|
|
2255
|
-
|
|
2681
|
+
createdPages: (result.created ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
|
|
2682
|
+
updatedPages: (result.updated ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
|
|
2256
2683
|
errors: result.errors ?? [],
|
|
2257
2684
|
}));
|
|
2258
2685
|
}
|