@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.
Files changed (87) hide show
  1. package/README.md +2 -0
  2. package/bin/construct +266 -13
  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/audit-trail.mjs +77 -10
  7. package/lib/beads-client.mjs +9 -0
  8. package/lib/beads-optimistic.mjs +23 -71
  9. package/lib/bridges/copilot-proxy.mjs +116 -0
  10. package/lib/cli-commands.mjs +112 -1
  11. package/lib/comment-lint.mjs +1 -1
  12. package/lib/config/schema.mjs +1 -1
  13. package/lib/demo.mjs +245 -0
  14. package/lib/diagram.mjs +300 -0
  15. package/lib/doctor/index.mjs +1 -1
  16. package/lib/doctor/watchers/process-pressure.mjs +1 -1
  17. package/lib/doctor/watchers/service-health.mjs +1 -1
  18. package/lib/document-extract/docling-client.mjs +16 -6
  19. package/lib/document-extract/docling-sidecar.py +32 -2
  20. package/lib/document-extract.mjs +85 -10
  21. package/lib/document-ingest.mjs +97 -7
  22. package/lib/embed/roadmap.mjs +16 -1
  23. package/lib/embed/semantic.mjs +5 -2
  24. package/lib/engine/consolidate.mjs +160 -3
  25. package/lib/engine/contradiction-judge.mjs +71 -0
  26. package/lib/engine/contradiction.mjs +74 -0
  27. package/lib/handoffs/cleanup.mjs +1 -1
  28. package/lib/hooks/audit-trail.mjs +14 -28
  29. package/lib/host-capabilities.mjs +30 -0
  30. package/lib/ingest/docling-remote.mjs +90 -0
  31. package/lib/ingest/strategy.mjs +1 -1
  32. package/lib/init-unified.mjs +9 -13
  33. package/lib/logging/rotate.mjs +18 -0
  34. package/lib/mcp/server.mjs +124 -12
  35. package/lib/mcp/tool-budget.mjs +53 -0
  36. package/lib/mcp-catalog.json +1 -1
  37. package/lib/model-router.mjs +52 -1
  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-policy.mjs +41 -6
  43. package/lib/persona-sections.mjs +66 -0
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +14 -9
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reflect/extractor.mjs +14 -1
  48. package/lib/reflect/salience.mjs +65 -0
  49. package/lib/rules-delivery.mjs +122 -0
  50. package/lib/runtime/uv-bootstrap.mjs +32 -17
  51. package/lib/server/index.mjs +1 -1
  52. package/lib/server/langfuse-login.mjs +3 -3
  53. package/lib/service-manager.mjs +42 -4
  54. package/lib/session-store.mjs +1 -1
  55. package/lib/setup.mjs +58 -2
  56. package/lib/specialists/prompt-schema.mjs +162 -0
  57. package/lib/specialists/scaffold.mjs +109 -0
  58. package/lib/storage/embeddings-engine.mjs +19 -5
  59. package/lib/storage/embeddings-local.mjs +6 -3
  60. package/lib/storage/file-lock.mjs +18 -11
  61. package/lib/telemetry/beads-fallback.mjs +40 -0
  62. package/lib/telemetry/hook-calls.mjs +138 -0
  63. package/package.json +4 -2
  64. package/personas/construct.md +12 -12
  65. package/platforms/capabilities.json +76 -0
  66. package/platforms/opencode/sync-config.mjs +121 -25
  67. package/rules/common/neurodivergent-output.md +1 -1
  68. package/rules/web/coding-style.md +8 -0
  69. package/rules/web/design-quality.md +8 -0
  70. package/rules/web/hooks.md +8 -0
  71. package/rules/web/patterns.md +8 -0
  72. package/rules/web/performance.md +8 -0
  73. package/rules/web/security.md +8 -0
  74. package/rules/web/testing.md +8 -0
  75. package/scripts/sync-specialists.mjs +277 -63
  76. package/skills/docs/init-project.md +1 -1
  77. package/specialists/prompts/cx-architect.md +20 -0
  78. package/specialists/prompts/cx-test-automation.md +12 -0
  79. package/templates/docs/construct_guide.md +2 -2
  80. package/lib/ingest/chunker.mjs +0 -94
  81. package/lib/ingest/pipeline.mjs +0 -53
  82. package/lib/ingest/store.mjs +0 -82
  83. package/lib/mode-commands.mjs +0 -122
  84. package/lib/policy/unified-gates.mjs +0 -96
  85. package/lib/profiles/validate-custom.mjs +0 -114
  86. package/lib/services/telemetry-backend.mjs +0 -177
  87. 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 };
@@ -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 down` (best effort, time-boxed)
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, mkdirSync } from 'node:fs';
36
+ import { readFileSync, existsSync } from 'node:fs';
37
37
  import { createHash } from 'node:crypto';
38
- import { join, resolve, dirname } from 'node:path';
38
+ import { join, resolve } from 'node:path';
39
39
  import { homedir } from 'node:os';
40
- import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
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
- mkdirSync(dirname(AUDIT_FILE), { recursive: true });
158
- appendBounded('audit-trail', AUDIT_FILE, JSON.stringify(record) + '\n');
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
+ }
@@ -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):
@@ -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 HOST_FLAG_KEYS = ["claude", "codex", "opencode", "vscode", "cursor", "copilot"];
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
- if (selected.has('vscode')) selected.add('claude');
827
- if (selected.size === 0) selected.add('claude');
828
- return HOST_FLAG_KEYS.filter((k) => selected.has(k));
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() {
@@ -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
+ }
@@ -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",