@geraldmaron/construct 1.0.24 → 1.1.1
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 +2 -0
- package/bin/construct +266 -13
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/audit-trail.mjs +77 -10
- 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 +112 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/demo.mjs +245 -0
- package/lib/diagram.mjs +300 -0
- package/lib/doctor/index.mjs +1 -1
- package/lib/doctor/watchers/process-pressure.mjs +1 -1
- package/lib/doctor/watchers/service-health.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 +85 -10
- package/lib/document-ingest.mjs +97 -7
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/embed/semantic.mjs +5 -2
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/handoffs/cleanup.mjs +1 -1
- package/lib/hooks/audit-trail.mjs +14 -28
- 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/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp-catalog.json +1 -1
- package/lib/model-router.mjs +52 -1
- 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-policy.mjs +41 -6
- package/lib/persona-sections.mjs +66 -0
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +14 -9
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- 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/server/index.mjs +1 -1
- package/lib/server/langfuse-login.mjs +3 -3
- package/lib/service-manager.mjs +42 -4
- package/lib/session-store.mjs +1 -1
- package/lib/setup.mjs +58 -2
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/embeddings-local.mjs +6 -3
- package/lib/storage/file-lock.mjs +18 -11
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/package.json +4 -2
- package/personas/construct.md +12 -12
- package/platforms/capabilities.json +76 -0
- 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 +277 -63
- package/skills/docs/init-project.md +1 -1
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/templates/docs/construct_guide.md +2 -2
- package/lib/ingest/chunker.mjs +0 -94
- package/lib/ingest/pipeline.mjs +0 -53
- package/lib/ingest/store.mjs +0 -82
- package/lib/mode-commands.mjs +0 -122
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/profiles/validate-custom.mjs +0 -114
- package/lib/services/telemetry-backend.mjs +0 -177
- package/lib/storage/fusion.mjs +0 -95
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/contradiction.mjs — deterministic "do these two observations disagree?"
|
|
3
|
+
*
|
|
4
|
+
* Restatement supersede (construct-xh6c) collapses near-duplicates that say the
|
|
5
|
+
* same thing. The open case is contradiction: two observations about the same
|
|
6
|
+
* subject that make opposing claims ("auth is supported" vs "auth is not
|
|
7
|
+
* supported"). A contradiction inherently shares most of its tokens but flips
|
|
8
|
+
* one — so its cosine sits *below* the duplicate threshold, which is why the
|
|
9
|
+
* consolidation scan looks in a suspicious band rather than inside a cluster.
|
|
10
|
+
*
|
|
11
|
+
* The signal this detects without an LLM is negation polarity: same claim
|
|
12
|
+
* words, opposite assertion. A value swap with no negation cue ("RS256" vs
|
|
13
|
+
* "HS256") is not caught here — that needs semantic judgment and is left to an
|
|
14
|
+
* optional `contradictionJudge` plugin wired into the consolidation pass.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Cues that flip an assertion. Apostrophes are stripped during tokenization, so
|
|
18
|
+
// contracted forms appear here without them (don't -> dont, isn't -> isnt).
|
|
19
|
+
const NEGATION_CUES = new Set([
|
|
20
|
+
'not', 'no', 'never', 'none', 'neither', 'nor', 'without', 'cannot',
|
|
21
|
+
'dont', 'doesnt', 'didnt', 'isnt', 'arent', 'wasnt', 'werent', 'wont',
|
|
22
|
+
'cant', 'couldnt', 'shouldnt', 'wouldnt', 'fails', 'failing', 'failed',
|
|
23
|
+
'unsupported', 'disabled', 'broken', 'missing', 'absent',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function tokenize(text) {
|
|
27
|
+
return String(text || '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/['’]/g, '')
|
|
30
|
+
.split(/[^a-z0-9]+/)
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function jaccard(a, b) {
|
|
35
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
36
|
+
let inter = 0;
|
|
37
|
+
for (const t of a) if (b.has(t)) inter += 1;
|
|
38
|
+
return inter / (a.size + b.size - inter);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether two observation texts contradict on negation polarity.
|
|
43
|
+
*
|
|
44
|
+
* Contradiction = the claim words (tokens minus negation cues) overlap heavily
|
|
45
|
+
* AND the two carry a different number of negation cues — one asserts, the other
|
|
46
|
+
* denies the same thing.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} textA
|
|
49
|
+
* @param {string} textB
|
|
50
|
+
* @param {{coreSimilarity?: number}} [opts] coreSimilarity: Jaccard floor on the
|
|
51
|
+
* cue-stripped token sets (default 0.6); below it the two are about different
|
|
52
|
+
* subjects, not a flipped claim.
|
|
53
|
+
* @returns {{contradicts: boolean, coreSimilarity: number, negDelta: number, reason: string}}
|
|
54
|
+
*/
|
|
55
|
+
export function detectContradiction(textA, textB, opts = {}) {
|
|
56
|
+
const floor = opts.coreSimilarity ?? 0.6;
|
|
57
|
+
const tokA = tokenize(textA);
|
|
58
|
+
const tokB = tokenize(textB);
|
|
59
|
+
const negA = tokA.filter((t) => NEGATION_CUES.has(t)).length;
|
|
60
|
+
const negB = tokB.filter((t) => NEGATION_CUES.has(t)).length;
|
|
61
|
+
const coreA = new Set(tokA.filter((t) => !NEGATION_CUES.has(t)));
|
|
62
|
+
const coreB = new Set(tokB.filter((t) => !NEGATION_CUES.has(t)));
|
|
63
|
+
|
|
64
|
+
const coreSimilarity = jaccard(coreA, coreB);
|
|
65
|
+
const negDelta = negA - negB;
|
|
66
|
+
const contradicts =
|
|
67
|
+
coreA.size > 0 && coreB.size > 0 &&
|
|
68
|
+
coreSimilarity >= floor &&
|
|
69
|
+
negDelta !== 0;
|
|
70
|
+
|
|
71
|
+
return { contradicts, coreSimilarity, negDelta, reason: 'negation-polarity' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const __testing = { tokenize, jaccard, NEGATION_CUES };
|
package/lib/handoffs/cleanup.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* surfaced as a warning but never deleted — open work is sacred.
|
|
11
11
|
*
|
|
12
12
|
* The function is idempotent and runs from:
|
|
13
|
-
* - `construct
|
|
13
|
+
* - `construct stop` (best effort, time-boxed)
|
|
14
14
|
* - doctor daemon tick (lib/doctor/watchers/handoffs.mjs)
|
|
15
15
|
* - `construct handoffs prune` (manual)
|
|
16
16
|
*
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
* @matcher Edit|Write|MultiEdit|NotebookEdit|Bash
|
|
34
34
|
* @exits 0 = pass
|
|
35
35
|
*/
|
|
36
|
-
import { readFileSync, existsSync
|
|
36
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
37
37
|
import { createHash } from 'node:crypto';
|
|
38
|
-
import { join, resolve
|
|
38
|
+
import { join, resolve } from 'node:path';
|
|
39
39
|
import { homedir } from 'node:os';
|
|
40
|
-
import {
|
|
40
|
+
import { appendAuditRecord } from '../audit-trail.mjs';
|
|
41
41
|
import { resolveProjectScopedPath } from '../project-root.mjs';
|
|
42
42
|
import { logHookFailure } from './_lib/log.mjs';
|
|
43
43
|
|
|
@@ -77,16 +77,6 @@ function readLastAgent() {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function readPrevLineHash() {
|
|
81
|
-
try {
|
|
82
|
-
const lastLine = readLastLineAcrossSegments(AUDIT_FILE);
|
|
83
|
-
if (lastLine === null) return null;
|
|
84
|
-
return sha256(lastLine);
|
|
85
|
-
} catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
80
|
function truncateSnippet(s, limit = 280) {
|
|
91
81
|
if (!s) return '';
|
|
92
82
|
const str = String(s);
|
|
@@ -140,22 +130,18 @@ if (toolName !== 'Bash' && target) {
|
|
|
140
130
|
}
|
|
141
131
|
}
|
|
142
132
|
|
|
143
|
-
const record = {
|
|
144
|
-
ts: new Date().toISOString(),
|
|
145
|
-
session_id: input?.session_id || null,
|
|
146
|
-
tool: toolName,
|
|
147
|
-
agent: readLastAgent(),
|
|
148
|
-
task: null,
|
|
149
|
-
cwd,
|
|
150
|
-
target,
|
|
151
|
-
detail,
|
|
152
|
-
content_hash: contentHash,
|
|
153
|
-
prev_line_hash: readPrevLineHash(),
|
|
154
|
-
};
|
|
155
|
-
|
|
156
133
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
134
|
+
appendAuditRecord({
|
|
135
|
+
ts: new Date().toISOString(),
|
|
136
|
+
session_id: input?.session_id || null,
|
|
137
|
+
tool: toolName,
|
|
138
|
+
agent: readLastAgent(),
|
|
139
|
+
task: null,
|
|
140
|
+
cwd,
|
|
141
|
+
target,
|
|
142
|
+
detail,
|
|
143
|
+
content_hash: contentHash,
|
|
144
|
+
}, { file: AUDIT_FILE });
|
|
159
145
|
} catch (err) {
|
|
160
146
|
logHookFailure({ hook: 'audit-trail', err, phase: 'append' });
|
|
161
147
|
}
|
|
@@ -118,6 +118,36 @@ function detectCopilotAvailability(homeDir = os.homedir()) {
|
|
|
118
118
|
return { hasFiles };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Detects active host-native sessions (GitHub, Anthropic, etc.).
|
|
123
|
+
* Returns a list of authenticated provider families.
|
|
124
|
+
*/
|
|
125
|
+
export function detectActiveSessions() {
|
|
126
|
+
const active = [];
|
|
127
|
+
|
|
128
|
+
// GitHub Copilot check
|
|
129
|
+
try {
|
|
130
|
+
const ghStatus = execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
131
|
+
if (ghStatus.includes("Logged in to github.com")) {
|
|
132
|
+
active.push("github-copilot");
|
|
133
|
+
}
|
|
134
|
+
} catch { /* not logged in or gh cli missing */ }
|
|
135
|
+
|
|
136
|
+
// Anthropic / Claude Code check
|
|
137
|
+
const home = os.homedir();
|
|
138
|
+
const claudeSettings = path.join(home, ".claude", "settings.json");
|
|
139
|
+
try {
|
|
140
|
+
if (fs.existsSync(claudeSettings)) {
|
|
141
|
+
const settings = JSON.parse(fs.readFileSync(claudeSettings, "utf8"));
|
|
142
|
+
if (settings?.primaryModel || settings?.mcpServers) {
|
|
143
|
+
active.push("anthropic");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch { /* invalid or missing config */ }
|
|
147
|
+
|
|
148
|
+
return active;
|
|
149
|
+
}
|
|
150
|
+
|
|
121
151
|
// `capability` is the honest, normalized functional classification per host:
|
|
122
152
|
// full-native — the host runs a multi-specialist chain itself (native
|
|
123
153
|
// dispatch or config-driven agents): Claude Code, OpenCode.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ingest/docling-remote.mjs — opt-in remote document conversion via Docling Serve.
|
|
3
|
+
*
|
|
4
|
+
* The `docling-remote` ingest strategy (ADR-0036) sends the document to a
|
|
5
|
+
* user-configured Docling Serve API instead of the offline Python sidecar —
|
|
6
|
+
* for zero-local-footprint installs that accept remote conversion. The endpoint
|
|
7
|
+
* contract is Docling Serve's `POST /v1/convert/file` (multipart field `files`,
|
|
8
|
+
* markdown at `document.md_content`, per docling-serve docs/usage.md).
|
|
9
|
+
*
|
|
10
|
+
* The serve URL must be configured (DOCLING_SERVE_URL); a missing URL fails loud —
|
|
11
|
+
* the user explicitly chose remote, so silently degrading to the sidecar would
|
|
12
|
+
* hide the misconfiguration. The call is bounded by the same timeout knob as the
|
|
13
|
+
* sidecar (CONSTRUCT_DOCLING_TIMEOUT_MS, default 600s).
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
19
|
+
|
|
20
|
+
export function resolveDoclingServeUrl(env = process.env) {
|
|
21
|
+
const raw = (env.DOCLING_SERVE_URL || '').trim();
|
|
22
|
+
return raw ? raw.replace(/\/+$/, '') : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert one local file through Docling Serve. Returns the shared extractor
|
|
27
|
+
* contract ({ text, extractionMethod, characters, truncated, droppedInfo }).
|
|
28
|
+
* Throws (fail-loud) on missing URL, HTTP error, non-success status, or timeout.
|
|
29
|
+
*/
|
|
30
|
+
export async function extractViaDoclingRemote({ filePath, maxChars = null, env = process.env, timeoutMs = null } = {}) {
|
|
31
|
+
const baseUrl = resolveDoclingServeUrl(env);
|
|
32
|
+
if (!baseUrl) {
|
|
33
|
+
const err = new Error(
|
|
34
|
+
"ingest.strategy is 'docling-remote' but DOCLING_SERVE_URL is not set. " +
|
|
35
|
+
"Point it at a Docling Serve instance, or switch ingest.strategy back to 'adapter' (offline sidecar).",
|
|
36
|
+
);
|
|
37
|
+
err.code = 'DOCLING_REMOTE_UNCONFIGURED';
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const budget = timeoutMs ?? (Number(env.CONSTRUCT_DOCLING_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS);
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), budget);
|
|
44
|
+
try {
|
|
45
|
+
const form = new FormData();
|
|
46
|
+
const bytes = fs.readFileSync(filePath);
|
|
47
|
+
form.append('files', new Blob([bytes]), path.basename(filePath));
|
|
48
|
+
|
|
49
|
+
const res = await fetch(`${baseUrl}/v1/convert/file`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
body: form,
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const err = new Error(`Docling Serve returned HTTP ${res.status} for ${path.basename(filePath)}`);
|
|
56
|
+
err.code = 'DOCLING_REMOTE_HTTP';
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
const status = data?.status;
|
|
61
|
+
const markdown = data?.document?.md_content;
|
|
62
|
+
if ((status && status !== 'success' && status !== 'partial_success') || typeof markdown !== 'string') {
|
|
63
|
+
const detail = Array.isArray(data?.errors) && data.errors.length ? `: ${JSON.stringify(data.errors).slice(0, 200)}` : '';
|
|
64
|
+
const err = new Error(`Docling Serve conversion ${status || 'returned no markdown'}${detail}`);
|
|
65
|
+
err.code = 'DOCLING_REMOTE_FAILED';
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const truncated = maxChars != null && markdown.length > maxChars;
|
|
70
|
+
const text = truncated ? markdown.slice(0, maxChars) : markdown;
|
|
71
|
+
return {
|
|
72
|
+
text,
|
|
73
|
+
extractionMethod: 'docling-remote',
|
|
74
|
+
characters: text.length,
|
|
75
|
+
truncated,
|
|
76
|
+
droppedInfo: status === 'partial_success'
|
|
77
|
+
? [{ type: 'partial-conversion', count: 1, reason: 'Docling Serve reported partial_success', recoverable: true }]
|
|
78
|
+
: [],
|
|
79
|
+
};
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err.name === 'AbortError') {
|
|
82
|
+
const e = new Error(`Docling Serve conversion timed out after ${budget}ms`);
|
|
83
|
+
e.code = 'DOCLING_REMOTE_TIMEOUT';
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/lib/ingest/strategy.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { resolveSetting } from '../config/project-config.mjs';
|
|
|
17
17
|
import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
|
|
18
18
|
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
19
19
|
|
|
20
|
-
export const INGEST_STRATEGIES = ['adapter', 'provider'];
|
|
20
|
+
export const INGEST_STRATEGIES = ['adapter', 'provider', 'docling-remote'];
|
|
21
21
|
export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
|
|
22
22
|
|
|
23
23
|
// Orchestration is a second, independent axis from extraction (adapter|provider):
|
package/lib/init-unified.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { multiSelect } from './tty-prompts.mjs';
|
|
|
23
23
|
import { execSync, spawnSync } from 'node:child_process';
|
|
24
24
|
import { stageProjectAdapters } from './install/stage-project.mjs';
|
|
25
25
|
import { missingIgnorePatterns, isConstructPackageRepo } from './host-disposition.mjs';
|
|
26
|
+
import { HOST_KEYS, displayNameToKey } from './platforms/capabilities.mjs';
|
|
26
27
|
|
|
27
28
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
28
29
|
const ROOT_DIR = path.join(__dirname, "..");
|
|
@@ -63,8 +64,7 @@ const commitBootstrap = args.includes("--commit-bootstrap");
|
|
|
63
64
|
// one, --all-hosts writes every adapter set. Copilot (`.github/`) is opt-in only
|
|
64
65
|
// — never written by detection — so init never touches a repo's CI directory
|
|
65
66
|
// without --with-copilot.
|
|
66
|
-
const
|
|
67
|
-
const withHostFlags = new Set(HOST_FLAG_KEYS.filter((k) => args.includes(`--with-${k}`)));
|
|
67
|
+
const withHostFlags = new Set(HOST_KEYS.filter((k) => args.includes(`--with-${k}`)));
|
|
68
68
|
const allHosts = args.includes("--all-hosts");
|
|
69
69
|
|
|
70
70
|
// Active profile selector. `--profile=<id>` writes the field into the
|
|
@@ -809,23 +809,19 @@ async function resolveAdapterHosts() {
|
|
|
809
809
|
const detected = new Set();
|
|
810
810
|
try {
|
|
811
811
|
const { detectHostCapabilities } = await import('./host-capabilities.mjs');
|
|
812
|
-
const nameToKey =
|
|
813
|
-
'Claude Code': 'claude',
|
|
814
|
-
'OpenCode': 'opencode',
|
|
815
|
-
'Codex': 'codex',
|
|
816
|
-
'VS Code': 'vscode',
|
|
817
|
-
'Cursor': 'cursor',
|
|
818
|
-
'Copilot': 'copilot',
|
|
819
|
-
};
|
|
812
|
+
const nameToKey = displayNameToKey();
|
|
820
813
|
for (const cap of detectHostCapabilities()) {
|
|
821
814
|
if (cap.availability === 'installed' && nameToKey[cap.host]) detected.add(nameToKey[cap.host]);
|
|
822
815
|
}
|
|
823
816
|
} catch { /* detection is advisory; fall back to flags + baseline */ }
|
|
824
817
|
detected.delete('copilot');
|
|
825
818
|
const selected = new Set([...detected, ...withHostFlags]);
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
819
|
+
|
|
820
|
+
// An empty selection writes no adapters: nothing detected and no --with-<host>
|
|
821
|
+
// flag means the user is guided by docs to run sync with an explicit host,
|
|
822
|
+
// rather than scaffolding a sidecar they did not ask for.
|
|
823
|
+
|
|
824
|
+
return HOST_KEYS.filter((k) => selected.has(k));
|
|
829
825
|
}
|
|
830
826
|
|
|
831
827
|
async function main() {
|
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
|
+
}
|
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",
|