drafted 1.7.25 → 1.8.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/mcp/gates.mjs +124 -0
- package/mcp/server.mjs +200 -177
- package/package.json +2 -2
- package/src/shared/gate-budget.mjs +30 -0
package/mcp/gates.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Pure gate logic for the Drafted compounding harness.
|
|
2
|
+
//
|
|
3
|
+
// These helpers are intentionally side-effect free (no `api()`, no network, no
|
|
4
|
+
// module state) so they can be unit tested in isolation. mcp/server.mjs imports
|
|
5
|
+
// them and wires the decisions into the tool handlers + per-session getState().
|
|
6
|
+
//
|
|
7
|
+
// See docs/plans/compounding-harness.md and the Drafted "gates-checklist" frame.
|
|
8
|
+
|
|
9
|
+
// One combined per-project budget for the auto-injected priming set:
|
|
10
|
+
// attached-skill bodies + project anchor bodies + the active layer's rules.
|
|
11
|
+
// Single source of truth lives in src/shared so the server-side deposit caps
|
|
12
|
+
// enforce the identical limit. Re-exported here for the MCP gate helpers.
|
|
13
|
+
import { PROJECT_CONTEXT_BUDGET_CHARS, selectWithinBudget } from '../src/shared/gate-budget.mjs';
|
|
14
|
+
export { PROJECT_CONTEXT_BUDGET_CHARS, selectWithinBudget };
|
|
15
|
+
|
|
16
|
+
// ── Per-session gate flags (reset every MCP session) ──────────────────────────
|
|
17
|
+
|
|
18
|
+
export function createGateState() {
|
|
19
|
+
return { wikiSearched: false, skillSearched: false, templateSearched: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// kind: 'wiki' | 'skill' | 'template'
|
|
23
|
+
export function markSearched(gateState, kind) {
|
|
24
|
+
if (kind === 'wiki') gateState.wikiSearched = true;
|
|
25
|
+
else if (kind === 'skill') gateState.skillSearched = true;
|
|
26
|
+
else if (kind === 'template') gateState.templateSearched = true;
|
|
27
|
+
return gateState;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Enforce gates (create chain). Return an error string if blocked, else null ─
|
|
31
|
+
|
|
32
|
+
export function g1Block(gateState, wikiIndex) {
|
|
33
|
+
if (gateState.wikiSearched) return null;
|
|
34
|
+
let msg =
|
|
35
|
+
'G1: search the org wiki before reading or editing anything. ' +
|
|
36
|
+
'Call wiki(action="search", query="<relevant terms>") first, then retry. ' +
|
|
37
|
+
'More knowledge = less searching — start by drawing on what the org already knows.';
|
|
38
|
+
if (wikiIndex) msg += `\n\nWiki index (what exists to search):\n${wikiIndex}`;
|
|
39
|
+
return msg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function g2Block(gateState) {
|
|
43
|
+
if (gateState.skillSearched) return null;
|
|
44
|
+
return (
|
|
45
|
+
'G2: search for prior-art skills before creating one. ' +
|
|
46
|
+
'Call skill(action="search", query="<topic>") first — if a close match exists, improve it ' +
|
|
47
|
+
'(/drafted:improve-skill) instead of duplicating — then retry skill(action="add").'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function g3Block(gateState) {
|
|
52
|
+
const missing = [];
|
|
53
|
+
if (!gateState.wikiSearched) missing.push('wiki(action="search")');
|
|
54
|
+
if (!gateState.skillSearched) missing.push('skill(action="search")');
|
|
55
|
+
if (!gateState.templateSearched) missing.push('template(action="list")');
|
|
56
|
+
if (missing.length === 0) return null;
|
|
57
|
+
return (
|
|
58
|
+
`G3: search before creating a project. Run ${missing.join(', ')} first ` +
|
|
59
|
+
'(reuse existing knowledge, skills, and templates), then retry project(action="create").'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Per-project context budget (auto-inject set) ──────────────────────────────
|
|
64
|
+
|
|
65
|
+
function itemChars(it) {
|
|
66
|
+
if (typeof it === 'number') return it;
|
|
67
|
+
if (typeof it === 'string') return it.length;
|
|
68
|
+
if (it && typeof it === 'object') {
|
|
69
|
+
if (typeof it.chars === 'number') return it.chars;
|
|
70
|
+
if (it.content != null) return String(it.content).length;
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sumChars(items) {
|
|
76
|
+
if (!Array.isArray(items)) return 0;
|
|
77
|
+
return items.reduce((total, it) => total + itemChars(it), 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function budgetRemaining(currentTotal, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
81
|
+
return Math.max(0, budget - currentTotal);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function wouldExceedBudget(currentTotal, addChars, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
85
|
+
return currentTotal + addChars > budget;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function budgetError(currentTotal, addChars, label, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
89
|
+
const remaining = budgetRemaining(currentTotal, budget);
|
|
90
|
+
return (
|
|
91
|
+
`Per-project context budget exceeded: ${label} needs ${addChars} chars but only ${remaining} ` +
|
|
92
|
+
`of ${budget} remain. Tighten existing attached skills / anchors / layer rules first ` +
|
|
93
|
+
`(see /drafted:improve-project-harness).`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── G6 layer-rule default detection ───────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
// A layer prompt is "default" (skip G6 inject) when it is empty or byte-identical
|
|
100
|
+
// to the template's default prompt for that layer key.
|
|
101
|
+
export function isLayerPromptDefault(layerPrompt, templateDefaultPrompt) {
|
|
102
|
+
const p = (layerPrompt ?? '').trim();
|
|
103
|
+
if (p === '') return true;
|
|
104
|
+
const d = (templateDefaultPrompt ?? '').trim();
|
|
105
|
+
return d !== '' && p === d;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function shouldInjectLayerPrompt(layerPrompt, templateDefaultPrompt) {
|
|
109
|
+
return !isLayerPromptDefault(layerPrompt, templateDefaultPrompt);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Wiki index formatting (bounded map injected with the G1 block) ────────────
|
|
113
|
+
|
|
114
|
+
export function formatWikiIndex(pages, max = 50) {
|
|
115
|
+
if (!Array.isArray(pages) || pages.length === 0) return '(wiki is empty — no pages yet)';
|
|
116
|
+
const lines = pages.slice(0, max).map((p) => {
|
|
117
|
+
const path = typeof p === 'string' ? p : p.path ?? p.slug ?? '';
|
|
118
|
+
const title = typeof p === 'object' && p && p.title ? ` — ${p.title}` : '';
|
|
119
|
+
return ` ${path}${title}`;
|
|
120
|
+
});
|
|
121
|
+
const more = pages.length > max ? `\n …and ${pages.length - max} more` : '';
|
|
122
|
+
return lines.join('\n') + more;
|
|
123
|
+
}
|
|
124
|
+
|
package/mcp/server.mjs
CHANGED
|
@@ -20,6 +20,16 @@ import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/e
|
|
|
20
20
|
import WebSocket from 'ws';
|
|
21
21
|
import { LAYERS } from '../src/shared/constants.mjs';
|
|
22
22
|
import { emptyExcalidrawScene, excalidrawSceneFromMermaid, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
|
|
23
|
+
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
24
|
+
|
|
25
|
+
// Frame actions that mutate content — gated by G1 (wiki search before editing).
|
|
26
|
+
// Read-style actions (read, search, versions, get_*/read_*) are exempt.
|
|
27
|
+
const G1_MUTATING_FRAME_ACTIONS = new Set([
|
|
28
|
+
'write', 'write_excalidraw', 'edit', 'mv', 'anchor', 'restore_version',
|
|
29
|
+
'write_sheet_values', 'append_sheet_rows', 'clear_sheet_range', 'update_sheet',
|
|
30
|
+
'write_doc_content', 'append_doc_content', 'clear_doc_content', 'update_doc',
|
|
31
|
+
'write_slide_content', 'append_slides', 'clear_slides', 'update_slide',
|
|
32
|
+
]);
|
|
23
33
|
const { UMAMI_EVENTS, trackUmamiEvent } = await (async () => {
|
|
24
34
|
try {
|
|
25
35
|
return await import('../server/lib/umami.mjs');
|
|
@@ -76,6 +86,7 @@ function getOrCreateSessionState(sid) {
|
|
|
76
86
|
activeProjectId: null,
|
|
77
87
|
activeProjectMeta: null,
|
|
78
88
|
loadedSkillIds: new Set(),
|
|
89
|
+
gates: createGateState(),
|
|
79
90
|
cachedOrgId: null,
|
|
80
91
|
cachedOrgIdTime: 0,
|
|
81
92
|
wsSessionId: null,
|
|
@@ -112,6 +123,23 @@ export function runWithRequestState(initial, fn) {
|
|
|
112
123
|
// Stripped from the advertised schema on remote transports.
|
|
113
124
|
const LOCAL_ONLY_PARAMS = ['file_path'];
|
|
114
125
|
|
|
126
|
+
// Per-tool params whose value is arbitrary / deeply-nested JSON. Zod renders
|
|
127
|
+
// these (z.any(), z.array(z.any()), z.object({}).passthrough()) as an UNTYPED
|
|
128
|
+
// schema — an empty `{}` or an array whose `items` has no `type`. ChatGPT's
|
|
129
|
+
// connector import runs OpenAI's strict Structured-Outputs JSON-Schema
|
|
130
|
+
// validator over the whole `tools/list` and rejects the ENTIRE connector if any
|
|
131
|
+
// tool contains an untyped subschema (Claude tolerates it; ChatGPT does not).
|
|
132
|
+
// On remote transports we advertise each of these as a single JSON-encoded
|
|
133
|
+
// string (always a valid typed schema) and parse it back to an object in the
|
|
134
|
+
// tool wrapper before the handler runs — so the stdio contract, the Zod runtime
|
|
135
|
+
// validation, and every handler stay byte-for-byte unchanged.
|
|
136
|
+
const REMOTE_JSON_STRING_PARAMS = {
|
|
137
|
+
frame: ['excalidraw_data', 'requests'],
|
|
138
|
+
wiki: ['frontmatter', 'pages'],
|
|
139
|
+
project: ['layers'],
|
|
140
|
+
template: ['layers'],
|
|
141
|
+
};
|
|
142
|
+
|
|
115
143
|
// Remove sentences that reference local-file params from a tool description,
|
|
116
144
|
// so remote-transport descriptions don't mention options that were stripped.
|
|
117
145
|
function scrubLocalPathMentions(description) {
|
|
@@ -290,6 +318,15 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
|
290
318
|
inputSchema[k] = field.describe(scrubLocalPathMentions(field.description));
|
|
291
319
|
}
|
|
292
320
|
}
|
|
321
|
+
// Present arbitrary-JSON params as a JSON string so the advertised schema is
|
|
322
|
+
// ChatGPT-valid (untyped `{}` subschemas fail OpenAI's strict validator and
|
|
323
|
+
// fail the whole connector). The wrapper below parses them back.
|
|
324
|
+
for (const k of (REMOTE_JSON_STRING_PARAMS[name] || [])) {
|
|
325
|
+
const field = inputSchema[k];
|
|
326
|
+
if (!field) continue;
|
|
327
|
+
const orig = field.description ? ` ${field.description}` : '';
|
|
328
|
+
inputSchema[k] = z.string().optional().describe(`JSON-encoded value — pass a JSON string.${orig}`);
|
|
329
|
+
}
|
|
293
330
|
description = scrubLocalPathMentions(description);
|
|
294
331
|
}
|
|
295
332
|
const config = {
|
|
@@ -316,6 +353,17 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
|
316
353
|
try {
|
|
317
354
|
const requiredUpdateError = await getRequiredMcpUpdateError(name, args?.[0] || {});
|
|
318
355
|
if (requiredUpdateError) return err(new Error(requiredUpdateError));
|
|
356
|
+
// Reverse the remote JSON-string encoding (see REMOTE_JSON_STRING_PARAMS)
|
|
357
|
+
// so handlers receive the same object/array shapes they get on stdio.
|
|
358
|
+
if (isRemote && args?.[0] && typeof args[0] === 'object') {
|
|
359
|
+
for (const k of (REMOTE_JSON_STRING_PARAMS[name] || [])) {
|
|
360
|
+
const v = args[0][k];
|
|
361
|
+
if (typeof v === 'string' && v.trim()) {
|
|
362
|
+
try { args[0][k] = JSON.parse(v); }
|
|
363
|
+
catch { return err(new Error(`Param "${k}" must be a JSON-encoded ${k === 'excalidraw_data' || k === 'frontmatter' ? 'object' : 'array'} string; JSON.parse failed.`)); }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
319
367
|
return await cb(...args);
|
|
320
368
|
} finally {
|
|
321
369
|
state.currentTool = previousTool;
|
|
@@ -614,34 +662,50 @@ function schedulePendingAuthPoll(delayMs = 2000) {
|
|
|
614
662
|
if (typeof pendingAuthPollTimer.unref === 'function') pendingAuthPollTimer.unref();
|
|
615
663
|
}
|
|
616
664
|
|
|
665
|
+
// Cookie for normal tool operations. Deliberately uses ONLY this instance's
|
|
666
|
+
// per-process session (getState().sessionId) and never the shared root login in
|
|
667
|
+
// ~/.drafted/auth.json. The root is a *credential for minting child sessions*
|
|
668
|
+
// (see cloneSession): every agent process on the machine reads the same root, so
|
|
669
|
+
// operating on it directly is exactly what let one agent's get_org switch move
|
|
670
|
+
// every other agent's active org. ensureSession() guarantees a per-instance
|
|
671
|
+
// session before any operation runs.
|
|
617
672
|
function getAuthHeaders() {
|
|
618
|
-
const sid = getState().sessionId
|
|
673
|
+
const sid = getState().sessionId;
|
|
619
674
|
if (sid) return { Cookie: `gc_session=${sid}` };
|
|
620
675
|
return {};
|
|
621
676
|
}
|
|
622
677
|
|
|
678
|
+
// Mint a fresh per-instance child session from the shared root login and bind it
|
|
679
|
+
// to this process. The root session id (getBootstrapSessionId) is shared by every
|
|
680
|
+
// agent on this machine via ~/.drafted/auth.json; the clone gives THIS process its
|
|
681
|
+
// own server-side session so its active org/project stay isolated from other
|
|
682
|
+
// agents. Returns true once a per-instance session is set.
|
|
623
683
|
async function cloneSession() {
|
|
684
|
+
if (getState().sessionId) return true;
|
|
624
685
|
const bootstrapId = getBootstrapSessionId();
|
|
625
|
-
if (!bootstrapId) return;
|
|
686
|
+
if (!bootstrapId) return false;
|
|
626
687
|
|
|
627
688
|
try {
|
|
628
|
-
const
|
|
629
|
-
const res = await fetch(url, {
|
|
689
|
+
const res = await fetch(`${getServerUrl()}/auth/session/clone`, {
|
|
630
690
|
method: 'POST',
|
|
631
691
|
headers: { Cookie: `gc_session=${bootstrapId}` },
|
|
632
692
|
});
|
|
633
|
-
if (
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
693
|
+
if (res.ok) {
|
|
694
|
+
const data = await res.json();
|
|
695
|
+
if (data.sessionId) {
|
|
696
|
+
getState().sessionId = data.sessionId;
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
637
699
|
}
|
|
638
|
-
} catch { /* server may not be ready yet
|
|
700
|
+
} catch { /* server may not be ready yet; caller retries on next API call */ }
|
|
701
|
+
return false;
|
|
639
702
|
}
|
|
640
703
|
|
|
641
704
|
async function ensureSession() {
|
|
642
705
|
if (getState().sessionId) return;
|
|
643
|
-
|
|
644
|
-
|
|
706
|
+
// A pending device-code login (from `auth get_link`) takes priority; consuming
|
|
707
|
+
// it mints this instance's own session. Otherwise clone the saved root login.
|
|
708
|
+
if (await consumePendingDeviceCode()) return;
|
|
645
709
|
await cloneSession();
|
|
646
710
|
}
|
|
647
711
|
|
|
@@ -905,94 +969,10 @@ function err(error) {
|
|
|
905
969
|
return { content: [{ type: 'text', text: error.message || String(error) }], isError: true };
|
|
906
970
|
}
|
|
907
971
|
|
|
908
|
-
// ── Anchor enforcement ────────────────────────────────────────────
|
|
909
|
-
// Track which frames this session has fully read (no line range = full read)
|
|
910
|
-
const readFrameIds = new Set();
|
|
911
|
-
|
|
912
|
-
// Cache anchored frames (refreshed on each check)
|
|
913
|
-
let anchoredCache = null;
|
|
914
|
-
let anchoredCacheTime = 0;
|
|
915
|
-
|
|
916
|
-
async function getAnchoredFrames() {
|
|
917
|
-
// Cache for 10 seconds to avoid hammering the API
|
|
918
|
-
if (anchoredCache && Date.now() - anchoredCacheTime < 10000) return anchoredCache;
|
|
919
|
-
try {
|
|
920
|
-
anchoredCache = await api('GET', '/api/designs/anchored');
|
|
921
|
-
anchoredCacheTime = Date.now();
|
|
922
|
-
return anchoredCache;
|
|
923
|
-
} catch {
|
|
924
|
-
return [];
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
972
|
function parseLayer(path) {
|
|
929
973
|
return path.replace(/^\/+/, '').split('/')[0];
|
|
930
974
|
}
|
|
931
975
|
|
|
932
|
-
async function checkAnchors(layer) {
|
|
933
|
-
const anchored = await getAnchoredFrames();
|
|
934
|
-
if (!Array.isArray(anchored)) return null;
|
|
935
|
-
|
|
936
|
-
const layerAnchors = anchored.filter(f => f.layer === layer);
|
|
937
|
-
if (layerAnchors.length === 0) return null;
|
|
938
|
-
|
|
939
|
-
const unread = layerAnchors.filter(f => !readFrameIds.has(f.id));
|
|
940
|
-
if (unread.length === 0) return null;
|
|
941
|
-
|
|
942
|
-
const paths = unread.map(f => `/${f.layer}/${f.lane}/${f.label}`);
|
|
943
|
-
return `This layer has ${layerAnchors.length} anchored frame(s) that must be read before making changes. ` +
|
|
944
|
-
`Unread anchors:\n${paths.map(p => ' read path="' + p + '"').join('\n')}\n\n` +
|
|
945
|
-
`Read all anchored frames first, then retry your operation.`;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// ── Project skill enforcement ─────────────────────────────────────
|
|
949
|
-
// Skills attached to a project must be loaded by the agent before any
|
|
950
|
-
// mutating operation in that project. Same shape as anchor enforcement.
|
|
951
|
-
//
|
|
952
|
-
// Loaded-skill state is per-MCP-session (see getSessionState) so that
|
|
953
|
-
// (a) skill.load + wiki.write across separate HTTP requests stay coherent
|
|
954
|
-
// and (b) one user's loads never satisfy another user's gate.
|
|
955
|
-
const projectSkillsCache = new Map(); // projectId -> { data, time }
|
|
956
|
-
|
|
957
|
-
async function getProjectSkills(projectId) {
|
|
958
|
-
if (!projectId) return [];
|
|
959
|
-
const cached = projectSkillsCache.get(projectId);
|
|
960
|
-
if (cached && Date.now() - cached.time < 10000) return cached.data;
|
|
961
|
-
try {
|
|
962
|
-
const result = await api('GET', `/api/projects/${projectId}/skills`);
|
|
963
|
-
const skills = Array.isArray(result?.skills) ? result.skills : (Array.isArray(result) ? result : []);
|
|
964
|
-
projectSkillsCache.set(projectId, { data: skills, time: Date.now() });
|
|
965
|
-
return skills;
|
|
966
|
-
} catch {
|
|
967
|
-
return [];
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
function invalidateProjectSkillsCache(projectId) {
|
|
972
|
-
if (projectId) projectSkillsCache.delete(projectId);
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
async function checkProjectSkills(projectId, operation) {
|
|
976
|
-
if (!projectId) return null;
|
|
977
|
-
const skills = await getProjectSkills(projectId);
|
|
978
|
-
if (skills.length === 0) return null;
|
|
979
|
-
const loaded = getSessionState().loadedSkillIds;
|
|
980
|
-
const unloaded = skills.filter(s => !loaded.has(s.id));
|
|
981
|
-
if (unloaded.length === 0) return null;
|
|
982
|
-
|
|
983
|
-
// Fire-and-forget: tell the server so any open browser shows a toast.
|
|
984
|
-
api('POST', `/api/projects/${projectId}/skill-gate-events`, {
|
|
985
|
-
unloadedSkills: unloaded.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
|
986
|
-
operation: operation || 'mutation',
|
|
987
|
-
agent: process.env.DRAFTED_AGENT_NAME || null,
|
|
988
|
-
}).catch(() => { /* non-fatal */ });
|
|
989
|
-
|
|
990
|
-
const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
|
|
991
|
-
return `This project has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
|
|
992
|
-
`Unloaded skills:\n${lines.join('\n')}\n\n` +
|
|
993
|
-
`Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
976
|
// ── Org skill enforcement ────────────────────────────────────────
|
|
997
977
|
// Same shape as project skill enforcement but for org-attached skills.
|
|
998
978
|
// Wiki mutations require org skills to be loaded first.
|
|
@@ -1027,6 +1007,42 @@ async function getOrgSkills(orgId) {
|
|
|
1027
1007
|
} catch { return []; }
|
|
1028
1008
|
}
|
|
1029
1009
|
|
|
1010
|
+
// Sum the per-project priming content (attached skill bodies + project anchor
|
|
1011
|
+
// bodies) so deposits can be capped against the per-project context budget (G4/G5).
|
|
1012
|
+
async function getProjectPrimingSize(projectId) {
|
|
1013
|
+
if (!projectId) return 0;
|
|
1014
|
+
let total = 0;
|
|
1015
|
+
try {
|
|
1016
|
+
const { skills = [] } = (await api('GET', `/api/projects/${projectId}/skills`)) || {};
|
|
1017
|
+
for (const s of skills) {
|
|
1018
|
+
try { const full = await api('GET', `/api/skills/${s.id}`); total += (full?.content || '').length; } catch { /* skip */ }
|
|
1019
|
+
}
|
|
1020
|
+
} catch { /* skills unavailable */ }
|
|
1021
|
+
try {
|
|
1022
|
+
const anchored = await api('GET', `/api/designs/anchored?projectId=${projectId}`);
|
|
1023
|
+
for (const a of (Array.isArray(anchored) ? anchored : [])) total += (a?.content || '').length;
|
|
1024
|
+
} catch { /* anchors unavailable */ }
|
|
1025
|
+
return total;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Best-effort size (chars) of a frame's content by path / URL / id — for the
|
|
1029
|
+
// anchor deposit cap. Returns 0 if it can't be read (cap then allows).
|
|
1030
|
+
async function getFrameContentSize(path) {
|
|
1031
|
+
try {
|
|
1032
|
+
const s = String(path);
|
|
1033
|
+
const frameId = s.match(/\/f\/([a-f0-9-]{36})/)?.[1] || (s.match(/^[a-f0-9-]{36}$/) ? s : null);
|
|
1034
|
+
let result;
|
|
1035
|
+
if (frameId) {
|
|
1036
|
+
result = await api('GET', `/api/fs/by-id/${frameId}`);
|
|
1037
|
+
} else {
|
|
1038
|
+
const parts = s.replace(/^\/+/, '').split('/');
|
|
1039
|
+
if (parts.length !== 3) return 0;
|
|
1040
|
+
result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
|
|
1041
|
+
}
|
|
1042
|
+
return (result?.content || '').length;
|
|
1043
|
+
} catch { return 0; }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1030
1046
|
async function checkOrgSkills(orgId, operation) {
|
|
1031
1047
|
if (!orgId) return null;
|
|
1032
1048
|
const skills = await getOrgSkills(orgId);
|
|
@@ -1130,10 +1146,14 @@ async function consumePendingDeviceCode() {
|
|
|
1130
1146
|
if (!res.ok) return false;
|
|
1131
1147
|
const data = await res.json();
|
|
1132
1148
|
if (data.status === 'approved' && data.sessionId) {
|
|
1149
|
+
// data.sessionId is the shared ROOT login. Persist it as the machine-level
|
|
1150
|
+
// credential, then mint our OWN per-instance child session from it (mirrors
|
|
1151
|
+
// the `auth login` tool) so this process never operates on the shared root.
|
|
1133
1152
|
persistAuthSession(data);
|
|
1134
|
-
getState().sessionId = data.sessionId;
|
|
1135
1153
|
clearPendingDeviceCode();
|
|
1136
|
-
|
|
1154
|
+
getState().sessionId = null;
|
|
1155
|
+
await cloneSession();
|
|
1156
|
+
return !!getState().sessionId;
|
|
1137
1157
|
}
|
|
1138
1158
|
if (data.status === 'expired') {
|
|
1139
1159
|
clearPendingDeviceCode();
|
|
@@ -1303,42 +1323,62 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1303
1323
|
exec(`${cmd} ${JSON.stringify(url)}`);
|
|
1304
1324
|
}
|
|
1305
1325
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1326
|
+
// G4/G5 auto-inject (locked design): the project's attached skills + anchors
|
|
1327
|
+
// are pushed into the open response within the per-project context budget,
|
|
1328
|
+
// replacing the reject-style gate. Prefer the server-computed `priming`
|
|
1329
|
+
// (authoritative + fresh on deploy); fall back to MCP-side assembly for
|
|
1330
|
+
// older servers that don't return it.
|
|
1331
|
+
let responseExtras;
|
|
1332
|
+
const priming = result && result.priming ? result.priming : null;
|
|
1333
|
+
if (priming) {
|
|
1334
|
+
const primedSkills = priming.skills || [];
|
|
1335
|
+
const primedAnchors = priming.anchors || [];
|
|
1336
|
+
for (const s of primedSkills) getSessionState().loadedSkillIds.add(s.id);
|
|
1337
|
+
responseExtras = { url, opened: true, navigated, skills: primedSkills, anchors: primedAnchors };
|
|
1338
|
+
if (priming.budgetNotice) responseExtras.budgetNotice = priming.budgetNotice;
|
|
1339
|
+
} else {
|
|
1340
|
+
let projectSkillsList = [];
|
|
1341
|
+
try {
|
|
1342
|
+
const skillData = await api('GET', `/api/projects/${projectId}/skills`);
|
|
1343
|
+
projectSkillsList = skillData.skills || [];
|
|
1344
|
+
} catch { /* skills not available yet */ }
|
|
1313
1345
|
for (const s of projectSkillsList) {
|
|
1314
1346
|
try {
|
|
1315
1347
|
const full = await api('GET', `/api/skills/${s.id}`);
|
|
1316
1348
|
s.content = full.content;
|
|
1317
1349
|
s.files = full.files || [];
|
|
1318
|
-
|
|
1319
|
-
// so the project-skill gate doesn't immediately demand a re-load.
|
|
1320
|
-
getSessionState().loadedSkillIds.add(s.id);
|
|
1321
|
-
} catch { /* skip */ }
|
|
1350
|
+
} catch { /* leaves this skill without inlined content */ }
|
|
1322
1351
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1352
|
+
let anchors = [];
|
|
1353
|
+
try {
|
|
1354
|
+
const anchored = await api('GET', `/api/designs/anchored?projectId=${projectId}`);
|
|
1355
|
+
anchors = (Array.isArray(anchored) ? anchored : []).map(a => ({
|
|
1356
|
+
id: a.id,
|
|
1357
|
+
path: a.path || `/${a.layer || ''}/${a.lane || ''}/${a.label || ''}`,
|
|
1358
|
+
layer: a.layer,
|
|
1359
|
+
content: a.content || '',
|
|
1360
|
+
}));
|
|
1361
|
+
} catch { /* anchors unavailable */ }
|
|
1362
|
+
const sel = selectWithinBudget([...projectSkillsList, ...anchors], PROJECT_CONTEXT_BUDGET_CHARS);
|
|
1363
|
+
for (const it of sel.included) {
|
|
1364
|
+
if (projectSkillsList.includes(it)) getSessionState().loadedSkillIds.add(it.id);
|
|
1365
|
+
}
|
|
1366
|
+
for (const it of sel.deferred) { it.content = undefined; if ('files' in it) it.files = undefined; }
|
|
1367
|
+
responseExtras = { url, opened: true, navigated, skills: projectSkillsList, anchors };
|
|
1368
|
+
if (sel.deferred.length > 0) {
|
|
1369
|
+
responseExtras.budgetNotice =
|
|
1370
|
+
`${sel.deferred.length} attached skill(s)/anchor(s) exceeded the per-project context budget ` +
|
|
1371
|
+
`(${PROJECT_CONTEXT_BUDGET_CHARS} chars) and were not inlined — load explicitly if needed: ` +
|
|
1372
|
+
sel.deferred.map(it => it.slug || it.path || it.id).join(', ');
|
|
1337
1373
|
}
|
|
1338
1374
|
}
|
|
1375
|
+
// Don't echo the raw priming blob (surfaced via skills/anchors/budgetNotice).
|
|
1376
|
+
if (result && typeof result === 'object') delete result.priming;
|
|
1339
1377
|
return ok({ ...result, ...responseExtras });
|
|
1340
1378
|
}
|
|
1341
1379
|
case 'create': {
|
|
1380
|
+
const g3 = g3Block(getSessionState().gates);
|
|
1381
|
+
if (g3) return err(new Error(g3));
|
|
1342
1382
|
const { name, description, templateSlug } = args;
|
|
1343
1383
|
if (!name) throw new Error('name required for action=create');
|
|
1344
1384
|
const body = { name };
|
|
@@ -1380,8 +1420,11 @@ tool('template', 'Manage project templates in the org. Dispatch by `action`: lis
|
|
|
1380
1420
|
try {
|
|
1381
1421
|
const { action } = args;
|
|
1382
1422
|
switch (action) {
|
|
1383
|
-
case 'list':
|
|
1384
|
-
|
|
1423
|
+
case 'list': {
|
|
1424
|
+
const tpls = await api('GET', '/api/templates');
|
|
1425
|
+
markSearched(getSessionState().gates, 'template');
|
|
1426
|
+
return ok(tpls);
|
|
1427
|
+
}
|
|
1385
1428
|
case 'create': {
|
|
1386
1429
|
const { name, description, layers, skillSlugs, visibility } = args;
|
|
1387
1430
|
if (!name || !description || !layers) throw new Error('name, description, layers required for action=create');
|
|
@@ -1468,6 +1511,7 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
|
|
|
1468
1511
|
frameId = frame.id;
|
|
1469
1512
|
}
|
|
1470
1513
|
const url = `${getServerUrl()}/api/screenshot/${frameId}?width=${width}&height=${height}&fullPage=${fullPage}`;
|
|
1514
|
+
await ensureSession();
|
|
1471
1515
|
const res = await fetch(url, { headers: getAuthHeaders() });
|
|
1472
1516
|
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
|
|
1473
1517
|
const buffer = await res.arrayBuffer();
|
|
@@ -1486,6 +1530,7 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
|
|
|
1486
1530
|
targetSlug = active.slug;
|
|
1487
1531
|
}
|
|
1488
1532
|
const url = `${getServerUrl()}/api/canvas-screenshot/${encodeURIComponent(targetSlug)}?layer=${encodeURIComponent(layer)}&width=${width}&height=${height}`;
|
|
1533
|
+
await ensureSession();
|
|
1489
1534
|
const res = await fetch(url, { headers: getAuthHeaders() });
|
|
1490
1535
|
if (!res.ok) throw new Error(`Canvas screenshot failed: ${res.status} ${await res.text()}`);
|
|
1491
1536
|
const buffer = await res.arrayBuffer();
|
|
@@ -1514,8 +1559,6 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
|
|
|
1514
1559
|
try {
|
|
1515
1560
|
const { action, projectId } = args;
|
|
1516
1561
|
if (!projectId) throw new Error('projectId is required');
|
|
1517
|
-
const skillErr = await checkProjectSkills(projectId);
|
|
1518
|
-
if (skillErr) return err(new Error(skillErr));
|
|
1519
1562
|
switch (action) {
|
|
1520
1563
|
case 'add': {
|
|
1521
1564
|
const { key, label, type, width, height, description, prompt } = args;
|
|
@@ -1827,11 +1870,11 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1827
1870
|
bullets: z.array(z.string()).optional(),
|
|
1828
1871
|
speakerNotes: z.string().optional(),
|
|
1829
1872
|
layout: z.string().optional(),
|
|
1830
|
-
})
|
|
1873
|
+
})).optional().describe('[write_slide_content|append_slides] Structured slide spec: [{ title, bullets, speakerNotes?, layout? }].'),
|
|
1831
1874
|
requests: z.array(z.any()).optional().describe('[update_doc|update_slide] Raw Google Docs/Slides batchUpdate requests for advanced updates only; common Doc/Slide population should use write_doc_content/append_doc_content/write_slide_content/append_slides.'),
|
|
1832
1875
|
slideObjectIds: z.array(z.string()).optional().describe('[clear_slides] Optional slide object IDs to delete. Omit to clear all slides.'),
|
|
1833
1876
|
range: z.string().optional().describe('[Sheet value actions] A1 range, e.g. Sheet1!A1 or Data!A:Z.'),
|
|
1834
|
-
values: z.array(z.array(z.
|
|
1877
|
+
values: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional().describe('[write_sheet_values|append_sheet_rows] 2D array of row values (each cell: string, number, boolean, or null).'),
|
|
1835
1878
|
valueInputOption: z.enum(['RAW', 'USER_ENTERED']).optional().describe('[write_sheet_values|append_sheet_rows] Google Sheets value input option. Defaults to USER_ENTERED.'),
|
|
1836
1879
|
majorDimension: z.enum(['ROWS', 'COLUMNS']).optional().describe('[Sheet value actions] Major dimension for values. Defaults to ROWS when writing/appending.'),
|
|
1837
1880
|
valueRenderOption: z.enum(['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA']).optional().describe('[read_sheet_values] How values should be rendered. Defaults to Google Sheets API default.'),
|
|
@@ -1862,6 +1905,16 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1862
1905
|
}, async (args) => {
|
|
1863
1906
|
try {
|
|
1864
1907
|
const { action } = args;
|
|
1908
|
+
// G1: don't edit frame content before the session has searched the wiki.
|
|
1909
|
+
// When blocking, auto-inject the (bounded) wiki index so the search lands.
|
|
1910
|
+
if (G1_MUTATING_FRAME_ACTIONS.has(action)) {
|
|
1911
|
+
const gs = getSessionState().gates;
|
|
1912
|
+
if (!gs.wikiSearched) {
|
|
1913
|
+
let wikiIndex = '';
|
|
1914
|
+
try { const tree = await api('GET', '/api/wiki/tree'); wikiIndex = formatWikiIndex(tree?.pages || []); } catch { /* index best-effort */ }
|
|
1915
|
+
return err(new Error(g1Block(gs, wikiIndex)));
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1865
1918
|
// Echo project context on every mutation so the agent sees where the
|
|
1866
1919
|
// write landed before it can develop a wrong assumption.
|
|
1867
1920
|
const projectCtx = getCurrentProjectContext();
|
|
@@ -1902,9 +1955,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1902
1955
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
|
|
1903
1956
|
result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}${query}`);
|
|
1904
1957
|
}
|
|
1905
|
-
if (!lines && result.ok && result.id) {
|
|
1906
|
-
readFrameIds.add(result.id);
|
|
1907
|
-
}
|
|
1908
1958
|
// Surface content as the visible text. Some Claude clients prefer
|
|
1909
1959
|
// structuredContent over text when both are present and structured looks
|
|
1910
1960
|
// "complete" — so include content in BOTH places to ensure the agent
|
|
@@ -2036,10 +2086,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2036
2086
|
const writeSources = [content != null, !!file_path, base64 != null, !!googleType].filter(Boolean).length;
|
|
2037
2087
|
if (writeSources > 1) throw new Error('Provide only one of content, file_path, base64, or googleType');
|
|
2038
2088
|
if (writeSources === 0) throw new Error('Provide content, file_path, base64, or googleType');
|
|
2039
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2040
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2041
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2042
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2043
2089
|
const parts = path.replace(/^\/+/, '').split('/');
|
|
2044
2090
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
2045
2091
|
if (googleType) {
|
|
@@ -2101,10 +2147,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2101
2147
|
const { path, excalidraw_data, mermaid, width, height, color } = args;
|
|
2102
2148
|
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
2103
2149
|
if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
|
|
2104
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2105
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2106
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2107
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2108
2150
|
const parts = path.replace(/^\/+/, '').split('/');
|
|
2109
2151
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
2110
2152
|
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
@@ -2123,10 +2165,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2123
2165
|
const { path, operations } = args;
|
|
2124
2166
|
if (!path) throw new Error('path required for action=edit');
|
|
2125
2167
|
if (!Array.isArray(operations) || operations.length === 0) throw new Error('operations (array) required for action=edit');
|
|
2126
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2127
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2128
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2129
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2130
2168
|
const result = await api('POST', '/api/fs/edit', { path, operations });
|
|
2131
2169
|
return ok(withProject(result), {
|
|
2132
2170
|
structuredContent: frameStructuredContent(result, projectCtx),
|
|
@@ -2148,20 +2186,19 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2148
2186
|
note: 'No changes applied. Re-call with dryRun=false (or omit) to perform the rename.',
|
|
2149
2187
|
}));
|
|
2150
2188
|
}
|
|
2151
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2152
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2153
|
-
const fromErr = await checkAnchors(parseLayer(from));
|
|
2154
|
-
if (fromErr) return err(new Error(fromErr));
|
|
2155
|
-
const toErr = await checkAnchors(parseLayer(to));
|
|
2156
|
-
if (toErr) return err(new Error(toErr));
|
|
2157
2189
|
return ok(withProject(await api('POST', '/api/fs/mv', { from, to })));
|
|
2158
2190
|
}
|
|
2159
2191
|
case 'anchor': {
|
|
2160
2192
|
const { path, anchored } = args;
|
|
2161
2193
|
if (!path) throw new Error('path required for action=anchor');
|
|
2162
2194
|
if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
|
|
2163
|
-
|
|
2164
|
-
if
|
|
2195
|
+
// Cap (G5): anchoring adds the frame's body to the project's required-reading
|
|
2196
|
+
// set — reject if it would push the project past the per-project budget.
|
|
2197
|
+
if (anchored === true) {
|
|
2198
|
+
const used = await getProjectPrimingSize(getState().projectId);
|
|
2199
|
+
const addSize = await getFrameContentSize(path);
|
|
2200
|
+
if (wouldExceedBudget(used, addSize)) return err(new Error(budgetError(used, addSize, 'this anchored frame')));
|
|
2201
|
+
}
|
|
2165
2202
|
return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
|
|
2166
2203
|
}
|
|
2167
2204
|
|
|
@@ -2180,8 +2217,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2180
2217
|
case 'restore_version': {
|
|
2181
2218
|
const { versionId, reason } = args;
|
|
2182
2219
|
if (!versionId) throw new Error('versionId required for action=restore_version');
|
|
2183
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2184
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2185
2220
|
const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
|
|
2186
2221
|
return ok(withProject(result));
|
|
2187
2222
|
}
|
|
@@ -2275,10 +2310,6 @@ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "p
|
|
|
2275
2310
|
path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).'),
|
|
2276
2311
|
}, async ({ path }) => {
|
|
2277
2312
|
try {
|
|
2278
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2279
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2280
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2281
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2282
2313
|
const clean = path.replace(/^\/+|\/+$/g, '');
|
|
2283
2314
|
const result = await api('DELETE', `/api/fs/${clean}`);
|
|
2284
2315
|
return ok({ ...result, project: getCurrentProjectContext() });
|
|
@@ -2316,8 +2347,6 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
|
|
|
2316
2347
|
if (op.to) layers.add(parseLayer(op.to));
|
|
2317
2348
|
}
|
|
2318
2349
|
for (const layer of layers) {
|
|
2319
|
-
const anchorErr = await checkAnchors(layer);
|
|
2320
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2321
2350
|
}
|
|
2322
2351
|
|
|
2323
2352
|
// Resolve file_path → base64 for write operations before sending to server
|
|
@@ -2407,8 +2436,6 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
|
|
|
2407
2436
|
const projectId = getState().projectId;
|
|
2408
2437
|
if (!projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
2409
2438
|
if (action === 'upload') {
|
|
2410
|
-
const skillErr = await checkProjectSkills(projectId);
|
|
2411
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2412
2439
|
const { asset_path, file_path, content, base64: rawBase64, content_type, frame_id } = args;
|
|
2413
2440
|
if (!asset_path) throw new Error('asset_path is required for action=upload');
|
|
2414
2441
|
if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
|
|
@@ -2438,8 +2465,6 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
|
|
|
2438
2465
|
return ok(await api('GET', `/api/projects/${projectId}/assets${query}`));
|
|
2439
2466
|
}
|
|
2440
2467
|
if (action === 'rm') {
|
|
2441
|
-
const skillErr = await checkProjectSkills(projectId);
|
|
2442
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2443
2468
|
const { asset_path } = args;
|
|
2444
2469
|
if (!asset_path) throw new Error('asset_path is required for action=rm');
|
|
2445
2470
|
if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
|
|
@@ -2465,8 +2490,6 @@ tool('shape', 'Create a flowchart shape on the surface. Shapes are lightweight t
|
|
|
2465
2490
|
layoutIgnore: z.boolean().optional().describe('Exclude this shape from auto-layout. Use for annotations, labels, or manually positioned elements.'),
|
|
2466
2491
|
}, async ({ text, shape, layer, lane, color, fill, textColor, group, width, height, layoutIgnore }) => {
|
|
2467
2492
|
try {
|
|
2468
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2469
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2470
2493
|
const body = { text, shape };
|
|
2471
2494
|
if (layer) body.layer = layer;
|
|
2472
2495
|
if (lane) body.lane = lane;
|
|
@@ -2491,8 +2514,6 @@ tool('group', 'Create a group (swim lane / region) on the surface. Groups are la
|
|
|
2491
2514
|
order: z.number().optional().describe('Sort order for group positioning (lower = first). Default: 0'),
|
|
2492
2515
|
}, async ({ label, color, fill, layer, lane, order }) => {
|
|
2493
2516
|
try {
|
|
2494
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2495
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2496
2517
|
const body = { label };
|
|
2497
2518
|
if (color) body.color = color;
|
|
2498
2519
|
if (fill) body.fill = fill;
|
|
@@ -2515,8 +2536,6 @@ tool('connector', 'Create or remove connectors (arrows) between frames on the su
|
|
|
2515
2536
|
connectorId: z.string().optional().describe('[disconnect] connector ID to delete directly'),
|
|
2516
2537
|
}, async (args) => {
|
|
2517
2538
|
try {
|
|
2518
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2519
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2520
2539
|
const { action } = args;
|
|
2521
2540
|
if (action === 'connect') {
|
|
2522
2541
|
const { source, target, label, type = 'arrow-forward', color } = args;
|
|
@@ -2558,8 +2577,6 @@ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions conn
|
|
|
2558
2577
|
groups: z.boolean().optional().default(false).describe('Enable group clustering. When true, shapes assigned to a group (via the group param) are clustered together, and group frames are resized to enclose their members.'),
|
|
2559
2578
|
}, async ({ direction, groups }) => {
|
|
2560
2579
|
try {
|
|
2561
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2562
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2563
2580
|
const body = { direction };
|
|
2564
2581
|
if (groups) body.groups = true;
|
|
2565
2582
|
const result = await api('POST', '/api/layout', body);
|
|
@@ -2602,6 +2619,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2602
2619
|
const qs = params.toString();
|
|
2603
2620
|
const endpoint = query ? '/api/skills/search' : '/api/skills';
|
|
2604
2621
|
const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`);
|
|
2622
|
+
markSearched(getSessionState().gates, 'skill');
|
|
2605
2623
|
const cap = Math.min(Math.max(1, limit || 25), 100);
|
|
2606
2624
|
if (Array.isArray(result?.skills)) {
|
|
2607
2625
|
if (result.skills.length > cap) {
|
|
@@ -2657,6 +2675,8 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2657
2675
|
return ok(await api('GET', `/api/orgs/${orgId}/skills`));
|
|
2658
2676
|
}
|
|
2659
2677
|
case 'add': {
|
|
2678
|
+
const g2 = g2Block(getSessionState().gates);
|
|
2679
|
+
if (g2) return err(new Error(g2));
|
|
2660
2680
|
const { name, description, content, tags, triggerPatterns } = args;
|
|
2661
2681
|
if (!name || !description || !content) throw new Error('name, description, content required for action=add');
|
|
2662
2682
|
const body = { name, description, content };
|
|
@@ -2685,8 +2705,15 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2685
2705
|
const { skillId } = args;
|
|
2686
2706
|
if (!skillId) throw new Error('skillId required for action=attach');
|
|
2687
2707
|
if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
2708
|
+
// Cap (G4): keep attached skills + anchors within the per-project budget so
|
|
2709
|
+
// the auto-inject set always fits — reject the attach if it would overflow.
|
|
2710
|
+
try {
|
|
2711
|
+
const used = await getProjectPrimingSize(getState().projectId);
|
|
2712
|
+
let addSize = 0;
|
|
2713
|
+
try { const full = await api('GET', `/api/skills/${skillId}`); addSize = (full?.content || '').length; } catch { /* unknown size */ }
|
|
2714
|
+
if (wouldExceedBudget(used, addSize)) return err(new Error(budgetError(used, addSize, 'this skill')));
|
|
2715
|
+
} catch { /* size check is best-effort */ }
|
|
2688
2716
|
const result = await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId });
|
|
2689
|
-
invalidateProjectSkillsCache(getState().projectId);
|
|
2690
2717
|
return ok(result);
|
|
2691
2718
|
}
|
|
2692
2719
|
case 'detach': {
|
|
@@ -2694,7 +2721,6 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2694
2721
|
if (!skillId) throw new Error('skillId required for action=detach');
|
|
2695
2722
|
if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
2696
2723
|
const result = await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`);
|
|
2697
|
-
invalidateProjectSkillsCache(getState().projectId);
|
|
2698
2724
|
return ok(result);
|
|
2699
2725
|
}
|
|
2700
2726
|
case 'favorite': {
|
|
@@ -2889,6 +2915,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2889
2915
|
if (!searchQuery) throw new Error('query required for action=search');
|
|
2890
2916
|
const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
|
|
2891
2917
|
const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
|
|
2918
|
+
markSearched(getSessionState().gates, 'wiki');
|
|
2892
2919
|
return ok({ hits });
|
|
2893
2920
|
}
|
|
2894
2921
|
|
|
@@ -2967,10 +2994,6 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2967
2994
|
const { path, excalidraw_data, mermaid, width, height, color } = args;
|
|
2968
2995
|
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
2969
2996
|
if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
|
|
2970
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2971
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2972
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2973
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2974
2997
|
const parts = path.replace(/^\/+/, '').split('/');
|
|
2975
2998
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
2976
2999
|
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"import:legacy": "tsx scripts/import-legacy.mts",
|
|
29
29
|
"test": "vitest run",
|
|
30
30
|
"test:watch": "vitest",
|
|
31
|
-
"version": "node scripts/sync-versions.mjs && git add plugin/.claude-plugin/plugin.json .claude-plugin/marketplace.json",
|
|
31
|
+
"version": "node scripts/sync-versions.mjs && git add plugin/.claude-plugin/plugin.json .claude-plugin/marketplace.json web-plugin/.claude-plugin/plugin.json web-plugin/.claude-plugin/marketplace.json",
|
|
32
32
|
"version:check": "node scripts/sync-versions.mjs",
|
|
33
33
|
"postpublish": "bash scripts/sync-plugin.sh \"chore: sync plugin to v$npm_package_version\"",
|
|
34
34
|
"deploy:check:google": "node scripts/check-google-drive-deploy.mjs",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Single source of truth for the per-project auto-inject context budget.
|
|
2
|
+
// Shared by the MCP gate logic (mcp/gates.mjs) and the server-side deposit caps
|
|
3
|
+
// (server/lib/project-skill-routes.mjs) so web-remote, local-stdio, and raw-API
|
|
4
|
+
// callers all enforce the same limit. ~24k chars ≈ 6k tokens.
|
|
5
|
+
export const PROJECT_CONTEXT_BUDGET_CHARS = 24000;
|
|
6
|
+
|
|
7
|
+
function gateItemChars(it) {
|
|
8
|
+
if (it && typeof it === 'object') {
|
|
9
|
+
if (it.content != null) return String(it.content).length;
|
|
10
|
+
if (typeof it.chars === 'number') return it.chars;
|
|
11
|
+
}
|
|
12
|
+
if (typeof it === 'string') return it.length;
|
|
13
|
+
if (typeof it === 'number') return it;
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Greedily include items whose sizes fit the budget (in priority order); defer the
|
|
18
|
+
// rest. Shared by the server-side project-open priming assembly and the MCP gate
|
|
19
|
+
// fallback so both produce identical auto-inject sets.
|
|
20
|
+
export function selectWithinBudget(items, budget = PROJECT_CONTEXT_BUDGET_CHARS, sizeOf = gateItemChars) {
|
|
21
|
+
const included = [];
|
|
22
|
+
const deferred = [];
|
|
23
|
+
let used = 0;
|
|
24
|
+
for (const it of items || []) {
|
|
25
|
+
const size = sizeOf(it);
|
|
26
|
+
if (used + size <= budget) { included.push(it); used += size; }
|
|
27
|
+
else { deferred.push(it); }
|
|
28
|
+
}
|
|
29
|
+
return { included, deferred, used, budget };
|
|
30
|
+
}
|