drafted 1.7.26 → 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 +165 -164
- 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;
|
|
@@ -921,94 +969,10 @@ function err(error) {
|
|
|
921
969
|
return { content: [{ type: 'text', text: error.message || String(error) }], isError: true };
|
|
922
970
|
}
|
|
923
971
|
|
|
924
|
-
// ── Anchor enforcement ────────────────────────────────────────────
|
|
925
|
-
// Track which frames this session has fully read (no line range = full read)
|
|
926
|
-
const readFrameIds = new Set();
|
|
927
|
-
|
|
928
|
-
// Cache anchored frames (refreshed on each check)
|
|
929
|
-
let anchoredCache = null;
|
|
930
|
-
let anchoredCacheTime = 0;
|
|
931
|
-
|
|
932
|
-
async function getAnchoredFrames() {
|
|
933
|
-
// Cache for 10 seconds to avoid hammering the API
|
|
934
|
-
if (anchoredCache && Date.now() - anchoredCacheTime < 10000) return anchoredCache;
|
|
935
|
-
try {
|
|
936
|
-
anchoredCache = await api('GET', '/api/designs/anchored');
|
|
937
|
-
anchoredCacheTime = Date.now();
|
|
938
|
-
return anchoredCache;
|
|
939
|
-
} catch {
|
|
940
|
-
return [];
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
972
|
function parseLayer(path) {
|
|
945
973
|
return path.replace(/^\/+/, '').split('/')[0];
|
|
946
974
|
}
|
|
947
975
|
|
|
948
|
-
async function checkAnchors(layer) {
|
|
949
|
-
const anchored = await getAnchoredFrames();
|
|
950
|
-
if (!Array.isArray(anchored)) return null;
|
|
951
|
-
|
|
952
|
-
const layerAnchors = anchored.filter(f => f.layer === layer);
|
|
953
|
-
if (layerAnchors.length === 0) return null;
|
|
954
|
-
|
|
955
|
-
const unread = layerAnchors.filter(f => !readFrameIds.has(f.id));
|
|
956
|
-
if (unread.length === 0) return null;
|
|
957
|
-
|
|
958
|
-
const paths = unread.map(f => `/${f.layer}/${f.lane}/${f.label}`);
|
|
959
|
-
return `This layer has ${layerAnchors.length} anchored frame(s) that must be read before making changes. ` +
|
|
960
|
-
`Unread anchors:\n${paths.map(p => ' read path="' + p + '"').join('\n')}\n\n` +
|
|
961
|
-
`Read all anchored frames first, then retry your operation.`;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// ── Project skill enforcement ─────────────────────────────────────
|
|
965
|
-
// Skills attached to a project must be loaded by the agent before any
|
|
966
|
-
// mutating operation in that project. Same shape as anchor enforcement.
|
|
967
|
-
//
|
|
968
|
-
// Loaded-skill state is per-MCP-session (see getSessionState) so that
|
|
969
|
-
// (a) skill.load + wiki.write across separate HTTP requests stay coherent
|
|
970
|
-
// and (b) one user's loads never satisfy another user's gate.
|
|
971
|
-
const projectSkillsCache = new Map(); // projectId -> { data, time }
|
|
972
|
-
|
|
973
|
-
async function getProjectSkills(projectId) {
|
|
974
|
-
if (!projectId) return [];
|
|
975
|
-
const cached = projectSkillsCache.get(projectId);
|
|
976
|
-
if (cached && Date.now() - cached.time < 10000) return cached.data;
|
|
977
|
-
try {
|
|
978
|
-
const result = await api('GET', `/api/projects/${projectId}/skills`);
|
|
979
|
-
const skills = Array.isArray(result?.skills) ? result.skills : (Array.isArray(result) ? result : []);
|
|
980
|
-
projectSkillsCache.set(projectId, { data: skills, time: Date.now() });
|
|
981
|
-
return skills;
|
|
982
|
-
} catch {
|
|
983
|
-
return [];
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
function invalidateProjectSkillsCache(projectId) {
|
|
988
|
-
if (projectId) projectSkillsCache.delete(projectId);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
async function checkProjectSkills(projectId, operation) {
|
|
992
|
-
if (!projectId) return null;
|
|
993
|
-
const skills = await getProjectSkills(projectId);
|
|
994
|
-
if (skills.length === 0) return null;
|
|
995
|
-
const loaded = getSessionState().loadedSkillIds;
|
|
996
|
-
const unloaded = skills.filter(s => !loaded.has(s.id));
|
|
997
|
-
if (unloaded.length === 0) return null;
|
|
998
|
-
|
|
999
|
-
// Fire-and-forget: tell the server so any open browser shows a toast.
|
|
1000
|
-
api('POST', `/api/projects/${projectId}/skill-gate-events`, {
|
|
1001
|
-
unloadedSkills: unloaded.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
|
1002
|
-
operation: operation || 'mutation',
|
|
1003
|
-
agent: process.env.DRAFTED_AGENT_NAME || null,
|
|
1004
|
-
}).catch(() => { /* non-fatal */ });
|
|
1005
|
-
|
|
1006
|
-
const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
|
|
1007
|
-
return `This project has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
|
|
1008
|
-
`Unloaded skills:\n${lines.join('\n')}\n\n` +
|
|
1009
|
-
`Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
976
|
// ── Org skill enforcement ────────────────────────────────────────
|
|
1013
977
|
// Same shape as project skill enforcement but for org-attached skills.
|
|
1014
978
|
// Wiki mutations require org skills to be loaded first.
|
|
@@ -1043,6 +1007,42 @@ async function getOrgSkills(orgId) {
|
|
|
1043
1007
|
} catch { return []; }
|
|
1044
1008
|
}
|
|
1045
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
|
+
|
|
1046
1046
|
async function checkOrgSkills(orgId, operation) {
|
|
1047
1047
|
if (!orgId) return null;
|
|
1048
1048
|
const skills = await getOrgSkills(orgId);
|
|
@@ -1323,42 +1323,62 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1323
1323
|
exec(`${cmd} ${JSON.stringify(url)}`);
|
|
1324
1324
|
}
|
|
1325
1325
|
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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 */ }
|
|
1333
1345
|
for (const s of projectSkillsList) {
|
|
1334
1346
|
try {
|
|
1335
1347
|
const full = await api('GET', `/api/skills/${s.id}`);
|
|
1336
1348
|
s.content = full.content;
|
|
1337
1349
|
s.files = full.files || [];
|
|
1338
|
-
|
|
1339
|
-
// so the project-skill gate doesn't immediately demand a re-load.
|
|
1340
|
-
getSessionState().loadedSkillIds.add(s.id);
|
|
1341
|
-
} catch { /* skip */ }
|
|
1350
|
+
} catch { /* leaves this skill without inlined content */ }
|
|
1342
1351
|
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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(', ');
|
|
1357
1373
|
}
|
|
1358
1374
|
}
|
|
1375
|
+
// Don't echo the raw priming blob (surfaced via skills/anchors/budgetNotice).
|
|
1376
|
+
if (result && typeof result === 'object') delete result.priming;
|
|
1359
1377
|
return ok({ ...result, ...responseExtras });
|
|
1360
1378
|
}
|
|
1361
1379
|
case 'create': {
|
|
1380
|
+
const g3 = g3Block(getSessionState().gates);
|
|
1381
|
+
if (g3) return err(new Error(g3));
|
|
1362
1382
|
const { name, description, templateSlug } = args;
|
|
1363
1383
|
if (!name) throw new Error('name required for action=create');
|
|
1364
1384
|
const body = { name };
|
|
@@ -1400,8 +1420,11 @@ tool('template', 'Manage project templates in the org. Dispatch by `action`: lis
|
|
|
1400
1420
|
try {
|
|
1401
1421
|
const { action } = args;
|
|
1402
1422
|
switch (action) {
|
|
1403
|
-
case 'list':
|
|
1404
|
-
|
|
1423
|
+
case 'list': {
|
|
1424
|
+
const tpls = await api('GET', '/api/templates');
|
|
1425
|
+
markSearched(getSessionState().gates, 'template');
|
|
1426
|
+
return ok(tpls);
|
|
1427
|
+
}
|
|
1405
1428
|
case 'create': {
|
|
1406
1429
|
const { name, description, layers, skillSlugs, visibility } = args;
|
|
1407
1430
|
if (!name || !description || !layers) throw new Error('name, description, layers required for action=create');
|
|
@@ -1536,8 +1559,6 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
|
|
|
1536
1559
|
try {
|
|
1537
1560
|
const { action, projectId } = args;
|
|
1538
1561
|
if (!projectId) throw new Error('projectId is required');
|
|
1539
|
-
const skillErr = await checkProjectSkills(projectId);
|
|
1540
|
-
if (skillErr) return err(new Error(skillErr));
|
|
1541
1562
|
switch (action) {
|
|
1542
1563
|
case 'add': {
|
|
1543
1564
|
const { key, label, type, width, height, description, prompt } = args;
|
|
@@ -1849,11 +1870,11 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1849
1870
|
bullets: z.array(z.string()).optional(),
|
|
1850
1871
|
speakerNotes: z.string().optional(),
|
|
1851
1872
|
layout: z.string().optional(),
|
|
1852
|
-
})
|
|
1873
|
+
})).optional().describe('[write_slide_content|append_slides] Structured slide spec: [{ title, bullets, speakerNotes?, layout? }].'),
|
|
1853
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.'),
|
|
1854
1875
|
slideObjectIds: z.array(z.string()).optional().describe('[clear_slides] Optional slide object IDs to delete. Omit to clear all slides.'),
|
|
1855
1876
|
range: z.string().optional().describe('[Sheet value actions] A1 range, e.g. Sheet1!A1 or Data!A:Z.'),
|
|
1856
|
-
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).'),
|
|
1857
1878
|
valueInputOption: z.enum(['RAW', 'USER_ENTERED']).optional().describe('[write_sheet_values|append_sheet_rows] Google Sheets value input option. Defaults to USER_ENTERED.'),
|
|
1858
1879
|
majorDimension: z.enum(['ROWS', 'COLUMNS']).optional().describe('[Sheet value actions] Major dimension for values. Defaults to ROWS when writing/appending.'),
|
|
1859
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.'),
|
|
@@ -1884,6 +1905,16 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1884
1905
|
}, async (args) => {
|
|
1885
1906
|
try {
|
|
1886
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
|
+
}
|
|
1887
1918
|
// Echo project context on every mutation so the agent sees where the
|
|
1888
1919
|
// write landed before it can develop a wrong assumption.
|
|
1889
1920
|
const projectCtx = getCurrentProjectContext();
|
|
@@ -1924,9 +1955,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1924
1955
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
|
|
1925
1956
|
result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}${query}`);
|
|
1926
1957
|
}
|
|
1927
|
-
if (!lines && result.ok && result.id) {
|
|
1928
|
-
readFrameIds.add(result.id);
|
|
1929
|
-
}
|
|
1930
1958
|
// Surface content as the visible text. Some Claude clients prefer
|
|
1931
1959
|
// structuredContent over text when both are present and structured looks
|
|
1932
1960
|
// "complete" — so include content in BOTH places to ensure the agent
|
|
@@ -2058,10 +2086,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2058
2086
|
const writeSources = [content != null, !!file_path, base64 != null, !!googleType].filter(Boolean).length;
|
|
2059
2087
|
if (writeSources > 1) throw new Error('Provide only one of content, file_path, base64, or googleType');
|
|
2060
2088
|
if (writeSources === 0) throw new Error('Provide content, file_path, base64, or googleType');
|
|
2061
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2062
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2063
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2064
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2065
2089
|
const parts = path.replace(/^\/+/, '').split('/');
|
|
2066
2090
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
2067
2091
|
if (googleType) {
|
|
@@ -2123,10 +2147,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2123
2147
|
const { path, excalidraw_data, mermaid, width, height, color } = args;
|
|
2124
2148
|
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
2125
2149
|
if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
|
|
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
2150
|
const parts = path.replace(/^\/+/, '').split('/');
|
|
2131
2151
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
2132
2152
|
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
@@ -2145,10 +2165,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2145
2165
|
const { path, operations } = args;
|
|
2146
2166
|
if (!path) throw new Error('path required for action=edit');
|
|
2147
2167
|
if (!Array.isArray(operations) || operations.length === 0) throw new Error('operations (array) required for action=edit');
|
|
2148
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2149
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2150
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2151
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2152
2168
|
const result = await api('POST', '/api/fs/edit', { path, operations });
|
|
2153
2169
|
return ok(withProject(result), {
|
|
2154
2170
|
structuredContent: frameStructuredContent(result, projectCtx),
|
|
@@ -2170,20 +2186,19 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2170
2186
|
note: 'No changes applied. Re-call with dryRun=false (or omit) to perform the rename.',
|
|
2171
2187
|
}));
|
|
2172
2188
|
}
|
|
2173
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2174
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2175
|
-
const fromErr = await checkAnchors(parseLayer(from));
|
|
2176
|
-
if (fromErr) return err(new Error(fromErr));
|
|
2177
|
-
const toErr = await checkAnchors(parseLayer(to));
|
|
2178
|
-
if (toErr) return err(new Error(toErr));
|
|
2179
2189
|
return ok(withProject(await api('POST', '/api/fs/mv', { from, to })));
|
|
2180
2190
|
}
|
|
2181
2191
|
case 'anchor': {
|
|
2182
2192
|
const { path, anchored } = args;
|
|
2183
2193
|
if (!path) throw new Error('path required for action=anchor');
|
|
2184
2194
|
if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
|
|
2185
|
-
|
|
2186
|
-
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
|
+
}
|
|
2187
2202
|
return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
|
|
2188
2203
|
}
|
|
2189
2204
|
|
|
@@ -2202,8 +2217,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2202
2217
|
case 'restore_version': {
|
|
2203
2218
|
const { versionId, reason } = args;
|
|
2204
2219
|
if (!versionId) throw new Error('versionId required for action=restore_version');
|
|
2205
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2206
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2207
2220
|
const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
|
|
2208
2221
|
return ok(withProject(result));
|
|
2209
2222
|
}
|
|
@@ -2297,10 +2310,6 @@ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "p
|
|
|
2297
2310
|
path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).'),
|
|
2298
2311
|
}, async ({ path }) => {
|
|
2299
2312
|
try {
|
|
2300
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2301
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2302
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2303
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2304
2313
|
const clean = path.replace(/^\/+|\/+$/g, '');
|
|
2305
2314
|
const result = await api('DELETE', `/api/fs/${clean}`);
|
|
2306
2315
|
return ok({ ...result, project: getCurrentProjectContext() });
|
|
@@ -2338,8 +2347,6 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
|
|
|
2338
2347
|
if (op.to) layers.add(parseLayer(op.to));
|
|
2339
2348
|
}
|
|
2340
2349
|
for (const layer of layers) {
|
|
2341
|
-
const anchorErr = await checkAnchors(layer);
|
|
2342
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2343
2350
|
}
|
|
2344
2351
|
|
|
2345
2352
|
// Resolve file_path → base64 for write operations before sending to server
|
|
@@ -2429,8 +2436,6 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
|
|
|
2429
2436
|
const projectId = getState().projectId;
|
|
2430
2437
|
if (!projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
2431
2438
|
if (action === 'upload') {
|
|
2432
|
-
const skillErr = await checkProjectSkills(projectId);
|
|
2433
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2434
2439
|
const { asset_path, file_path, content, base64: rawBase64, content_type, frame_id } = args;
|
|
2435
2440
|
if (!asset_path) throw new Error('asset_path is required for action=upload');
|
|
2436
2441
|
if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
|
|
@@ -2460,8 +2465,6 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
|
|
|
2460
2465
|
return ok(await api('GET', `/api/projects/${projectId}/assets${query}`));
|
|
2461
2466
|
}
|
|
2462
2467
|
if (action === 'rm') {
|
|
2463
|
-
const skillErr = await checkProjectSkills(projectId);
|
|
2464
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2465
2468
|
const { asset_path } = args;
|
|
2466
2469
|
if (!asset_path) throw new Error('asset_path is required for action=rm');
|
|
2467
2470
|
if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
|
|
@@ -2487,8 +2490,6 @@ tool('shape', 'Create a flowchart shape on the surface. Shapes are lightweight t
|
|
|
2487
2490
|
layoutIgnore: z.boolean().optional().describe('Exclude this shape from auto-layout. Use for annotations, labels, or manually positioned elements.'),
|
|
2488
2491
|
}, async ({ text, shape, layer, lane, color, fill, textColor, group, width, height, layoutIgnore }) => {
|
|
2489
2492
|
try {
|
|
2490
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2491
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2492
2493
|
const body = { text, shape };
|
|
2493
2494
|
if (layer) body.layer = layer;
|
|
2494
2495
|
if (lane) body.lane = lane;
|
|
@@ -2513,8 +2514,6 @@ tool('group', 'Create a group (swim lane / region) on the surface. Groups are la
|
|
|
2513
2514
|
order: z.number().optional().describe('Sort order for group positioning (lower = first). Default: 0'),
|
|
2514
2515
|
}, async ({ label, color, fill, layer, lane, order }) => {
|
|
2515
2516
|
try {
|
|
2516
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2517
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2518
2517
|
const body = { label };
|
|
2519
2518
|
if (color) body.color = color;
|
|
2520
2519
|
if (fill) body.fill = fill;
|
|
@@ -2537,8 +2536,6 @@ tool('connector', 'Create or remove connectors (arrows) between frames on the su
|
|
|
2537
2536
|
connectorId: z.string().optional().describe('[disconnect] connector ID to delete directly'),
|
|
2538
2537
|
}, async (args) => {
|
|
2539
2538
|
try {
|
|
2540
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2541
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2542
2539
|
const { action } = args;
|
|
2543
2540
|
if (action === 'connect') {
|
|
2544
2541
|
const { source, target, label, type = 'arrow-forward', color } = args;
|
|
@@ -2580,8 +2577,6 @@ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions conn
|
|
|
2580
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.'),
|
|
2581
2578
|
}, async ({ direction, groups }) => {
|
|
2582
2579
|
try {
|
|
2583
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2584
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2585
2580
|
const body = { direction };
|
|
2586
2581
|
if (groups) body.groups = true;
|
|
2587
2582
|
const result = await api('POST', '/api/layout', body);
|
|
@@ -2624,6 +2619,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2624
2619
|
const qs = params.toString();
|
|
2625
2620
|
const endpoint = query ? '/api/skills/search' : '/api/skills';
|
|
2626
2621
|
const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`);
|
|
2622
|
+
markSearched(getSessionState().gates, 'skill');
|
|
2627
2623
|
const cap = Math.min(Math.max(1, limit || 25), 100);
|
|
2628
2624
|
if (Array.isArray(result?.skills)) {
|
|
2629
2625
|
if (result.skills.length > cap) {
|
|
@@ -2679,6 +2675,8 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2679
2675
|
return ok(await api('GET', `/api/orgs/${orgId}/skills`));
|
|
2680
2676
|
}
|
|
2681
2677
|
case 'add': {
|
|
2678
|
+
const g2 = g2Block(getSessionState().gates);
|
|
2679
|
+
if (g2) return err(new Error(g2));
|
|
2682
2680
|
const { name, description, content, tags, triggerPatterns } = args;
|
|
2683
2681
|
if (!name || !description || !content) throw new Error('name, description, content required for action=add');
|
|
2684
2682
|
const body = { name, description, content };
|
|
@@ -2707,8 +2705,15 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2707
2705
|
const { skillId } = args;
|
|
2708
2706
|
if (!skillId) throw new Error('skillId required for action=attach');
|
|
2709
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 */ }
|
|
2710
2716
|
const result = await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId });
|
|
2711
|
-
invalidateProjectSkillsCache(getState().projectId);
|
|
2712
2717
|
return ok(result);
|
|
2713
2718
|
}
|
|
2714
2719
|
case 'detach': {
|
|
@@ -2716,7 +2721,6 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
2716
2721
|
if (!skillId) throw new Error('skillId required for action=detach');
|
|
2717
2722
|
if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
2718
2723
|
const result = await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`);
|
|
2719
|
-
invalidateProjectSkillsCache(getState().projectId);
|
|
2720
2724
|
return ok(result);
|
|
2721
2725
|
}
|
|
2722
2726
|
case 'favorite': {
|
|
@@ -2911,6 +2915,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2911
2915
|
if (!searchQuery) throw new Error('query required for action=search');
|
|
2912
2916
|
const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
|
|
2913
2917
|
const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
|
|
2918
|
+
markSearched(getSessionState().gates, 'wiki');
|
|
2914
2919
|
return ok({ hits });
|
|
2915
2920
|
}
|
|
2916
2921
|
|
|
@@ -2989,10 +2994,6 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2989
2994
|
const { path, excalidraw_data, mermaid, width, height, color } = args;
|
|
2990
2995
|
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
2991
2996
|
if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
|
|
2992
|
-
const skillErr = await checkProjectSkills(getState().projectId);
|
|
2993
|
-
if (skillErr) return err(new Error(skillErr));
|
|
2994
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
2995
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
2996
2997
|
const parts = path.replace(/^\/+/, '').split('/');
|
|
2997
2998
|
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
2998
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
|
+
}
|