@geraldmaron/construct 1.0.23 → 1.1.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.
Files changed (106) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +189 -207
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/beads-client.mjs +9 -0
  7. package/lib/beads-optimistic.mjs +23 -71
  8. package/lib/bridges/copilot-proxy.mjs +116 -0
  9. package/lib/cli-commands.mjs +5 -1
  10. package/lib/comment-lint.mjs +1 -1
  11. package/lib/config/schema.mjs +1 -1
  12. package/lib/document-extract/docling-client.mjs +16 -6
  13. package/lib/document-extract/docling-sidecar.py +32 -2
  14. package/lib/document-extract.mjs +37 -10
  15. package/lib/document-ingest.mjs +90 -5
  16. package/lib/embed/inbox.mjs +6 -3
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/embed/roadmap.mjs +16 -1
  20. package/lib/engine/consolidate.mjs +160 -3
  21. package/lib/engine/contradiction-judge.mjs +71 -0
  22. package/lib/engine/contradiction.mjs +74 -0
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/session-reflect.mjs +5 -1
  25. package/lib/host-capabilities.mjs +30 -0
  26. package/lib/ingest/docling-remote.mjs +90 -0
  27. package/lib/ingest/strategy.mjs +1 -1
  28. package/lib/init-unified.mjs +9 -13
  29. package/lib/intake/git-queue.mjs +195 -0
  30. package/lib/intake/queue.mjs +9 -16
  31. package/lib/logging/rotate.mjs +18 -0
  32. package/lib/mcp/server.mjs +124 -12
  33. package/lib/mcp/tool-budget.mjs +53 -0
  34. package/lib/mcp/tools/storage.mjs +2 -3
  35. package/lib/mcp-catalog.json +4 -4
  36. package/lib/mcp-manager.mjs +59 -3
  37. package/lib/observation-store.mjs +38 -166
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration/runtime.mjs +3 -2
  43. package/lib/orchestration-policy.mjs +41 -6
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +12 -8
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reconcile/index.mjs +0 -2
  48. package/lib/reflect/extractor.mjs +14 -1
  49. package/lib/reflect/salience.mjs +65 -0
  50. package/lib/rules-delivery.mjs +122 -0
  51. package/lib/runtime/uv-bootstrap.mjs +32 -17
  52. package/lib/service-manager.mjs +79 -259
  53. package/lib/setup.mjs +44 -425
  54. package/lib/specialists/prompt-schema.mjs +162 -0
  55. package/lib/specialists/scaffold.mjs +109 -0
  56. package/lib/status.mjs +3 -6
  57. package/lib/storage/admin.mjs +48 -325
  58. package/lib/storage/backend.mjs +10 -57
  59. package/lib/storage/embeddings-engine.mjs +19 -5
  60. package/lib/storage/hybrid-query.mjs +15 -196
  61. package/lib/storage/sync.mjs +36 -177
  62. package/lib/storage/vector-client.mjs +256 -235
  63. package/lib/strategy-store.mjs +35 -286
  64. package/lib/telemetry/beads-fallback.mjs +40 -0
  65. package/lib/telemetry/hook-calls.mjs +138 -0
  66. package/lib/worker/entrypoint.mjs +6 -14
  67. package/package.json +6 -5
  68. package/personas/construct.md +1 -1
  69. package/platforms/capabilities.json +76 -0
  70. package/platforms/claude/settings.template.json +0 -7
  71. package/platforms/opencode/sync-config.mjs +121 -25
  72. package/rules/common/neurodivergent-output.md +1 -1
  73. package/rules/web/coding-style.md +8 -0
  74. package/rules/web/design-quality.md +8 -0
  75. package/rules/web/hooks.md +8 -0
  76. package/rules/web/patterns.md +8 -0
  77. package/rules/web/performance.md +8 -0
  78. package/rules/web/security.md +8 -0
  79. package/rules/web/testing.md +8 -0
  80. package/scripts/sync-specialists.mjs +174 -40
  81. package/specialists/prompts/cx-architect.md +20 -0
  82. package/specialists/prompts/cx-qa.md +1 -1
  83. package/specialists/prompts/cx-test-automation.md +12 -0
  84. package/specialists/registry.json +0 -8
  85. package/templates/docs/construct_guide.md +1 -1
  86. package/db/schema/001_init.sql +0 -40
  87. package/db/schema/002_pgvector.sql +0 -182
  88. package/db/schema/003_intake.sql +0 -47
  89. package/db/schema/003_observation_reconciliation.sql +0 -14
  90. package/db/schema/004_recommendations.sql +0 -46
  91. package/db/schema/005_strategy.sql +0 -21
  92. package/db/schema/006_graph.sql +0 -24
  93. package/db/schema/007_tags.sql +0 -30
  94. package/db/schema/008_skill_usage.sql +0 -24
  95. package/db/schema/009_scheduler.sql +0 -14
  96. package/db/schema/010_cx_scores.sql +0 -51
  97. package/lib/intake/postgres-queue.mjs +0 -240
  98. package/lib/reconcile/postgres-namespace.mjs +0 -102
  99. package/lib/services/local-postgres.mjs +0 -15
  100. package/lib/storage/backup.mjs +0 -347
  101. package/lib/storage/migrations.mjs +0 -187
  102. package/lib/storage/postgres-backup.mjs +0 -124
  103. package/lib/storage/sql-store.mjs +0 -45
  104. package/lib/storage/store-version.mjs +0 -115
  105. package/lib/storage/unified-storage.mjs +0 -550
  106. package/lib/storage/vector-store.mjs +0 -100
