@geraldmaron/construct 1.0.19 → 1.0.21
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 +49 -1
- package/lib/decisions/enforced-baseline.json +3 -0
- package/lib/document-ingest.mjs +80 -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/embedded-contract/triage.mjs +24 -2
- package/lib/embedded-contract/workflow-invoke.mjs +21 -0
- 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 +127 -0
- package/lib/mcp/server.mjs +72 -4
- package/lib/mcp/tools/embedded-contract.mjs +17 -1
- package/lib/orchestration/run-store-postgres.mjs +85 -0
- package/lib/orchestration/run-store-sqlite.mjs +122 -0
- package/lib/orchestration/run-store.mjs +82 -0
- package/lib/orchestration/runtime.mjs +317 -0
- package/lib/orchestration/store.mjs +102 -0
- package/lib/orchestration/worker.mjs +167 -0
- package/lib/parity.mjs +35 -3
- 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
|
}
|
|
@@ -16,6 +16,7 @@ import { classifyRdIntake } from '../intake/classify.mjs';
|
|
|
16
16
|
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
17
17
|
import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
|
|
18
18
|
import { workflowTypeForIntake } from './workflow-defs.mjs';
|
|
19
|
+
import { resolveExecution } from './execution.mjs';
|
|
19
20
|
|
|
20
21
|
const CONFIDENCE_FLOOR = 0.6;
|
|
21
22
|
|
|
@@ -46,7 +47,7 @@ function buildRiskFactors(triage) {
|
|
|
46
47
|
* @returns {object}
|
|
47
48
|
*/
|
|
48
49
|
export function recommendPlan(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
49
|
-
const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null } = request;
|
|
50
|
+
const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null, host, hostModel, hostProvider, constructStrategy = 'auto' } = request;
|
|
50
51
|
const warnings = [];
|
|
51
52
|
|
|
52
53
|
// Surface extraction provenance and flag low-yield/truncated inputs so a
|
|
@@ -109,6 +110,26 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
|
|
|
109
110
|
if (canExecute) nextStepOptions.push({ action: 'invoke-workflow', description: 'Invoke the recommended role chain as an embedded workflow.' });
|
|
110
111
|
if (!canExecute) nextStepOptions.push({ action: 'clarify', description: 'Request more detail; classification confidence is low or unknown.' });
|
|
111
112
|
|
|
113
|
+
const suggestedWorkflowType = workflowTypeForIntake(triage.intakeType);
|
|
114
|
+
|
|
115
|
+
// Execution preview: forecast the executionMode an invocation of the suggested
|
|
116
|
+
// workflow would resolve to, but only when the host supplies context — absent
|
|
117
|
+
// it, forcing a model resolution on every triage call would be wasteful and
|
|
118
|
+
// the preview would be uninformative. null keeps the field parity-stable.
|
|
119
|
+
let execution = null;
|
|
120
|
+
if (suggestedWorkflowType && (hostModel || hostProvider || (constructStrategy && constructStrategy !== 'auto'))) {
|
|
121
|
+
const e = resolveExecution({ workflowType: suggestedWorkflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider }, { env, cwd });
|
|
122
|
+
execution = {
|
|
123
|
+
executionMode: e.executionMode,
|
|
124
|
+
effectiveStrategy: e.effectiveStrategy,
|
|
125
|
+
requestedStrategy: e.requestedStrategy,
|
|
126
|
+
constructCapabilitiesActive: e.constructCapabilitiesActive,
|
|
127
|
+
degraded: e.degraded,
|
|
128
|
+
degradationReason: e.degradationReason,
|
|
129
|
+
semantics: e.semantics,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
return {
|
|
113
134
|
classification: { intakeType: triage.intakeType, rdStage: triage.rdStage },
|
|
114
135
|
ingestion,
|
|
@@ -117,7 +138,8 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
|
|
|
117
138
|
primaryOwner,
|
|
118
139
|
recommendedAction: triage.recommendedAction,
|
|
119
140
|
recommendedChain: chain,
|
|
120
|
-
suggestedWorkflowType
|
|
141
|
+
suggestedWorkflowType,
|
|
142
|
+
execution,
|
|
121
143
|
roleRationale: rationale,
|
|
122
144
|
suggestedSkills,
|
|
123
145
|
evidenceRequirements,
|
|
@@ -26,6 +26,7 @@ import { recordApprovalRequest } from '../roles/approval-surface.mjs';
|
|
|
26
26
|
import { getWorkflowDef, WORKFLOW_TYPES } from './workflow-defs.mjs';
|
|
27
27
|
import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
|
|
28
28
|
import { resolveEmbeddedModel } from './model-resolve.mjs';
|
|
29
|
+
import { resolveExecution } from './execution.mjs';
|
|
29
30
|
import { resolveWriteGate, newTraceId, DEFAULT_APPROVAL_MODE } from './audit.mjs';
|
|
30
31
|
|
|
31
32
|
const VALID_STRATEGIES = ['auto', 'explicit', 'constrained'];
|
|
@@ -91,6 +92,7 @@ function errorResult({ workflowId, traceId, approvalMode, code, message, warning
|
|
|
91
92
|
* @param {string} [request.host]
|
|
92
93
|
* @param {string} [request.hostModel]
|
|
93
94
|
* @param {string} [request.hostProvider]
|
|
95
|
+
* @param {string} [request.constructStrategy] orchestrated | prompt-only | auto (execution mode)
|
|
94
96
|
* @param {object} [opts] { env, cwd }
|
|
95
97
|
* @returns {Promise<object>}
|
|
96
98
|
*/
|
|
@@ -98,6 +100,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
98
100
|
const {
|
|
99
101
|
workflowType, context = {}, roleStrategy = 'auto', requestedRoles,
|
|
100
102
|
approvalMode, trace = true, host, hostModel, hostProvider, ingestion = null,
|
|
103
|
+
constructStrategy = 'auto',
|
|
101
104
|
} = request;
|
|
102
105
|
const warnings = [];
|
|
103
106
|
if (ingestion) {
|
|
@@ -129,6 +132,23 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
129
132
|
);
|
|
130
133
|
for (const w of modelWarnings) warnings.push(`model-resolution: ${w}`);
|
|
131
134
|
|
|
135
|
+
// The execution-capability contract reports the PLANNED executionMode for this
|
|
136
|
+
// run (descriptive, not enforced — ADR-0019). Construct returns a plan; the
|
|
137
|
+
// host runtime executes it, so this never claims observed specialist execution.
|
|
138
|
+
const executionData = resolveExecution(
|
|
139
|
+
{ workflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider, requestedTier: def.tier },
|
|
140
|
+
{ env, cwd },
|
|
141
|
+
);
|
|
142
|
+
const execution = {
|
|
143
|
+
executionMode: executionData.executionMode,
|
|
144
|
+
effectiveStrategy: executionData.effectiveStrategy,
|
|
145
|
+
requestedStrategy: executionData.requestedStrategy,
|
|
146
|
+
constructCapabilitiesActive: executionData.constructCapabilitiesActive,
|
|
147
|
+
degraded: executionData.degraded,
|
|
148
|
+
degradationReason: executionData.degradationReason,
|
|
149
|
+
semantics: executionData.semantics,
|
|
150
|
+
};
|
|
151
|
+
|
|
132
152
|
const primaryOwner = selectedRoles[0];
|
|
133
153
|
const facts = contractFacts(primaryOwner);
|
|
134
154
|
const skillsApplied = skillsForChain(selectedRoles, map);
|
|
@@ -204,6 +224,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
204
224
|
roleRationale: rationale,
|
|
205
225
|
skillsApplied,
|
|
206
226
|
modelResolution,
|
|
227
|
+
execution,
|
|
207
228
|
outputs,
|
|
208
229
|
recommendations,
|
|
209
230
|
evidence: { requirements: facts.evidenceRequirements, satisfied, missing: missingEvidence, traceId },
|
|
@@ -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
|
+
}
|