@geraldmaron/construct 1.0.19 → 1.0.20
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 +79 -0
- package/lib/cli-commands.mjs +24 -1
- package/lib/config/schema.mjs +31 -1
- package/lib/decisions/enforced-baseline.json +2 -0
- package/lib/document-ingest.mjs +71 -6
- package/lib/embedded-contract/capability.mjs +3 -3
- package/lib/embedded-contract/contract-version.mjs +1 -1
- package/lib/embedded-contract/execution.mjs +184 -0
- package/lib/embedded-contract/index.mjs +16 -0
- package/lib/embedded-contract/ingest.mjs +61 -7
- package/lib/hooks/_lib/output-mode.mjs +101 -0
- package/lib/hooks/session-start.mjs +13 -1
- package/lib/ingest/provider-extract.mjs +200 -0
- package/lib/ingest/strategy.mjs +95 -0
- package/lib/mcp/server.mjs +72 -4
- package/lib/mcp/tools/embedded-contract.mjs +17 -1
- package/lib/orchestration/run-store.mjs +82 -0
- package/lib/orchestration/runtime.mjs +240 -0
- package/package.json +2 -1
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
* error or a clearly-flagged best-effort fallback — never silent gibberish.
|
|
11
11
|
*
|
|
12
12
|
* Surfaces (CLI/MCP/SDK) own I/O and call this; the contract cores stay pure and
|
|
13
|
-
* receive the resolved text plus an `ingestion` metadata block to echo back.
|
|
13
|
+
* receive the resolved text plus an `ingestion` metadata block to echo back. The
|
|
14
|
+
* `ingestion` block records the resolved strategy (adapter | provider) and the
|
|
15
|
+
* provider/model when one was selected, so the extraction choice is never opaque.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import { readFileSync } from 'node:fs';
|
|
17
19
|
import { extname } from 'node:path';
|
|
18
20
|
|
|
19
21
|
import { extractDocumentTextAsync, isExtractableDocumentPath } from '../document-extract.mjs';
|
|
22
|
+
import { loadProjectConfig } from '../config/project-config.mjs';
|
|
23
|
+
import { resolveIngestStrategy } from '../ingest/strategy.mjs';
|
|
24
|
+
import { extractViaProvider } from '../ingest/provider-extract.mjs';
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* Resolve contract input from inline text or a file path.
|
|
@@ -25,9 +30,14 @@ import { extractDocumentTextAsync, isExtractableDocumentPath } from '../document
|
|
|
25
30
|
* @param {string} [opts.input] Inline text (returned as-is when provided).
|
|
26
31
|
* @param {string} [opts.filePath] Path to extract when no inline text is given.
|
|
27
32
|
* @param {number} [opts.maxChars]
|
|
33
|
+
* @param {object} [opts.config] Loaded project config (for strategy resolution).
|
|
34
|
+
* @param {Record<string,string>} [opts.env]
|
|
35
|
+
* @param {string} [opts.strategy] Explicit strategy override.
|
|
36
|
+
* @param {string} [opts.cwd] Working directory for locating project config.
|
|
37
|
+
* @param {Function} [opts.fetchImpl] Injectable fetch for provider extraction (tests).
|
|
28
38
|
* @returns {Promise<{text:string, ingestion:(object|null), error:(object|null)}>}
|
|
29
39
|
*/
|
|
30
|
-
export async function resolveInput({ input = '', filePath = '', maxChars = null } = {}) {
|
|
40
|
+
export async function resolveInput({ input = '', filePath = '', maxChars = null, config = null, env = process.env, strategy = null, cwd = process.cwd(), fetchImpl = undefined } = {}) {
|
|
31
41
|
if (input && input.trim()) {
|
|
32
42
|
return { text: input, ingestion: null, error: null };
|
|
33
43
|
}
|
|
@@ -35,6 +45,48 @@ export async function resolveInput({ input = '', filePath = '', maxChars = null
|
|
|
35
45
|
return { text: '', ingestion: null, error: null };
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
const projectConfig = config || loadProjectConfig(cwd, env).config;
|
|
49
|
+
const resolved = resolveIngestStrategy({ config: projectConfig, env, override: strategy });
|
|
50
|
+
const strategyMeta = { strategy: resolved.strategy, fallback: resolved.fallback, model: resolved.model, provider: resolved.provider };
|
|
51
|
+
|
|
52
|
+
// Provider strategy runs a concrete provider-backed extraction. On failure
|
|
53
|
+
// the fallback policy decides: `adapter` routes to the local extractor and
|
|
54
|
+
// records the fallback; any other policy surfaces the structured provider
|
|
55
|
+
// error rather than silently masking the strategy mismatch.
|
|
56
|
+
|
|
57
|
+
let providerFallback = null;
|
|
58
|
+
if (resolved.strategy === 'provider') {
|
|
59
|
+
try {
|
|
60
|
+
const extracted = await extractViaProvider({ filePath, model: resolved.model, provider: resolved.provider, maxChars, env, ...(fetchImpl ? { fetchImpl } : {}) });
|
|
61
|
+
return {
|
|
62
|
+
text: extracted.text || '',
|
|
63
|
+
ingestion: {
|
|
64
|
+
sourcePath: filePath,
|
|
65
|
+
extractionMethod: extracted.extractionMethod,
|
|
66
|
+
characters: extracted.characters,
|
|
67
|
+
truncated: extracted.truncated,
|
|
68
|
+
droppedInfo: extracted.droppedInfo || [],
|
|
69
|
+
...strategyMeta,
|
|
70
|
+
fallbackApplied: null,
|
|
71
|
+
},
|
|
72
|
+
error: null,
|
|
73
|
+
};
|
|
74
|
+
} catch (providerErr) {
|
|
75
|
+
if (resolved.fallback !== 'adapter') {
|
|
76
|
+
return {
|
|
77
|
+
text: '',
|
|
78
|
+
ingestion: { sourcePath: filePath, extractionMethod: 'provider-failed', droppedInfo: [], ...strategyMeta },
|
|
79
|
+
error: {
|
|
80
|
+
code: providerErr.code || 'PROVIDER_EXTRACTION_FAILED',
|
|
81
|
+
reason: providerErr.message,
|
|
82
|
+
remediation: providerErr.remediation || 'Set ingest.fallback to "adapter" to use the local extractor, or select the adapter strategy.',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
providerFallback = { from: 'provider', to: 'adapter', reason: providerErr.message, code: providerErr.code || 'PROVIDER_EXTRACTION_FAILED' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
38
90
|
if (isExtractableDocumentPath(filePath)) {
|
|
39
91
|
try {
|
|
40
92
|
const extracted = await extractDocumentTextAsync(filePath, { maxChars });
|
|
@@ -46,17 +98,19 @@ export async function resolveInput({ input = '', filePath = '', maxChars = null
|
|
|
46
98
|
characters: extracted.characters,
|
|
47
99
|
truncated: extracted.truncated,
|
|
48
100
|
droppedInfo: extracted.droppedInfo || [],
|
|
101
|
+
...strategyMeta,
|
|
102
|
+
fallbackApplied: providerFallback,
|
|
49
103
|
},
|
|
50
104
|
error: null,
|
|
51
105
|
};
|
|
52
106
|
} catch (err) {
|
|
53
107
|
if (err && err.code === 'ASR_REQUIRED') {
|
|
54
|
-
return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'asr-required', droppedInfo: [] }, error: { code: 'ASR_REQUIRED', reason: err.message, remediation: 'Install whisper-cli (brew install whisper-cpp) or transcribe the file before sending.' } };
|
|
108
|
+
return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'asr-required', droppedInfo: [], ...strategyMeta }, error: { code: 'ASR_REQUIRED', reason: err.message, remediation: 'Install whisper-cli (brew install whisper-cpp) or transcribe the file before sending.' } };
|
|
55
109
|
}
|
|
56
110
|
if (err && /WHISPER_BINARY_MISSING/.test(String(err.message))) {
|
|
57
|
-
return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'asr-required', droppedInfo: [] }, error: { code: 'ASR_REQUIRED', reason: err.message, remediation: 'Install whisper-cli (brew install whisper-cpp) to transcribe audio/video.' } };
|
|
111
|
+
return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'asr-required', droppedInfo: [], ...strategyMeta }, error: { code: 'ASR_REQUIRED', reason: err.message, remediation: 'Install whisper-cli (brew install whisper-cpp) to transcribe audio/video.' } };
|
|
58
112
|
}
|
|
59
|
-
return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'failed', droppedInfo: [] }, error: { code: 'EXTRACTION_FAILED', reason: err.message, remediation: 'Verify the file is readable and of a supported type.' } };
|
|
113
|
+
return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'failed', droppedInfo: [], ...strategyMeta }, error: { code: 'EXTRACTION_FAILED', reason: err.message, remediation: 'Verify the file is readable and of a supported type.' } };
|
|
60
114
|
}
|
|
61
115
|
}
|
|
62
116
|
|
|
@@ -68,10 +122,10 @@ export async function resolveInput({ input = '', filePath = '', maxChars = null
|
|
|
68
122
|
const text = readFileSync(filePath, 'utf8');
|
|
69
123
|
return {
|
|
70
124
|
text,
|
|
71
|
-
ingestion: { sourcePath: filePath, extractionMethod: 'raw-utf8', droppedInfo: [], note: `No structured extractor for ${extname(filePath) || 'this type'}; read as plain text
|
|
125
|
+
ingestion: { sourcePath: filePath, extractionMethod: 'raw-utf8', droppedInfo: [], note: `No structured extractor for ${extname(filePath) || 'this type'}; read as plain text.`, ...strategyMeta, fallbackApplied: providerFallback },
|
|
72
126
|
error: null,
|
|
73
127
|
};
|
|
74
128
|
} catch (err) {
|
|
75
|
-
return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'failed', droppedInfo: [] }, error: { code: 'FILE_UNREADABLE', reason: err.message, remediation: 'Check the path and permissions.' } };
|
|
129
|
+
return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'failed', droppedInfo: [], ...strategyMeta }, error: { code: 'FILE_UNREADABLE', reason: err.message, remediation: 'Check the path and permissions.' } };
|
|
76
130
|
}
|
|
77
131
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/hooks/_lib/output-mode.mjs — capability-aware SessionStart output routing.
|
|
3
|
+
*
|
|
4
|
+
* A SessionStart hook's stdout is injected into the model's context. In a
|
|
5
|
+
* non-interactive / SDK / `claude -p` one-shot, that verbose context pollutes a
|
|
6
|
+
* caller's machine-oriented stdout and can spill startup payloads into editors
|
|
7
|
+
* and logs. This resolver picks the channel the payload should use so stdout
|
|
8
|
+
* stays reserved for the caller's output contract while diagnostics remain
|
|
9
|
+
* recoverable.
|
|
10
|
+
*
|
|
11
|
+
* Modes (`CONSTRUCT_HOOK_OUTPUT_MODE` env or `hooks.outputMode` config, env >
|
|
12
|
+
* config > default): `stdout` injects as context (interactive default),
|
|
13
|
+
* `stderr` writes to stderr, `silent` writes only to a debug log, and `auto`
|
|
14
|
+
* resolves to stdout when interactive and silent when not.
|
|
15
|
+
*
|
|
16
|
+
* Honest limitation: Claude Code exposes no reliable in-hook signal for
|
|
17
|
+
* interactive vs print/SDK mode (`CLAUDECODE=1` is set in both; `isTTY` is false
|
|
18
|
+
* even interactively because hooks run without a controlling terminal). So
|
|
19
|
+
* `auto` detects non-interactive only from signals that ARE reliable — `CI`,
|
|
20
|
+
* `NODE_ENV=test`, and an explicit `CONSTRUCT_NONINTERACTIVE` flag — and SDK /
|
|
21
|
+
* host adapters set `CONSTRUCT_HOOK_OUTPUT_MODE` explicitly. `auto` never
|
|
22
|
+
* suppresses on an ambiguous signal, so real interactive sessions keep their
|
|
23
|
+
* rich startup context.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
|
|
30
|
+
import { resolveSetting, loadProjectConfig } from '../../config/project-config.mjs';
|
|
31
|
+
import { HOOK_OUTPUT_MODES } from '../../config/schema.mjs';
|
|
32
|
+
|
|
33
|
+
export { HOOK_OUTPUT_MODES };
|
|
34
|
+
|
|
35
|
+
function truthyFlag(value) {
|
|
36
|
+
if (value === undefined || value === null) return false;
|
|
37
|
+
const v = String(value).trim().toLowerCase();
|
|
38
|
+
return v !== '' && v !== '0' && v !== 'false' && v !== 'no';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether the invocation is non-interactive, using only reliable signals.
|
|
43
|
+
* @param {Record<string,string>} [env]
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function isNonInteractive(env = process.env) {
|
|
47
|
+
if (env.CI === 'true') return true;
|
|
48
|
+
if (env.NODE_ENV === 'test') return true;
|
|
49
|
+
if (truthyFlag(env.CONSTRUCT_NONINTERACTIVE)) return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeMode(value) {
|
|
54
|
+
const m = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
55
|
+
return HOOK_OUTPUT_MODES.includes(m) ? m : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the concrete output channel for the SessionStart payload.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [opts]
|
|
62
|
+
* @param {string} [opts.cwd]
|
|
63
|
+
* @param {Record<string,string>} [opts.env]
|
|
64
|
+
* @param {object} [opts.config] loaded project config; read from cwd when omitted
|
|
65
|
+
* @returns {{mode:('stdout'|'stderr'|'silent'), requested:string, source:string, nonInteractive:boolean}}
|
|
66
|
+
*/
|
|
67
|
+
export function resolveHookOutputMode({ cwd = process.cwd(), env = process.env, config } = {}) {
|
|
68
|
+
let cfg = config;
|
|
69
|
+
if (cfg === undefined) {
|
|
70
|
+
try { cfg = loadProjectConfig(cwd, env).config; } catch { cfg = null; }
|
|
71
|
+
}
|
|
72
|
+
const setting = resolveSetting({ config: cfg, jsonPath: 'hooks.outputMode', env, envKey: 'CONSTRUCT_HOOK_OUTPUT_MODE', defaultValue: 'auto' });
|
|
73
|
+
const requested = normalizeMode(setting.value) || 'auto';
|
|
74
|
+
const nonInteractive = isNonInteractive(env);
|
|
75
|
+
const mode = requested !== 'auto' ? requested : (nonInteractive ? 'silent' : 'stdout');
|
|
76
|
+
return { mode, requested, source: setting.source, nonInteractive };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Route the SessionStart payload to the resolved channel. `silent` preserves the
|
|
81
|
+
* payload in a debug log so diagnostics stay available without touching the
|
|
82
|
+
* caller's stdout/stderr. Returns the channel actually used.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} opts
|
|
85
|
+
* @param {string} opts.payload
|
|
86
|
+
* @param {string} opts.mode one of stdout|stderr|silent
|
|
87
|
+
* @param {string} [opts.homeDir]
|
|
88
|
+
* @param {{write:Function}} [opts.stdout]
|
|
89
|
+
* @param {{write:Function}} [opts.stderr]
|
|
90
|
+
* @returns {string} the channel used
|
|
91
|
+
*/
|
|
92
|
+
export function writeHookContext({ payload, mode, homeDir = homedir(), stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
93
|
+
if (mode === 'stdout') { stdout.write(payload); return 'stdout'; }
|
|
94
|
+
if (mode === 'stderr') { stderr.write(payload); return 'stderr'; }
|
|
95
|
+
try {
|
|
96
|
+
const dir = join(homeDir, '.cx');
|
|
97
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
98
|
+
writeFileSync(join(dir, 'session-start-last.log'), payload);
|
|
99
|
+
} catch { /* best effort — never block the session on a debug-log write */ }
|
|
100
|
+
return 'silent';
|
|
101
|
+
}
|
|
@@ -23,6 +23,7 @@ import { createSession, lastSession, buildResumeContext } from '../session-store
|
|
|
23
23
|
import { listObservations, searchObservations } from '../observation-store.mjs';
|
|
24
24
|
import { countEntities } from '../entity-store.mjs';
|
|
25
25
|
import { logHookFailure } from './_lib/log.mjs';
|
|
26
|
+
import { resolveHookOutputMode, writeHookContext } from './_lib/output-mode.mjs';
|
|
26
27
|
|
|
27
28
|
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
const CONSTRUCT_BIN = resolve(MODULE_DIR, '..', '..', 'bin', 'construct');
|
|
@@ -378,7 +379,18 @@ try {
|
|
|
378
379
|
permissionPostureNote = buildPermissionPostureLine({ cwd });
|
|
379
380
|
} catch { /* best effort */ }
|
|
380
381
|
|
|
381
|
-
|
|
382
|
+
// SessionStart context is injected via stdout. In non-interactive / SDK runs
|
|
383
|
+
// that pollutes the caller's output contract, so the resolved output mode routes
|
|
384
|
+
// the payload to stdout (interactive), stderr, or a debug log (silent).
|
|
385
|
+
|
|
386
|
+
const payload = `${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${rolesNote}${intakeReviewNote}${brokerStatusNote}${permissionPostureNote}${envCheckNote}\n${footer}${pendingNote}\n`;
|
|
387
|
+
try {
|
|
388
|
+
const { mode } = resolveHookOutputMode({ cwd, env: process.env });
|
|
389
|
+
writeHookContext({ payload, mode });
|
|
390
|
+
} catch (err) {
|
|
391
|
+
logHookFailure({ hook: 'session-start', err, phase: 'emit' });
|
|
392
|
+
process.stdout.write(payload);
|
|
393
|
+
}
|
|
382
394
|
// Auto-bootstrap the policy-engine gate. session-start has just emitted
|
|
383
395
|
// branch + recent commits + prior observations + context-state — that is
|
|
384
396
|
// already grounding; mark the session bootstrapped so the PreToolUse rule
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ingest/provider-extract.mjs — concrete provider-backed document extraction.
|
|
3
|
+
*
|
|
4
|
+
* The `provider` ingest strategy routes document understanding through the
|
|
5
|
+
* configured provider/model instead of the local binary adapters. This module
|
|
6
|
+
* is that route: it reads a file, builds a provider-native request, and returns
|
|
7
|
+
* extracted text in the same shape `extractDocumentTextAsync` produces so every
|
|
8
|
+
* caller treats adapter and provider results uniformly.
|
|
9
|
+
*
|
|
10
|
+
* Capability is honest, never silently degraded. Images and PDFs are sent as
|
|
11
|
+
* multimodal blocks (Anthropic `image`/`document`, OpenRouter `image_url`);
|
|
12
|
+
* text-class files are sent inline for faithful extraction/normalization;
|
|
13
|
+
* audio/video and Office/zip documents are NOT chat-extractable and raise a
|
|
14
|
+
* specific `PROVIDER_MEDIA_UNSUPPORTED` so the strategy's fallback policy — not
|
|
15
|
+
* a hidden code path — decides whether the local adapter handles them. Provider
|
|
16
|
+
* selection follows the resolved model (Anthropic when the provider/model is
|
|
17
|
+
* Claude-family, OpenRouter otherwise); a missing key surfaces as a structured
|
|
18
|
+
* `PROVIDER_KEY_MISSING` rather than an opaque HTTP failure.
|
|
19
|
+
*
|
|
20
|
+
* `fetchImpl` and `env` are injectable so the provider path is exercised end to
|
|
21
|
+
* end against a mock provider without a live key.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
25
|
+
import { extname, join } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
AUDIO_VIDEO_EXTS,
|
|
30
|
+
UTF8_TEXT_EXTS,
|
|
31
|
+
TRANSCRIPT_EXTS,
|
|
32
|
+
CALENDAR_EXTS,
|
|
33
|
+
} from '../document-extract.mjs';
|
|
34
|
+
|
|
35
|
+
export const PROVIDER_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
|
|
36
|
+
|
|
37
|
+
const IMAGE_MIME = {
|
|
38
|
+
'.png': 'image/png',
|
|
39
|
+
'.jpg': 'image/jpeg',
|
|
40
|
+
'.jpeg': 'image/jpeg',
|
|
41
|
+
'.gif': 'image/gif',
|
|
42
|
+
'.webp': 'image/webp',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const DEFAULT_PROMPT = 'Extract the complete textual content of this document faithfully and verbatim. Preserve headings, lists, and tables as Markdown. Do not summarize, interpret, or add commentary — output only the extracted content.';
|
|
46
|
+
|
|
47
|
+
const MAX_OUTPUT_TOKENS = 4096;
|
|
48
|
+
|
|
49
|
+
function providerError(code, reason, remediation) {
|
|
50
|
+
const err = new Error(reason);
|
|
51
|
+
err.code = code;
|
|
52
|
+
err.remediation = remediation;
|
|
53
|
+
return err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function classifyMedia(filePath) {
|
|
57
|
+
const ext = extname(filePath).toLowerCase();
|
|
58
|
+
if (PROVIDER_IMAGE_EXTS.has(ext)) return 'image';
|
|
59
|
+
if (ext === '.pdf') return 'pdf';
|
|
60
|
+
if (AUDIO_VIDEO_EXTS.has(ext)) return 'audio-video';
|
|
61
|
+
if (UTF8_TEXT_EXTS.has(ext) || TRANSCRIPT_EXTS.has(ext) || CALENDAR_EXTS.has(ext)) return 'text';
|
|
62
|
+
return 'binary-doc';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isAnthropic(provider, model) {
|
|
66
|
+
return /anthropic|claude/i.test(provider || '') || /claude/i.test(model || '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Key resolution mirrors the daemon's cheap sources (env, then the two dotenv
|
|
70
|
+
// files) without the shell-rc/1Password walk, which belongs to long-running
|
|
71
|
+
// processes rather than a per-file extraction call. Ambient dotenv discovery
|
|
72
|
+
// only augments the real process env; a caller that injects an explicit env
|
|
73
|
+
// object (embedded callers, tests) is authoritative and hermetic.
|
|
74
|
+
|
|
75
|
+
function resolveKey(varName, env, allowAmbient) {
|
|
76
|
+
if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
|
|
77
|
+
if (!allowAmbient) return null;
|
|
78
|
+
for (const file of [join(homedir(), '.construct', 'config.env'), join(homedir(), '.env')]) {
|
|
79
|
+
try {
|
|
80
|
+
if (!existsSync(file)) continue;
|
|
81
|
+
const m = readFileSync(file, 'utf8').match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
|
|
82
|
+
if (m && m[1]) return m[1].trim();
|
|
83
|
+
} catch { /* unreadable dotenv is not authoritative */ }
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function bodyExcerpt(res) {
|
|
89
|
+
try {
|
|
90
|
+
const text = await res.text();
|
|
91
|
+
return text.slice(0, 200);
|
|
92
|
+
} catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function callAnthropic({ model, apiKey, content, fetchImpl }) {
|
|
98
|
+
const res = await fetchImpl('https://api.anthropic.com/v1/messages', {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
model: model.replace(/^anthropic\//, ''),
|
|
103
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
104
|
+
messages: [{ role: 'user', content }],
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw providerError('PROVIDER_EXTRACTION_FAILED', `Anthropic extraction failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and ANTHROPIC_API_KEY, or set ingest.fallback to "adapter".');
|
|
109
|
+
}
|
|
110
|
+
const data = await res.json();
|
|
111
|
+
return (data.content || []).filter((b) => b && b.type === 'text').map((b) => b.text).join('');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function callOpenRouter({ model, apiKey, content, fetchImpl }) {
|
|
115
|
+
const res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/construct' },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
model: model.replace(/^openrouter\//, ''),
|
|
120
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
121
|
+
messages: [{ role: 'user', content }],
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
throw providerError('PROVIDER_EXTRACTION_FAILED', `OpenRouter extraction failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and OPENROUTER_API_KEY, or set ingest.fallback to "adapter".');
|
|
126
|
+
}
|
|
127
|
+
const data = await res.json();
|
|
128
|
+
return data.choices?.[0]?.message?.content || '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Extract a document's text through the configured provider/model.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} opts
|
|
135
|
+
* @param {string} opts.filePath
|
|
136
|
+
* @param {string} opts.model resolved provider model id
|
|
137
|
+
* @param {string} [opts.provider] resolved provider id (selects the API)
|
|
138
|
+
* @param {number} [opts.maxChars] truncate returned text to this length
|
|
139
|
+
* @param {Record<string,string>} [opts.env]
|
|
140
|
+
* @param {Function} [opts.fetchImpl] injectable fetch (for tests)
|
|
141
|
+
* @param {string} [opts.prompt]
|
|
142
|
+
* @returns {Promise<{text:string, extractionMethod:string, characters:number, truncated:boolean, droppedInfo:Array}>}
|
|
143
|
+
*/
|
|
144
|
+
export async function extractViaProvider({ filePath, model, provider = null, maxChars = null, env = process.env, fetchImpl = globalThis.fetch, prompt = DEFAULT_PROMPT } = {}) {
|
|
145
|
+
if (!filePath) throw providerError('PROVIDER_NO_INPUT', 'No file path supplied for provider extraction.', 'Pass a readable file path.');
|
|
146
|
+
if (!model) throw providerError('PROVIDER_MODEL_UNRESOLVED', 'Provider strategy selected but no provider model resolved.', 'Configure the model tier registry so the ingest tier resolves a model, or set ingest.fallback to "adapter".');
|
|
147
|
+
if (typeof fetchImpl !== 'function') throw providerError('PROVIDER_NO_FETCH', 'No fetch implementation available for provider extraction.', 'Run on a runtime with global fetch (Node 18+) or inject fetchImpl.');
|
|
148
|
+
|
|
149
|
+
const media = classifyMedia(filePath);
|
|
150
|
+
if (media === 'audio-video') {
|
|
151
|
+
throw providerError('PROVIDER_MEDIA_UNSUPPORTED', `Provider chat extraction does not transcribe audio/video (${extname(filePath)}).`, 'Use the adapter strategy (whisper ASR) for audio/video, or set ingest.fallback to "adapter".');
|
|
152
|
+
}
|
|
153
|
+
if (media === 'binary-doc') {
|
|
154
|
+
throw providerError('PROVIDER_MEDIA_UNSUPPORTED', `Provider chat extraction does not accept ${extname(filePath) || 'this type'} directly.`, 'Use the adapter strategy for Office/zip documents, or set ingest.fallback to "adapter".');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const anthropic = isAnthropic(provider, model);
|
|
158
|
+
const keyVar = anthropic ? 'ANTHROPIC_API_KEY' : 'OPENROUTER_API_KEY';
|
|
159
|
+
const apiKey = resolveKey(keyVar, env, env === process.env);
|
|
160
|
+
if (!apiKey) {
|
|
161
|
+
throw providerError('PROVIDER_KEY_MISSING', `No API key for ${anthropic ? 'Anthropic' : 'OpenRouter'} provider extraction.`, `Set ${keyVar}, or set ingest.fallback to "adapter".`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let text;
|
|
165
|
+
if (media === 'text') {
|
|
166
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
167
|
+
const promptText = `${prompt}\n\n---\n${raw}`;
|
|
168
|
+
text = anthropic
|
|
169
|
+
? await callAnthropic({ model, apiKey, fetchImpl, content: [{ type: 'text', text: promptText }] })
|
|
170
|
+
: await callOpenRouter({ model, apiKey, fetchImpl, content: promptText });
|
|
171
|
+
} else {
|
|
172
|
+
const base64 = readFileSync(filePath).toString('base64');
|
|
173
|
+
if (anthropic) {
|
|
174
|
+
const mediaBlock = media === 'image'
|
|
175
|
+
? { type: 'image', source: { type: 'base64', media_type: IMAGE_MIME[extname(filePath).toLowerCase()], data: base64 } }
|
|
176
|
+
: { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: base64 } };
|
|
177
|
+
text = await callAnthropic({ model, apiKey, fetchImpl, content: [mediaBlock, { type: 'text', text: prompt }] });
|
|
178
|
+
} else if (media === 'pdf') {
|
|
179
|
+
throw providerError('PROVIDER_MEDIA_UNSUPPORTED', 'PDF provider extraction requires an Anthropic-family model in this build.', 'Select an Anthropic ingest model, or set ingest.fallback to "adapter".');
|
|
180
|
+
} else {
|
|
181
|
+
const dataUrl = `data:${IMAGE_MIME[extname(filePath).toLowerCase()]};base64,${base64}`;
|
|
182
|
+
text = await callOpenRouter({ model, apiKey, fetchImpl, content: [{ type: 'text', text: prompt }, { type: 'image_url', image_url: { url: dataUrl } }] });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
text = text || '';
|
|
187
|
+
let truncated = false;
|
|
188
|
+
if (maxChars && text.length > maxChars) {
|
|
189
|
+
text = text.slice(0, maxChars);
|
|
190
|
+
truncated = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
text,
|
|
195
|
+
extractionMethod: `provider:${anthropic ? 'anthropic' : 'openrouter'}:${model}`,
|
|
196
|
+
characters: text.length,
|
|
197
|
+
truncated,
|
|
198
|
+
droppedInfo: [],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ingest/strategy.mjs — resolve the ingest extraction strategy and provider model.
|
|
3
|
+
*
|
|
4
|
+
* Construct's ingest path historically extracted documents through local binary
|
|
5
|
+
* adapters (docling/whisper/pdftotext) without surfacing that choice. This module
|
|
6
|
+
* makes the choice explicit and configurable:
|
|
7
|
+
* - `adapter` (default): the local-extractor pipeline, byte-for-byte unchanged.
|
|
8
|
+
* - `provider`: route document understanding through the configured provider/model.
|
|
9
|
+
* A `fallback` policy (`none`/`provider`/`adapter`) governs what happens on
|
|
10
|
+
* primary-path failure; the default `none` never silently masks a strategy
|
|
11
|
+
* mismatch. Resolution precedence matches the project-config convention:
|
|
12
|
+
* env > config > default. Provider/model identification reuses the embedded model
|
|
13
|
+
* resolution contract and the tier registry so no second selection surface exists.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolveSetting } from '../config/project-config.mjs';
|
|
17
|
+
import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
|
|
18
|
+
|
|
19
|
+
export const INGEST_STRATEGIES = ['adapter', 'provider'];
|
|
20
|
+
export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_INGEST_STRATEGY = 'adapter';
|
|
23
|
+
export const DEFAULT_INGEST_FALLBACK = 'none';
|
|
24
|
+
|
|
25
|
+
function coerceEnum(value, allowed) {
|
|
26
|
+
if (typeof value !== 'string') return null;
|
|
27
|
+
const lowered = value.trim().toLowerCase();
|
|
28
|
+
return allowed.includes(lowered) ? lowered : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The strategy step a document-understanding model serves under is `fast`; ingest
|
|
32
|
+
// is an extraction/understanding pass, not a reasoning workflow. The embedded
|
|
33
|
+
// resolution contract maps this tier to a concrete provider/model.
|
|
34
|
+
|
|
35
|
+
const INGEST_WORKFLOW_TYPE = 'evidence-ingest';
|
|
36
|
+
|
|
37
|
+
function resolveProviderModel({ env, registryPath }) {
|
|
38
|
+
const resolved = resolveEmbeddedModel({ workflowType: INGEST_WORKFLOW_TYPE }, { env, registryPath });
|
|
39
|
+
return {
|
|
40
|
+
model: resolved.selectedModel || null,
|
|
41
|
+
provider: resolved.selectedProvider || null,
|
|
42
|
+
resolutionSource: resolved.resolutionSource || null,
|
|
43
|
+
error: resolved.error || null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the effective ingest strategy, fallback policy, and provider model.
|
|
49
|
+
*
|
|
50
|
+
* Precedence for both strategy and fallback is env > config > explicit override
|
|
51
|
+
* > default, where an explicit `override` (a CLI flag) wins over env/config when
|
|
52
|
+
* provided. The returned `model`/`provider` are non-null only when the resolved
|
|
53
|
+
* strategy is `provider` or when `fallback` could route to a provider.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {object} [opts.config] loaded project config object
|
|
57
|
+
* @param {Record<string,string>} [opts.env]
|
|
58
|
+
* @param {string} [opts.override] explicit strategy override (CLI flag)
|
|
59
|
+
* @param {string} [opts.registryPath]
|
|
60
|
+
* @returns {{strategy:string, fallback:string, model:(string|null), provider:(string|null), modelResolution:(object|null)}}
|
|
61
|
+
*/
|
|
62
|
+
export function resolveIngestStrategy({ config = null, env = process.env, override = null, registryPath = null } = {}) {
|
|
63
|
+
const overrideStrategy = coerceEnum(override, INGEST_STRATEGIES);
|
|
64
|
+
|
|
65
|
+
const fromSettings = resolveSetting({
|
|
66
|
+
config,
|
|
67
|
+
jsonPath: 'ingest.strategy',
|
|
68
|
+
env,
|
|
69
|
+
envKey: 'CONSTRUCT_INGEST_STRATEGY',
|
|
70
|
+
defaultValue: DEFAULT_INGEST_STRATEGY,
|
|
71
|
+
});
|
|
72
|
+
const strategy = overrideStrategy
|
|
73
|
+
|| coerceEnum(fromSettings.value, INGEST_STRATEGIES)
|
|
74
|
+
|| DEFAULT_INGEST_STRATEGY;
|
|
75
|
+
|
|
76
|
+
const fallbackSetting = resolveSetting({
|
|
77
|
+
config,
|
|
78
|
+
jsonPath: 'ingest.fallback',
|
|
79
|
+
env,
|
|
80
|
+
envKey: 'CONSTRUCT_INGEST_FALLBACK',
|
|
81
|
+
defaultValue: DEFAULT_INGEST_FALLBACK,
|
|
82
|
+
});
|
|
83
|
+
const fallback = coerceEnum(fallbackSetting.value, INGEST_FALLBACKS) || DEFAULT_INGEST_FALLBACK;
|
|
84
|
+
|
|
85
|
+
const needsProviderModel = strategy === 'provider' || fallback === 'provider';
|
|
86
|
+
const modelResolution = needsProviderModel ? resolveProviderModel({ env, registryPath }) : null;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
strategy,
|
|
90
|
+
fallback,
|
|
91
|
+
model: modelResolution?.model || null,
|
|
92
|
+
provider: modelResolution?.provider || null,
|
|
93
|
+
modelResolution,
|
|
94
|
+
};
|
|
95
|
+
}
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
12
12
|
import { resolve, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { realpathSync } from 'node:fs';
|
|
15
15
|
import { loadToolkitEnv } from '../toolkit-env.mjs';
|
|
16
16
|
import { loadConstructEnv } from '../env-config.mjs';
|
|
17
|
+
import { getInstalledVersion } from '../version.mjs';
|
|
18
|
+
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
17
19
|
import { withGenAiSpan, GenAiAttrs, extractTraceContext, injectTraceContext } from '../telemetry/otel-tracer.mjs';
|
|
18
20
|
|
|
19
21
|
// Apply config.env values to process.env, letting config.env win over shell env
|
|
@@ -56,7 +58,7 @@ import {
|
|
|
56
58
|
profileCreate, profileArchive, sandboxList, learningStatus,
|
|
57
59
|
knowledgeGraphAsk,
|
|
58
60
|
} from './tools/profile.mjs';
|
|
59
|
-
import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe } from './tools/embedded-contract.mjs';
|
|
61
|
+
import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe, executionResolve } from './tools/embedded-contract.mjs';
|
|
60
62
|
|
|
61
63
|
const DEFAULT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
62
64
|
const ROOT_DIR = resolve(process.env.CX_TOOLKIT_DIR || DEFAULT_ROOT_DIR);
|
|
@@ -64,11 +66,58 @@ loadToolkitEnv(ROOT_DIR);
|
|
|
64
66
|
|
|
65
67
|
const opts = { ROOT_DIR };
|
|
66
68
|
|
|
69
|
+
// Identity an MCP host can render meaningfully: the real installed version (not a
|
|
70
|
+
// hardcoded stub), a one-line description of what the server offers (shown in the
|
|
71
|
+
// host's server-detail view), and a `construct://status` resource for live state.
|
|
72
|
+
// Without these the host shows only "<name> · OK" with an empty detail panel.
|
|
73
|
+
|
|
74
|
+
const VERSION = getInstalledVersion()?.version || 'unknown';
|
|
75
|
+
const DEPLOYMENT_MODE = getDeploymentMode(process.env, { cwd: ROOT_DIR });
|
|
76
|
+
const INSTRUCTIONS = [
|
|
77
|
+
`Construct MCP — agent orchestration for this workspace (v${VERSION}, ${DEPLOYMENT_MODE} mode).`,
|
|
78
|
+
'Tools cover: project context and diff review, skills/templates/teams, document ingestion and vector storage,',
|
|
79
|
+
'workflow orchestration and contracts, telemetry and scoring, and cross-session memory.',
|
|
80
|
+
'Read the `construct://status` resource for live state, or run `construct status` for full project health.',
|
|
81
|
+
'Start the dashboard with `construct dev`.',
|
|
82
|
+
].join(' ');
|
|
83
|
+
|
|
67
84
|
const server = new Server(
|
|
68
|
-
{ name: 'construct-mcp', version:
|
|
69
|
-
{ capabilities: { tools: {} } }
|
|
85
|
+
{ name: 'construct-mcp', version: VERSION },
|
|
86
|
+
{ capabilities: { tools: {}, resources: {} }, instructions: INSTRUCTIONS },
|
|
70
87
|
);
|
|
71
88
|
|
|
89
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
90
|
+
resources: [
|
|
91
|
+
{
|
|
92
|
+
uri: 'construct://status',
|
|
93
|
+
name: 'Construct status',
|
|
94
|
+
description: 'Live toolkit status: version, deployment mode, MCP broker, and how to get full project health.',
|
|
95
|
+
mimeType: 'application/json',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
101
|
+
if (req.params?.uri !== 'construct://status') {
|
|
102
|
+
throw new Error(`Unknown resource: ${req.params?.uri}`);
|
|
103
|
+
}
|
|
104
|
+
const payload = {
|
|
105
|
+
name: 'Construct',
|
|
106
|
+
version: VERSION,
|
|
107
|
+
deploymentMode: DEPLOYMENT_MODE,
|
|
108
|
+
mcpBroker: process.env.CONSTRUCT_MCP_BROKER === 'on' ? 'on' : 'off',
|
|
109
|
+
capabilities: [
|
|
110
|
+
'project-context', 'skills', 'templates', 'teams', 'document-ingestion',
|
|
111
|
+
'vector-storage', 'workflow', 'telemetry', 'memory',
|
|
112
|
+
],
|
|
113
|
+
fullHealth: 'run `construct status`',
|
|
114
|
+
dashboard: 'run `construct dev`, then open the printed URL',
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
contents: [{ uri: req.params.uri, mimeType: 'application/json', text: JSON.stringify(payload, null, 2) }],
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
72
121
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
73
122
|
tools: [
|
|
74
123
|
{
|
|
@@ -1109,6 +1158,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1109
1158
|
},
|
|
1110
1159
|
},
|
|
1111
1160
|
},
|
|
1161
|
+
{
|
|
1162
|
+
name: 'construct_execution_resolve',
|
|
1163
|
+
description: 'Resolve the execution-capability contract for an embedded workflow BEFORE/at workflow start: returns executionMode (construct-orchestrated | construct-prompt-only | host-direct | same-family-fallback), constructCapabilitiesActive (subset of personas/skills/workflow-routing/prompt-envelope), degraded + machine-readable degradationReason, requestedStrategy vs effectiveStrategy, and the resolved provider/model. Descriptive, not enforced: reports what Construct planned and can resolve a model for, never an observation that the host ran personas (see the semantics field). Read-only and secret-free.',
|
|
1164
|
+
inputSchema: {
|
|
1165
|
+
type: 'object',
|
|
1166
|
+
properties: {
|
|
1167
|
+
workflow_type: { type: 'string', description: 'Workflow whose orchestration plan is weighed (e.g. evidence-ingest, architecture-review). Absent ⇒ generic orchestration availability.' },
|
|
1168
|
+
requested_strategy: { type: 'string', enum: ['orchestrated', 'prompt-only', 'auto'], description: 'Desired execution strategy (default auto).' },
|
|
1169
|
+
use_construct: { type: 'boolean', description: 'false ⇒ host-direct (host runs without Construct capabilities). Default true.' },
|
|
1170
|
+
host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
|
|
1171
|
+
host_model: { type: 'string', description: 'Model the host is currently using, for model resolution.' },
|
|
1172
|
+
host_provider: { type: 'string', description: 'Provider family the host uses, when no host_model is given.' },
|
|
1173
|
+
requested_tier: { type: 'string', enum: ['reasoning', 'standard', 'fast'], description: 'Desired model tier; overrides the workflow-type hint.' },
|
|
1174
|
+
capabilities: { type: 'array', items: { type: 'string' }, description: 'Optional required capabilities; unverifiable ones are returned as warnings.' },
|
|
1175
|
+
allow_cross_provider_fallback: { type: 'boolean', description: 'Permit model fallback outside the host provider family (default false).' },
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1112
1179
|
],
|
|
1113
1180
|
}));
|
|
1114
1181
|
|
|
@@ -1190,6 +1257,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1190
1257
|
else if (name === 'triage_recommend') result = await triageRecommend(args);
|
|
1191
1258
|
else if (name === 'workflow_invoke') result = await workflowInvoke(args);
|
|
1192
1259
|
else if (name === 'capability_describe') result = capabilityDescribe(args);
|
|
1260
|
+
else if (name === 'construct_execution_resolve') result = executionResolve(args);
|
|
1193
1261
|
else result = { error: `Unknown tool: ${name}` };
|
|
1194
1262
|
} catch (err) {
|
|
1195
1263
|
result = { error: err.message ?? String(err) };
|