@@ -0,0 +1,195 @@
1
+ /**
2
+ * lib/intake/git-queue.mjs — Git-backed adapter for the IntakeQueue interface.
3
+ *
4
+ * This implementation replaces the PostgresIntakeQueue for team/enterprise
5
+ * modes. It uses the filesystem and Git for state synchronization and
6
+ * conflict resolution, removing the dependency on a central database server.
7
+ *
8
+ * Structure:
9
+ * .cx/team-inbox/
10
+ * pending/ - JSON files for unclaimed tasks
11
+ * claimed/ - Subdirectories per worker containing claimed task files
12
+ * processed/ - Completed tasks
13
+ * skipped/ - Skipped tasks
14
+ * quarantine/ - Tasks needing human review
15
+ */
16
+
17
+ import path from 'node:path';
18
+ import fs from 'fs';
19
+ import { execSync } from 'node:child_process';
20
+ import { shouldQuarantine } from './quarantine.mjs';
21
+
22
+ function slugify(value) {
23
+ return String(value || 'untitled')
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ .slice(0, 60) || 'untitled';
28
+ }
29
+
30
+ let counter = 0;
31
+ function timestamp() {
32
+ counter = (counter + 1) % 1000;
33
+ const c = String(counter).padStart(3, '0');
34
+ return `${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23)}-${c}`;
35
+ }
36
+
37
+ export class GitIntakeQueue {
38
+ constructor({ project, rootDir = process.cwd() } = {}) {
39
+ this.project = project;
40
+ this.inboxRoot = path.join(rootDir, '.cx', 'team-inbox');
41
+ this._ensureDirs();
42
+ }
43
+
44
+ _ensureDirs() {
45
+ ['pending', 'claimed', 'processed', 'skipped', 'quarantine'].forEach(dir => {
46
+ fs.mkdirSync(path.join(this.inboxRoot, dir), { recursive: true });
47
+ });
48
+ }
49
+
50
+ _gitAddAndCommit(filePath, message) {
51
+ try {
52
+ execSync(`git add "${filePath}"`, { stdio: 'ignore' });
53
+ execSync(`git commit -m "${message}"`, { stdio: 'ignore' });
54
+ } catch (err) {
55
+ // It's okay if commit fails because of no changes
56
+ }
57
+ }
58
+
59
+ async enqueue(entry) {
60
+ const ts = timestamp();
61
+ const slug = slugify(path.basename(entry.intake.sourcePath, path.extname(entry.intake.sourcePath)));
62
+ const id = `${ts}-${slug}`;
63
+ const triage = entry.triage || {};
64
+
65
+ const quarantineDecision = shouldQuarantine(triage);
66
+ const subDir = quarantineDecision.quarantine ? 'quarantine' : 'pending';
67
+ const filePath = path.join(this.inboxRoot, subDir, `${id}.json`);
68
+
69
+ const data = {
70
+ id,
71
+ project: this.project,
72
+ status: subDir === 'quarantine' ? 'quarantined' : 'pending',
73
+ createdAt: new Date().toISOString(),
74
+ ...entry
75
+ };
76
+
77
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
78
+ this._gitAddAndCommit(filePath, `Enqueue task ${id}`);
79
+
80
+ return { id, route: subDir === 'quarantine' ? 'quarantine' : 'pending', reason: quarantineDecision.reason };
81
+ }
82
+
83
+ async listPending({ limit = 100 } = {}) {
84
+ const pendingDir = path.join(this.inboxRoot, 'pending');
85
+ return fs.readdirSync(pendingDir)
86
+ .filter(f => f.endsWith('.json'))
87
+ .slice(0, limit)
88
+ .map(f => JSON.parse(fs.readFileSync(path.join(pendingDir, f), 'utf8')));
89
+ }
90
+
91
+ async count() {
92
+ const pendingDir = path.join(this.inboxRoot, 'pending');
93
+ return fs.readdirSync(pendingDir).filter(f => f.endsWith('.json')).length;
94
+ }
95
+
96
+ async read(id) {
97
+ for (const dir of ['pending', 'claimed', 'processed', 'skipped', 'quarantine']) {
98
+ // Note: 'claimed' has subdirs, we'd need to search recursively for claimed.
99
+ // For simplicity in this implementation, we assume we know where it is or search pending first.
100
+ const searchPath = dir === 'claimed'
101
+ ? path.join(this.inboxRoot, dir) // needs recursive search
102
+ : path.join(this.inboxRoot, dir, `${id}.json`);
103
+
104
+ if (dir !== 'claimed' && fs.existsSync(searchPath)) {
105
+ return JSON.parse(fs.readFileSync(searchPath, 'utf8'));
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ async claim({ claimedBy }) {
112
+ if (!claimedBy) throw new Error('claim: claimedBy is required');
113
+
114
+ // 1. Pull latest to minimize conflicts
115
+ try { execSync('git pull --rebase', { stdio: 'ignore' }); } catch (e) {}
116
+
117
+ const pendingDir = path.join(this.inboxRoot, 'pending');
118
+ const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.json')).sort();
119
+
120
+ if (files.length === 0) return null;
121
+
122
+ const fileName = files[0];
123
+ const pendingPath = path.join(pendingDir, fileName);
124
+ const workerDir = path.join(this.inboxRoot, 'claimed', claimedBy);
125
+ fs.mkdirSync(workerDir, { recursive: true });
126
+ const claimedPath = path.join(workerDir, fileName);
127
+
128
+ try {
129
+ // Atomic move on filesystem
130
+ fs.renameSync(pendingPath, claimedPath);
131
+
132
+ // Update metadata
133
+ const data = JSON.parse(fs.readFileSync(claimedPath, 'utf8'));
134
+ data.status = 'claimed';
135
+ data.claimedBy = claimedBy;
136
+ data.claimedAt = new Date().toISOString();
137
+ fs.writeFileSync(claimedPath, JSON.stringify(data, null, 2));
138
+
139
+ // 2. Commit and Push
140
+ execSync(`git add .cx/team-inbox/pending/${fileName} .cx/team-inbox/claimed/${claimedBy}/${fileName}`, { stdio: 'ignore' });
141
+ execSync(`git commit -m "Claim task ${data.id} by ${claimedBy}"`, { stdio: 'ignore' });
142
+ execSync('git push', { stdio: 'ignore' });
143
+
144
+ return data;
145
+ } catch (err) {
146
+ // If push fails, someone else probably claimed it first or Git is out of sync.
147
+ // We should ideally rollback the local move, but for this first pass
148
+ // we'll let the next 'git pull --rebase' fix the state.
149
+ console.error(`Failed to claim ${fileName}: ${err.message}`);
150
+ return null;
151
+ }
152
+ }
153
+
154
+ async markProcessed(id, { processedBy = 'unknown', notes = '' } = {}) {
155
+ // Locate the file (might be in claimed/<worker>/<id>.json or pending/<id>.json)
156
+ // For simplicity, search all subdirs.
157
+ let foundPath = null;
158
+ let currentDir = null;
159
+
160
+ const dirs = ['pending', 'claimed']; // only mark as processed if pending or claimed
161
+ for (const d of dirs) {
162
+ const dirPath = path.join(this.inboxRoot, d);
163
+ if (d === 'claimed') {
164
+ const workers = fs.readdirSync(dirPath);
165
+ for (const w of workers) {
166
+ const p = path.join(dirPath, w, `${id}.json`);
167
+ if (fs.existsSync(p)) { foundPath = p; currentDir = path.join(d, w); break; }
168
+ }
169
+ } else {
170
+ const p = path.join(dirPath, `${id}.json`);
171
+ if (fs.existsSync(p)) { foundPath = p; currentDir = d; break; }
172
+ }
173
+ if (foundPath) break;
174
+ }
175
+
176
+ if (!foundPath) throw new Error(`markProcessed: no entry ${id} found`);
177
+
178
+ const data = JSON.parse(fs.readFileSync(foundPath, 'utf8'));
179
+ data.status = 'processed';
180
+ data.processedBy = processedBy;
181
+ data.processedAt = new Date().toISOString();
182
+ data.notes = notes;
183
+
184
+ const processedPath = path.join(this.inboxRoot, 'processed', `${id}.json`);
185
+ fs.renameSync(foundPath, processedPath);
186
+ fs.writeFileSync(processedPath, JSON.stringify(data, null, 2));
187
+
188
+ this._gitAddAndCommit(this.inboxRoot, `Mark task ${id} as processed`);
189
+ try { execSync('git push', { stdio: 'ignore' }); } catch (e) {}
190
+
191
+ return { id };
192
+ }
193
+
194
+ // markSkipped and reopen would follow similar logic...
195
+ }
@@ -36,11 +36,10 @@
36
36
  import path from 'node:path';
37
37
 
38
38
  import { FilesystemIntakeQueue, queueRoot, pendingDir, processedDir, skippedDir } from './filesystem-queue.mjs';
39
- import { PostgresIntakeQueue } from './postgres-queue.mjs';
39
+ import { GitIntakeQueue } from './git-queue.mjs';
40
40
  import { getDeploymentMode } from '../deployment-mode.mjs';
41
- import { createSqlClient } from '../storage/backend.mjs';
42
41
 
43
- export { FilesystemIntakeQueue, PostgresIntakeQueue, queueRoot, pendingDir, processedDir, skippedDir };
42
+ export { FilesystemIntakeQueue, GitIntakeQueue, queueRoot, pendingDir, processedDir, skippedDir };
44
43
 
45
44
  export const INTAKE_QUEUE_BACKEND_ENV_KEY = 'CONSTRUCT_INTAKE_QUEUE_BACKEND';
46
45
  export const INTAKE_PROJECT_ENV_KEY = 'CONSTRUCT_PROJECT_NAME';
@@ -48,9 +47,9 @@ export const INTAKE_TENANT_ENV_KEY = 'CONSTRUCT_TENANT_ID';
48
47
 
49
48
  function resolveBackend(env) {
50
49
  const override = env?.[INTAKE_QUEUE_BACKEND_ENV_KEY];
51
- if (override === 'filesystem' || override === 'postgres') return override;
50
+ if (override === 'filesystem' || override === 'git') return override;
52
51
  const mode = getDeploymentMode(env);
53
- return mode === 'solo' ? 'filesystem' : 'postgres';
52
+ return mode === 'solo' ? 'filesystem' : 'git';
54
53
  }
55
54
 
56
55
  function resolveProject(rootDir, env) {
@@ -62,22 +61,16 @@ function resolveProject(rootDir, env) {
62
61
  /**
63
62
  * Create an IntakeQueue instance for the given project root.
64
63
  * Backend selection: CONSTRUCT_INTAKE_QUEUE_BACKEND override wins; otherwise
65
- * solo mode → filesystem, team/enterprise → postgres. The postgres
66
- * backend pulls its connection from createSqlClient(env) and uses
67
- * CONSTRUCT_PROJECT_NAME / CONSTRUCT_TENANT_ID to scope queries; an
68
- * explicit `sql` and `project` in opts override both.
64
+ * solo mode → filesystem, team/enterprise → git. The git
65
+ * backend uses the filesystem and git for state synchronization.
69
66
  */
70
67
  export function createIntakeQueue(rootDir, env = process.env, opts = {}) {
71
68
  const backend = opts.backend || resolveBackend(env);
72
69
  if (backend === 'filesystem') return new FilesystemIntakeQueue(rootDir);
73
- if (backend === 'postgres') {
74
- const sql = opts.sql ?? createSqlClient(env);
75
- if (!sql) {
76
- throw new Error('Postgres intake queue requires a configured DATABASE_URL (or sql client). Run `construct init` or set CONSTRUCT_INTAKE_QUEUE_BACKEND=filesystem.');
77
- }
70
+ if (backend === 'git' || backend === 'postgres') {
71
+ // We treat 'postgres' as an alias for 'git' now to avoid breaking existing configs
78
72
  const project = opts.project ?? resolveProject(rootDir, env);
79
- const tenantId = opts.tenantId ?? env?.[INTAKE_TENANT_ENV_KEY] ?? null;
80
- return new PostgresIntakeQueue({ sql, project, tenantId });
73
+ return new GitIntakeQueue({ project, rootDir });
81
74
  }
82
75
  throw new Error(`Unknown intake queue backend: ${backend}`);
83
76
  }
@@ -222,6 +222,24 @@ export const LIMITS = {
222
222
  envOverride: 'CONSTRUCT_SKILL_CALLS_MAX_MB',
223
223
  },
224
224
 
225
+ // Per-hook fire/block/error telemetry. ~/.cx/hook-calls.jsonl.
226
+
227
+ 'hook-calls': {
228
+ maxBytes: 25 * 1024 * 1024,
229
+ maxSegments: 4,
230
+ gzip: true,
231
+ envOverride: 'CONSTRUCT_HOOK_CALLS_MAX_MB',
232
+ },
233
+
234
+ // Legacy-lock fallback firings on the beads write path. ~/.cx/beads-fallback.jsonl.
235
+
236
+ 'beads-fallback': {
237
+ maxBytes: 5 * 1024 * 1024,
238
+ maxSegments: 2,
239
+ gzip: true,
240
+ envOverride: 'CONSTRUCT_BEADS_FALLBACK_MAX_MB',
241
+ },
242
+
225
243
  // Agent-dispatch log written by `lib/hooks/agent-tracker.mjs`. Path: ~/.cx/agent-log.jsonl.
226
244
 
227
245
  'agent-log': {
@@ -118,8 +118,12 @@ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
118
118
  };
119
119
  });
120
120
 
121
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
122
- tools: [
121
+ // The full construct-mcp tool catalog. Only a curated core is exposed flat in
122
+ // ListTools; the long tail is reachable through the construct_call meta-tool, so
123
+ // the serialized tool surface stays small on every host and model. The 71-tool
124
+ // flat surface alone (~10.6k tokens) overran a 32k local-model window.
125
+
126
+ const ALL_TOOL_DEFS = [
123
127
  {
124
128
  name: 'agent_health',
125
129
  description: 'Returns agent health summaries from the most recent performance review.',
@@ -1209,17 +1213,60 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1209
1213
  },
1210
1214
  },
1211
1215
  },
1212
- ],
1213
- }));
1216
+ ];
1214
1217
 
