@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.
@@ -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: workflowTypeForIntake(triage.intakeType),
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
- process.stdout.write(`${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${rolesNote}${intakeReviewNote}${brokerStatusNote}${permissionPostureNote}${envCheckNote}\n${footer}${pendingNote}\n`);
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
+ }