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 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 || getBootstrapSessionId();
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 url = `${getServerUrl()}/auth/session/clone`;
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 (!res.ok) return;
634
- const data = await res.json();
635
- if (data.sessionId) {
636
- getState().sessionId = data.sessionId;
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, will retry on first API call */ }
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
- await consumePendingDeviceCode();
644
- if (getState().sessionId) return;
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
- return true;
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
- let projectSkillsList = [];
1307
- try {
1308
- const skillData = await api('GET', `/api/projects/${projectId}/skills`);
1309
- projectSkillsList = skillData.skills || [];
1310
- } catch { /* skills not available yet */ }
1311
-
1312
- if (projectSkillsList.length > 0 && projectSkillsList.length <= 3) {
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
- // Skill content was inlined in this response count it as loaded
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
- // Refresh project-skill cache so the gate uses fresh data after open
1326
- invalidateProjectSkillsCache(projectId);
1327
-
1328
- const responseExtras = { url, opened: true, navigated, skills: projectSkillsList };
1329
- if (projectSkillsList.length > 3) {
1330
- const sessionLoaded = getSessionState().loadedSkillIds;
1331
- const unloaded = projectSkillsList.filter(s => !sessionLoaded.has(s.id));
1332
- if (unloaded.length > 0) {
1333
- responseExtras.skillGateNotice =
1334
- `This project has ${projectSkillsList.length} attached skills (too many to auto-inline). ` +
1335
- `You MUST call skill(action="load") for each before any mutation. Unloaded: ` +
1336
- unloaded.map(s => s.slug || s.id).join(', ');
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
- return ok(await api('GET', '/api/templates'));
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
- }).passthrough()).optional().describe('[write_slide_content|append_slides] Structured slide spec: [{ title, bullets, speakerNotes?, layout? }].'),
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.any())).optional().describe('[write_sheet_values|append_sheet_rows] 2D array of row values.'),
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
- const skillErr = await checkProjectSkills(getState().projectId);
2164
- if (skillErr) return err(new Error(skillErr));
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.7.25",
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
+ }