1215
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1216
- const { name, arguments: args = {} } = request.params;
1218
+ // Curated flat core: high-frequency, low-arg tools the orchestrator and the
1219
+ // built-in Build/Plan agents actually reach for. Everything else collapses
1220
+ // behind construct_call so the schema stays small. Universal — applies on every
1221
+ // host/model; the long tail is reachable, just not front-loaded.
1222
+
1223
+ const CORE_TOOL_NAMES = new Set([
1224
+ 'orchestration_policy', 'get_skill', 'search_skills', 'knowledge_search',
1225
+ 'memory_search', 'project_context', 'summarize_diff',
1226
+ ]);
1227
+
1228
+ function firstSentence(text) {
1229
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
1230
+ const end = s.search(/\.(\s|$)/);
1231
+ return (end > 0 ? s.slice(0, end) : s).slice(0, 140);
1232
+ }
1233
+
1234
+ const LONG_TAIL_DEFS = ALL_TOOL_DEFS.filter((t) => !CORE_TOOL_NAMES.has(t.name));
1235
+
1236
+ // One dispatcher for the long tail. `tool` is constrained to an enum of valid
1237
+ // names (≈1 token each — kills hallucinated names, the key small-model lever),
1238
+ // and the description carries a compact one-line catalog instead of ~10k of full
1239
+ // schemas. Dispatch reuses the same handlers via dispatchToolByName.
1240
+
1241
+ const CONSTRUCT_CALL_TOOL = {
1242
+ name: 'construct_call',
1243
+ description:
1244
+ 'Invoke any non-core Construct tool by name. Put the tool name in `tool` and its arguments in `args`. '
1245
+ + 'Available tools:\n'
1246
+ + LONG_TAIL_DEFS.map((t) => `- ${t.name} — ${firstSentence(t.description)}`).join('\n'),
1247
+ inputSchema: {
1248
+ type: 'object',
1249
+ properties: {
1250
+ tool: { type: 'string', enum: LONG_TAIL_DEFS.map((t) => t.name), description: 'The Construct tool to invoke.' },
1251
+ args: { type: 'object', additionalProperties: true, description: 'Arguments object for the tool.' },
1252
+ },
1253
+ required: ['tool'],
1254
+ },
1255
+ };
1256
+
1257
+ export function exposedTools() {
1258
+ return [...ALL_TOOL_DEFS.filter((t) => CORE_TOOL_NAMES.has(t.name)), CONSTRUCT_CALL_TOOL];
1259
+ }
1217
1260
 
