@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.
- package/README.md +0 -2
- package/bin/construct +189 -207
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +4 -4
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration/runtime.mjs +3 -2
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reconcile/index.mjs +0 -2
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/service-manager.mjs +79 -259
- package/lib/setup.mjs +44 -425
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/personas/construct.md +1 -1
- package/platforms/capabilities.json +76 -0
- package/platforms/claude/settings.template.json +0 -7
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +174 -40
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/specialists/registry.json +0 -8
- package/templates/docs/construct_guide.md +1 -1
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/reconcile/postgres-namespace.mjs +0 -102
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/unified-storage.mjs +0 -550
- 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
|
+
}
|
package/lib/intake/queue.mjs
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 === '
|
|
50
|
+
if (override === 'filesystem' || override === 'git') return override;
|
|
52
51
|
const mode = getDeploymentMode(env);
|
|
53
|
-
return mode === 'solo' ? 'filesystem' : '
|
|
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 →
|
|
66
|
-
* backend
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/logging/rotate.mjs
CHANGED
|
@@ -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': {
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -118,8 +118,12 @@ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
|
118
118
|
};
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
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(
|
|
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
|
}
|
package/lib/mcp-catalog.json
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
{
|
|
30
30
|
"id": "sequential-thinking",
|
|
31
31
|
"name": "Sequential Thinking",
|
|
32
|
-
"category": "
|
|
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@
|
|
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@
|
|
93
|
+
"@playwright/mcp@0.0.75"
|
|
94
94
|
],
|
|
95
95
|
"env": {},
|
|
96
96
|
"requiredEnv": [],
|
package/lib/mcp-manager.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
453
|
-
console.log('Restart
|
|
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
|
/**
|