1218
- // Extract W3C traceparent from params._meta (SEP-414 propagation).
1219
- const parentCtx = await extractTraceContext(request.params?._meta || {});
1261
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: exposedTools() }));
1220
1262
 
1263
+ // Single dispatch table shared by the direct CallTool path and the construct_call
1264
+ // meta-tool, so collapsing the surface costs no capability — every tool stays
1265
+ // reachable by name. construct_call re-enters here once (guarded against
1266
+ // recursing into itself).
1267
+
1268
+ export async function dispatchToolByName(name, args = {}) {
1221
1269
  let result;
1222
- try {
1223
1270
  if (name === 'agent_health') result = agentHealth(args);
1224
1271
  else if (name === 'summarize_diff') result = summarizeDiff(args);
1225
1272
  else if (name === 'scan_file') result = scanFile(args);
@@ -1301,13 +1348,65 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1301
1348
  else if (name === 'construct_execution_resolve') result = executionResolve(args);
1302
1349
  else if (name === 'orchestration_run') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationRun(args); }
1303
1350
  else if (name === 'orchestration_status') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationStatus(args); }
1351
+ else if (name === 'construct_call') {
1352
+ const inner = String(args?.tool || '');
1353
+ if (!inner || inner === 'construct_call') result = { error: "construct_call requires a 'tool' name (not 'construct_call')" };
1354
+ else result = await dispatchToolByName(inner, args?.args || {});
1355
+ }
1304
1356
  else result = { error: `Unknown tool: ${name}` };
1305
- } catch (err) {
1306
- result = { error: err.message ?? String(err) };
1357
+ return result;
1358
+ }
1359
+
1360
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1361
+ const { name, arguments: args = {} } = request.params;
1362
+
1363
+ // Extract W3C traceparent from params._meta (SEP-414 propagation). Tracing
1364
+ // must never break dispatch — a malformed _meta should not fail the call.
1365
+ let parentCtx = {};
1366
+ try { parentCtx = await extractTraceContext(request.params?._meta || {}); } catch { /* tracing optional */ }
1367
+ void parentCtx;
1368
+
1369
+ // Bound every tool call. A tool that stalls (a stuck external extractor, a slow
1370
+ // model load, a wedged subprocess) must surface a clean timeout error to the
1371
+ // client rather than block the request until the client gives up and reports an
1372
+ // opaque failure. Override with CONSTRUCT_MCP_TOOL_TIMEOUT_MS (0 disables).
1373
+ const TOOL_TIMEOUT_MS = (() => {
1374
+ const raw = Number(process.env.CONSTRUCT_MCP_TOOL_TIMEOUT_MS);
1375
+ return Number.isFinite(raw) && raw >= 0 ? raw : 120_000;
1376
+ })();
1377
+
1378
+ const dispatch = (async () => {
1379
+ let result;
1380
+ try {
1381
+ result = await dispatchToolByName(name, args);
1382
+ } catch (err) {
1383
+ result = { error: err.message ?? String(err) };
1384
+ }
1385
+ return result;
1386
+ })();
1387
+
1388
+ let toolResult;
1389
+ if (!TOOL_TIMEOUT_MS) {
1390
+ toolResult = await dispatch;
1391
+ } else {
1392
+ let timer;
1393
+ const timeout = new Promise((_, reject) => {
1394
+ timer = setTimeout(
1395
+ () => reject(new Error(`tool ${name} timed out after ${Math.round(TOOL_TIMEOUT_MS / 1000)}s`)),
1396
+ TOOL_TIMEOUT_MS,
1397
+ );
1398
+ });
1399
+ try {
1400
+ toolResult = await Promise.race([dispatch, timeout]);
1401
+ } catch (err) {
1402
+ toolResult = { error: err.message ?? String(err) };
1403
+ } finally {
1404
+ clearTimeout(timer);
1405
+ }
1307
1406
  }
1308
1407
 
1309
1408
  return {
1310
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1409
+ content: [{ type: 'text', text: JSON.stringify(toolResult, null, 2) }],
1311
1410
  };
1312
1411
  });
1313
1412
 
@@ -1333,6 +1432,19 @@ export {
1333
1432
  const argv1Real = (() => { try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
1334
1433
  if (fileURLToPath(import.meta.url) === argv1Real) {
1335
1434
  console.error('[construct-mcp] server started');
1435
+
1436
+ // A long-running stdio server must survive a single malformed request: a
1437
+ // background rejection from one ingest (e.g. a broken docling sidecar that
1438
+ // settles late) must not terminate the process and close the client
1439
+ // connection. Log loudly and keep serving instead of crashing. Scoped to the
1440
+ // server entry so importing this module never installs global handlers.
1441
+ process.on('unhandledRejection', (reason) => {
1442
+ console.error('[construct-mcp] unhandledRejection (kept alive):', reason instanceof Error ? reason.stack : reason);
1443
+ });
1444
+ process.on('uncaughtException', (err) => {
1445
+ console.error('[construct-mcp] uncaughtException (kept alive):', err?.stack || err);
1446
+ });
1447
+
1336
1448
  const transport = new StdioServerTransport();
1337
1449
  await server.connect(transport);
1338
1450
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * lib/mcp/tool-budget.mjs — tool-surface sizing for the OpenCode integration.
3
+ *
4
+ * OpenCode 1.15.4 exposes no per-session tool filter (chat.params carries only
5
+ * sampler params, and tool.definition cannot remove a tool or see the model), so
6
+ * the serialized tool surface is fixed by two config/server-time levers:
7
+ * 1. construct-mcp's ListTools — a lean core + the construct_call gateway.
8
+ * 2. which external MCP servers are `enabled` in opencode.json — sync disables
9
+ * the heavy ones for local-capable setups (they cannot be trimmed at runtime).
10
+ * Holds the shared token-sizing helper and the external-server id list both
11
+ * levers reason about.
12
+ */
13
+
14
+ const TOKENS_PER_CHAR = 0.25;
15
+
16
+ // Heavy external MCP servers Construct registers globally. On a local-capable
17
+ // setup these alone serialize ~12k tokens into every agent's window (including
18
+ // the built-in Build/Plan agents) and cannot be filtered per request, so sync
19
+ // disables them in opencode.json. construct-mcp's own knowledge_search /
20
+ // memory_search cover the search/memory cases.
21
+
22
+ export const HEAVY_EXTERNAL_MCP_IDS = ['context7', 'github', 'memory', 'sequential-thinking', 'playwright'];
23
+
24
+ export const LOCAL_SURFACE_MODES = ['auto', 'on', 'off'];
25
+
26
+ // A model benefits from trimming only when it is a small-context local runtime
27
+ // reached over Ollama/localhost. github-copilot is a hosted, full-context proxy,
28
+ // so it is deliberately not "local" here even though it is provider-local.
29
+
30
+ export function isLocalModel(model) {
31
+ const m = (model || "").toLowerCase();
32
+ return m.includes("ollama") || m.includes("localhost") || m.includes("127.0.0.1");
33
+ }
34
+
35
+ // Decide whether to disable the heavy external MCP servers for one opencode.json.
36
+ // Trimming is driven by INTENT — the default model this config actually selects —
37
+ // not by whether the machine happens to have Ollama installed. Machine-wide
38
+ // presence stripped context7/github from cloud sessions that merely shared a box
39
+ // with Ollama; keying off the config's own default model preserves cloud surfaces
40
+ // and still shrinks the window when the user has chosen a local model.
41
+ // surface: 'on' (always trim) | 'off' (never) | 'auto' (trim iff default is local)
42
+
43
+ export function decideTrim({ surface = 'auto', defaultModel = null } = {}) {
44
+ if (surface === 'off') return false;
45
+ if (surface === 'on') return true;
46
+ return isLocalModel(defaultModel);
47
+ }
48
+
49
+ export function estimateToolTokens(tools) {
50
+ if (!tools) return 0;
51
+ const arr = Array.isArray(tools) ? tools : Object.values(tools);
52
+ return Math.round(JSON.stringify(arr).length * TOKENS_PER_CHAR);
53
+ }
@@ -37,14 +37,13 @@ export async function storageReset(args) {
37
37
  return resetStorage(cwd, {
38
38
  env: process.env,
39
39
  project,
40
- resetSql: args.reset_sql !== false,
41
40
  resetVector: args.reset_vector !== false,
42
41
  resetIngested: args.reset_ingested === true,
43
42
  confirm: true,
44
43
  });
45
44
  }
46
45
 
47
- export function deleteIngestedArtifactsTool(args) {
46
+ export async function deleteIngestedArtifactsTool(args) {
48
47
  const cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
49
48
  if (args.confirm !== true) {
50
49
  return { error: 'delete_ingested_artifacts requires confirm=true' };
@@ -53,7 +52,7 @@ export function deleteIngestedArtifactsTool(args) {
53
52
  ? args.files.map((value) => String(value))
54
53
  : [];
55
54
  try {
56
- return deleteIngestedArtifacts(cwd, { files, confirm: true });
55
+ return await deleteIngestedArtifacts(cwd, { files, confirm: true });
57
56
  } catch (error) {
58
57
  return { error: error.message ?? String(error) };
59
58
  }
@@ -29,7 +29,7 @@
29
29
  {
30
30
  "id": "sequential-thinking",
31
31
  "name": "Sequential Thinking",
32
- "category": "core",
32
+ "category": "optional",
33
33
  "description": "Structured multi-step reasoning chains. Helps with complex planning and analysis.",
34
34
  "package": "@modelcontextprotocol/server-sequential-thinking",
35
35
  "command": "npx",
@@ -85,12 +85,12 @@
85
85
  "id": "playwright",
86
86
  "name": "Playwright",
87
87
  "category": "optional",
88
- "description": "Browser automation for E2E testing and web research.",
89
- "package": "@playwright/mcp@latest",
88
+ "description": "Browser automation for E2E testing and web research. Opt-in via `construct mcp add playwright` — not synced by default (downloads browser binaries on first use).",
89
+ "package": "@playwright/mcp@0.0.75",
90
90
  "command": "npx",
91
91
  "args": [
92
92
  "-y",
93
- "@playwright/mcp@latest"
93
+ "@playwright/mcp@0.0.75"
94
94
  ],
95
95
  "env": {},
96
96
  "requiredEnv": [],
@@ -33,6 +33,56 @@ function getClaudeSettingsPath() {
33
33
  return join(homedir(), '.claude', 'settings.json');
34
34
  }
35
35
 
36
+ // VS Code's "MCP: Open User Configuration" edits a per-profile mcp.json keyed
37
+ // on top-level `servers`; Cursor uses ~/.cursor/mcp.json keyed on `mcpServers`.
38
+ // construct sync writes both surfaces, so remove must clean them too or a
39
+ // removed MCP lingers there.
40
+
41
+ function getVSCodeUserMcpPaths(home = homedir()) {
42
+ const plat = platform();
43
+ if (plat === 'darwin') {
44
+ return [
45
+ join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
46
+ join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json'),
47
+ ];
48
+ }
49
+ if (plat === 'win32') {
50
+ const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
51
+ return [join(appData, 'Code', 'User', 'mcp.json'), join(appData, 'Code - Insiders', 'User', 'mcp.json')];
52
+ }
53
+ return [
54
+ join(home, '.config', 'Code', 'User', 'mcp.json'),
55
+ join(home, '.config', 'Code - Insiders', 'User', 'mcp.json'),
56
+ ];
57
+ }
58
+
59
+ function getCursorMcpPath(home = homedir()) {
60
+ return join(home, '.cursor', 'mcp.json');
61
+ }
62
+
63
+ function mcpFileHasEntry(file, key, id) {
64
+ if (!existsSync(file)) return false;
65
+ try {
66
+ return Boolean(JSON.parse(readFileSync(file, 'utf8'))?.[key]?.[id]);
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function removeMcpFromJsonFile(file, key, id) {
73
+ if (!existsSync(file)) return false;
74
+ let config;
75
+ try {
76
+ config = JSON.parse(readFileSync(file, 'utf8'));
77
+ } catch {
78
+ return false;
79
+ }
80
+ if (!config?.[key] || !(id in config[key])) return false;
81
+ delete config[key][id];
82
+ writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
83
+ return true;
84
+ }
85
+
36
86
  function openUrl(url) {
37
87
  try {
38
88
  const opener = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
@@ -426,10 +476,14 @@ export function cmdMcpRemove(id) {
426
476
  const openCodeState = readOpenCodeConfig();
427
477
  const oc = openCodeState.config ?? {};
428
478
  const openCodeId = getOpenCodeMcpId(id);
479
+ const vscodePaths = getVSCodeUserMcpPaths();
480
+ const cursorPath = getCursorMcpPath();
429
481
  const hasClaudeEntry = Boolean(settings.mcpServers?.[id]);
430
482
  const hasOpenCodeEntry = Boolean(oc.mcp?.[openCodeId] || oc.mcp?.[id] || (id === 'memory' && oc.mcp?.cass));
431
483
  const hasCodexEntry = readCodexConfig().includes(`[mcp_servers.${id}]`) || readCodexConfig().includes(`[mcp_servers.${tomlString(id)}]`);
432
- if (!hasClaudeEntry && !hasOpenCodeEntry && !hasCodexEntry) {
484
+ const hasVscodeEntry = vscodePaths.some((p) => mcpFileHasEntry(p, 'servers', id));
485
+ const hasCursorEntry = mcpFileHasEntry(cursorPath, 'mcpServers', id);
486
+ if (!hasClaudeEntry && !hasOpenCodeEntry && !hasCodexEntry && !hasVscodeEntry && !hasCursorEntry) {
433
487
  console.log(`${id} is not installed. Nothing to remove.`);
434
488
  return;
435
489
  }
@@ -448,9 +502,11 @@ export function cmdMcpRemove(id) {
448
502
  if (openCodeState.config || hasOpenCodeEntry) saveOpenCodeConfig(oc);
449
503
  else if (openCodeState.file && existsSync(openCodeState.file)) rmSync(openCodeState.file, { force: true });
450
504
  removeCodexMcp(id);
505
+ for (const p of vscodePaths) removeMcpFromJsonFile(p, 'servers', id);
506
+ removeMcpFromJsonFile(cursorPath, 'mcpServers', id);
451
507
 
452
- console.log(`✓ ${name} removed from Claude Code, OpenCode, and Codex config`);
453
- console.log('Restart Claude Code, OpenCode, or Codex to deactivate it.');
508
+ console.log(`✓ ${name} removed from Claude Code, OpenCode, Codex, VS Code, and Cursor config`);
509
+ console.log('Restart the editor to deactivate it.');
454
510
  }
455
511
 
456
512
  /**