@geraldmaron/construct 1.0.14 → 1.0.15
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 +6 -0
- package/bin/construct +3 -3
- package/commands/build/feature.md +0 -6
- package/commands/build/fix.md +0 -6
- package/commands/design/access.md +0 -6
- package/commands/design/flow.md +0 -6
- package/commands/design/ui.md +0 -6
- package/commands/measure/experiment.md +0 -6
- package/commands/measure/metrics.md +0 -6
- package/commands/measure/results.md +0 -6
- package/commands/plan/api.md +0 -6
- package/commands/plan/challenge.md +0 -6
- package/commands/plan/decide.md +0 -6
- package/commands/plan/feature.md +0 -7
- package/commands/plan/requirements.md +0 -6
- package/commands/remember/context.md +0 -6
- package/commands/remember/handoff.md +0 -6
- package/commands/remember/runbook.md +0 -6
- package/commands/review/code.md +0 -6
- package/commands/review/quality.md +0 -6
- package/commands/review/security.md +0 -6
- package/commands/ship/ready.md +0 -6
- package/commands/ship/release.md +0 -6
- package/commands/ship/status.md +0 -6
- package/commands/understand/docs.md +0 -6
- package/commands/understand/research.md +0 -6
- package/commands/understand/this.md +0 -6
- package/commands/understand/why.md +0 -6
- package/commands/work/clean.md +0 -6
- package/commands/work/drive.md +0 -6
- package/commands/work/optimize-prompts.md +0 -6
- package/commands/work/parallel-review.md +0 -6
- package/lib/beads-client.mjs +19 -1
- package/lib/comment-lint.mjs +5 -1
- package/lib/context-state.mjs +15 -3
- package/lib/contracts/validate.mjs +54 -10
- package/lib/contracts/violation-log.mjs +154 -0
- package/lib/document-extract/docling-client.mjs +114 -0
- package/lib/document-extract/docling-sidecar.py +122 -0
- package/lib/document-extract/whisper-client.mjs +79 -0
- package/lib/document-extract.mjs +73 -8
- package/lib/document-ingest.mjs +26 -4
- package/lib/embed/daemon.mjs +6 -1
- package/lib/flavors/loader.mjs +1 -1
- package/lib/handoffs/contract.mjs +2 -2
- package/lib/hooks/session-start.mjs +1 -1
- package/lib/ingest/chunker.mjs +94 -0
- package/lib/ingest/pipeline.mjs +53 -0
- package/lib/ingest/store.mjs +82 -0
- package/lib/knowledge/search.mjs +12 -0
- package/lib/mcp/server.mjs +142 -1
- package/lib/mcp/tools/skills.mjs +6 -3
- package/lib/mcp/tools/workflow.mjs +6 -2
- package/lib/observation-store.mjs +14 -5
- package/lib/ollama-manager.mjs +32 -28
- package/lib/prompt-composer.js +11 -1
- package/lib/runtime/uv-bootstrap.mjs +129 -0
- package/lib/runtime/whisper-bootstrap.mjs +102 -0
- package/lib/server/index.mjs +22 -1
- package/lib/server/static/index.html +1 -1
- package/lib/specialists/postconditions.mjs +1 -1
- package/lib/sync/skill-frontmatter.mjs +113 -21
- package/lib/tracking-surfaces.mjs +2 -0
- package/lib/validators/skills.mjs +86 -54
- package/package.json +5 -5
- package/personas/construct.md +4 -7
- package/platforms/claude/settings.template.json +1 -1
- package/rules/common/beads-hygiene.md +3 -9
- package/rules/common/code-review.md +3 -6
- package/rules/common/coding-style.md +3 -6
- package/rules/common/comments.md +3 -7
- package/rules/common/commit-approval.md +3 -6
- package/rules/common/cx-agent-routing.md +3 -7
- package/rules/common/cx-skill-routing.md +3 -3
- package/rules/common/doc-ownership.md +3 -8
- package/rules/common/efficiency.md +3 -8
- package/rules/common/framing.md +3 -8
- package/rules/common/git-workflow.md +3 -5
- package/rules/common/no-fabrication.md +4 -12
- package/rules/common/patterns.md +3 -5
- package/rules/common/release-gates.md +3 -8
- package/rules/common/research.md +3 -8
- package/rules/common/review-before-change.md +3 -9
- package/rules/common/security.md +3 -6
- package/rules/common/skill-composition.md +3 -7
- package/rules/common/testing.md +3 -6
- package/rules/golang/coding-style.md +1 -5
- package/rules/golang/hooks.md +1 -5
- package/rules/golang/patterns.md +1 -5
- package/rules/golang/security.md +1 -5
- package/rules/golang/testing.md +1 -5
- package/rules/python/coding-style.md +1 -5
- package/rules/python/hooks.md +1 -5
- package/rules/python/patterns.md +1 -5
- package/rules/python/security.md +1 -5
- package/rules/python/testing.md +1 -5
- package/rules/swift/coding-style.md +1 -5
- package/rules/swift/hooks.md +1 -5
- package/rules/swift/patterns.md +1 -5
- package/rules/swift/security.md +1 -5
- package/rules/swift/testing.md +1 -5
- package/rules/typescript/coding-style.md +1 -5
- package/rules/typescript/hooks.md +1 -5
- package/rules/typescript/patterns.md +1 -5
- package/rules/typescript/security.md +1 -5
- package/rules/typescript/testing.md +1 -5
- package/rules/web/coding-style.md +3 -5
- package/rules/web/design-quality.md +3 -5
- package/rules/web/hooks.md +3 -5
- package/rules/web/patterns.md +3 -5
- package/rules/web/performance.md +3 -5
- package/rules/web/security.md +3 -5
- package/rules/web/testing.md +3 -5
- package/skills/ai/agent-dev.md +4 -5
- package/skills/ai/llm-security.md +4 -5
- package/skills/ai/ml-ops.md +4 -5
- package/skills/ai/orchestration-workflow.md +4 -5
- package/skills/ai/prompt-and-eval.md +4 -5
- package/skills/ai/prompt-optimizer.md +4 -6
- package/skills/ai/rag-system.md +4 -5
- package/skills/architecture/api-design.md +4 -5
- package/skills/architecture/caching.md +4 -5
- package/skills/architecture/cloud-native.md +4 -5
- package/skills/architecture/message-queue.md +4 -5
- package/skills/architecture/security-arch.md +4 -5
- package/skills/compliance/ai-disclosure.md +4 -5
- package/skills/compliance/data-privacy.md +4 -5
- package/skills/compliance/license-audit.md +4 -5
- package/skills/compliance/regulatory-review.md +4 -5
- package/skills/development/cpp.md +4 -5
- package/skills/development/go.md +4 -5
- package/skills/development/java.md +4 -5
- package/skills/development/kotlin.md +4 -5
- package/skills/development/mobile-crossplatform.md +4 -5
- package/skills/development/python.md +4 -5
- package/skills/development/rust.md +4 -5
- package/skills/development/shell.md +4 -5
- package/skills/development/swift.md +4 -5
- package/skills/development/typescript.md +4 -5
- package/skills/devops/ci-cd.md +4 -5
- package/skills/devops/containerization.md +4 -5
- package/skills/devops/cost-optimization.md +4 -5
- package/skills/devops/data-engineering.md +4 -5
- package/skills/devops/database.md +4 -5
- package/skills/devops/dependency-management.md +4 -5
- package/skills/devops/devsecops.md +4 -5
- package/skills/devops/git-workflow.md +4 -5
- package/skills/devops/incident-response.md +4 -5
- package/skills/devops/monorepo.md +4 -5
- package/skills/devops/observability.md +4 -5
- package/skills/devops/performance.md +4 -5
- package/skills/devops/testing.md +4 -5
- package/skills/docs/adr-workflow.md +4 -7
- package/skills/docs/backlog-proposal-workflow.md +4 -3
- package/skills/docs/customer-profile-workflow.md +4 -3
- package/skills/docs/document-ingest-workflow.md +4 -3
- package/skills/docs/evidence-ingest-workflow.md +4 -3
- package/skills/docs/init-docs.md +4 -5
- package/skills/docs/init-project.md +4 -5
- package/skills/docs/prd-workflow.md +4 -7
- package/skills/docs/prfaq-workflow.md +4 -3
- package/skills/docs/product-intelligence-review.md +4 -3
- package/skills/docs/product-intelligence-workflow.md +4 -6
- package/skills/docs/product-signal-workflow.md +4 -5
- package/skills/docs/research-workflow.md +4 -5
- package/skills/docs/runbook-workflow.md +4 -5
- package/skills/docs/strategy-workflow.md +4 -5
- package/skills/exploration/dependency-graph-reading.md +4 -7
- package/skills/exploration/repo-map.md +4 -5
- package/skills/exploration/tracer-bullet-method.md +4 -7
- package/skills/exploration/unknown-codebase-onboarding.md +4 -7
- package/skills/frameworks/django.md +4 -5
- package/skills/frameworks/nextjs.md +4 -5
- package/skills/frameworks/react.md +4 -5
- package/skills/frameworks/spring-boot.md +4 -5
- package/skills/frontend-design/accessibility.md +4 -5
- package/skills/frontend-design/component-patterns.md +4 -5
- package/skills/frontend-design/engineering.md +4 -5
- package/skills/frontend-design/state-management.md +4 -5
- package/skills/frontend-design/ui-aesthetics.md +4 -5
- package/skills/frontend-design/ux-principles.md +4 -5
- package/skills/operating/change-management.md +4 -8
- package/skills/operating/incident-response.md +4 -8
- package/skills/operating/oncall-rotation.md +4 -7
- package/skills/operating/orchestration-reference.md +4 -10
- package/skills/quality-gates/review-work.md +4 -5
- package/skills/quality-gates/verify-change.md +4 -5
- package/skills/quality-gates/verify-module.md +4 -5
- package/skills/quality-gates/verify-quality.md +4 -5
- package/skills/quality-gates/verify-security.md +4 -5
- package/skills/roles/architect.ai-systems.md +6 -9
- package/skills/roles/architect.data.md +6 -9
- package/skills/roles/architect.enterprise.md +6 -9
- package/skills/roles/architect.integration.md +6 -9
- package/skills/roles/architect.md +7 -14
- package/skills/roles/architect.platform.md +6 -9
- package/skills/roles/data-analyst.experiment.md +6 -9
- package/skills/roles/data-analyst.md +6 -9
- package/skills/roles/data-analyst.product-intelligence.md +7 -9
- package/skills/roles/data-analyst.product.md +6 -9
- package/skills/roles/data-analyst.telemetry.md +7 -9
- package/skills/roles/data-engineer.pipeline.md +6 -9
- package/skills/roles/data-engineer.vector-retrieval.md +7 -9
- package/skills/roles/data-engineer.warehouse.md +6 -9
- package/skills/roles/debugger.md +6 -9
- package/skills/roles/designer.accessibility.md +6 -9
- package/skills/roles/designer.md +7 -9
- package/skills/roles/engineer.ai.md +6 -9
- package/skills/roles/engineer.data.md +6 -9
- package/skills/roles/engineer.md +9 -9
- package/skills/roles/engineer.platform.md +6 -9
- package/skills/roles/operator.docs.md +6 -9
- package/skills/roles/operator.md +9 -9
- package/skills/roles/operator.release.md +6 -9
- package/skills/roles/operator.sre.md +6 -9
- package/skills/roles/orchestrator.md +6 -9
- package/skills/roles/product-manager.ai-product.md +6 -9
- package/skills/roles/product-manager.business-strategy.md +6 -9
- package/skills/roles/product-manager.enterprise.md +6 -9
- package/skills/roles/product-manager.growth.md +7 -9
- package/skills/roles/product-manager.md +7 -9
- package/skills/roles/product-manager.platform.md +6 -9
- package/skills/roles/product-manager.product.md +6 -9
- package/skills/roles/qa.ai-eval.md +8 -9
- package/skills/roles/qa.api-contract.md +7 -9
- package/skills/roles/qa.data-pipeline.md +7 -9
- package/skills/roles/qa.md +7 -9
- package/skills/roles/qa.test-automation.md +6 -9
- package/skills/roles/qa.web-ui.md +7 -9
- package/skills/roles/researcher.explorer.md +6 -9
- package/skills/roles/researcher.md +8 -9
- package/skills/roles/researcher.ux.md +6 -9
- package/skills/roles/reviewer.devil-advocate.md +6 -9
- package/skills/roles/reviewer.evaluator.md +6 -9
- package/skills/roles/reviewer.md +9 -9
- package/skills/roles/reviewer.trace.md +6 -9
- package/skills/roles/security.ai.md +7 -9
- package/skills/roles/security.appsec.md +6 -9
- package/skills/roles/security.cloud.md +6 -9
- package/skills/roles/security.legal-compliance.md +6 -9
- package/skills/roles/security.md +7 -9
- package/skills/roles/security.privacy.md +7 -9
- package/skills/roles/security.supply-chain.md +7 -9
- package/skills/security/blue-team.md +4 -5
- package/skills/security/code-audit.md +4 -5
- package/skills/security/pentest.md +4 -5
- package/skills/security/red-team.md +4 -5
- package/skills/security/threat-intel.md +4 -5
- package/skills/security/vuln-research.md +4 -5
- package/skills/strategy/competitive-landscape.md +4 -8
- package/skills/strategy/market-research-methods.md +4 -8
- package/skills/strategy/narrative-arc.md +4 -8
- package/skills/strategy/pricing-positioning.md +4 -8
- package/skills/utility/clean-code.md +4 -5
- package/specialists/policy-inventory.json +14 -0
- package/lib/specialist-contracts-enforce.mjs +0 -172
- package/rules/common/agents.md +0 -28
- package/rules/common/development-workflow.md +0 -32
- package/rules/common/performance.md +0 -55
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
import { existsSync, readFileSync } from 'node:fs';
|
|
20
20
|
import { join, dirname, resolve } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { POSTCONDITIONS, validateBinaryPostconditions } from '../specialists/postconditions.mjs';
|
|
23
|
+
import { logViolation } from './violation-log.mjs';
|
|
22
24
|
|
|
23
25
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
24
26
|
const CONTRACTS_PATH = join(REPO_ROOT, 'specialists', 'contracts.json');
|
|
@@ -145,28 +147,62 @@ export function findContract({ producer, consumer, id, contractsPath = CONTRACTS
|
|
|
145
147
|
* { ok: true, contract }
|
|
146
148
|
* { ok: false, status: 'BLOCKED_CONTRACT', errors[], contract }
|
|
147
149
|
*
|
|
148
|
-
* Enforcement defaults to
|
|
149
|
-
*
|
|
150
|
-
*
|
|
150
|
+
* Enforcement defaults to `block` (CISA Secure-by-Design pledge —
|
|
151
|
+
* strong-default configurations). Callers can pass `enforcement: 'warn'`
|
|
152
|
+
* explicitly per-call when they genuinely need advisory mode.
|
|
153
|
+
*
|
|
154
|
+
* Binary postconditions are enforced when the producer has a rule table in
|
|
155
|
+
* `lib/specialists/postconditions.mjs`. The check is self-enforcing: if a
|
|
156
|
+
* producer has rules and the caller does not pass a `packet`, the call is
|
|
157
|
+
* itself a contract violation (MCP best-practice 2025 — no silent skip on
|
|
158
|
+
* tool-argument omission).
|
|
151
159
|
*/
|
|
152
160
|
export function validateHandoff({
|
|
153
161
|
producer,
|
|
154
162
|
consumer,
|
|
155
163
|
id,
|
|
156
164
|
artifact,
|
|
165
|
+
packet,
|
|
157
166
|
contractsPath = CONTRACTS_PATH,
|
|
158
167
|
repoRoot = REPO_ROOT,
|
|
159
|
-
enforcement =
|
|
168
|
+
enforcement = 'block',
|
|
160
169
|
}) {
|
|
170
|
+
const errors = [];
|
|
171
|
+
const postconditionFailures = [];
|
|
172
|
+
|
|
173
|
+
// Binary postconditions are producer-bound invariants that fire regardless
|
|
174
|
+
// of whether the producer→consumer contract is registered in contracts.json.
|
|
175
|
+
// A producer with rules cannot have its packet validation skipped by
|
|
176
|
+
// claiming an unregistered consumer.
|
|
177
|
+
const hasBinaryRules = Array.isArray(POSTCONDITIONS[producer]) && POSTCONDITIONS[producer].length > 0;
|
|
178
|
+
if (hasBinaryRules) {
|
|
179
|
+
if (packet === undefined || packet === null) {
|
|
180
|
+
errors.push(`producer '${producer}' has binary postconditions; validateHandoff must be called with a packet argument`);
|
|
181
|
+
} else {
|
|
182
|
+
const binary = validateBinaryPostconditions(producer, packet);
|
|
183
|
+
for (const failure of binary.failures) {
|
|
184
|
+
errors.push(`[${failure.id}] ${failure.reason}`);
|
|
185
|
+
postconditionFailures.push(failure);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
161
190
|
const contract = findContract({ producer, consumer, id, contractsPath });
|
|
162
191
|
if (!contract) {
|
|
192
|
+
if (errors.length > 0) {
|
|
193
|
+
logViolation(`${producer}->${consumer}`, 'output', errors, packet ?? artifact, {
|
|
194
|
+
verdict: postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION',
|
|
195
|
+
postconditionFailures,
|
|
196
|
+
});
|
|
197
|
+
return enforcement === 'block'
|
|
198
|
+
? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`, ...errors], contract: null }
|
|
199
|
+
: { ok: true, warnings: [`no contract found for ${producer}→${consumer}`, ...errors], contract: null };
|
|
200
|
+
}
|
|
163
201
|
return enforcement === 'block'
|
|
164
202
|
? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`], contract: null }
|
|
165
203
|
: { ok: true, warnings: [`no contract found for ${producer}→${consumer}`], contract: null };
|
|
166
204
|
}
|
|
167
205
|
|
|
168
|
-
const errors = [];
|
|
169
|
-
|
|
170
206
|
// A handoff carries a producer's output, which is the consumer's input. The
|
|
171
207
|
// input contract is what the consumer expects to receive; check mustContain
|
|
172
208
|
// against that first. Output.schema validation only applies when the artifact
|
|
@@ -193,18 +229,26 @@ export function validateHandoff({
|
|
|
193
229
|
}
|
|
194
230
|
}
|
|
195
231
|
|
|
196
|
-
//
|
|
197
|
-
// handoff envelope declares `artifactPath`. Resolved relative to repoRoot.
|
|
232
|
+
// Disk-artifact postconditions: frontmatter, sections, citations.
|
|
198
233
|
const declaredArtifactPath = artifact?.artifactPath || artifact?.output?.artifactPath;
|
|
199
234
|
if (declaredArtifactPath) {
|
|
200
235
|
const absPath = isAbsolutePath(declaredArtifactPath)
|
|
201
236
|
? declaredArtifactPath
|
|
202
237
|
: join(repoRoot, declaredArtifactPath);
|
|
203
|
-
const pcErrors =
|
|
238
|
+
const pcErrors = validateArtifactPostconditions({ contract, artifactPath: absPath });
|
|
204
239
|
for (const e of pcErrors) errors.push(e);
|
|
205
240
|
}
|
|
206
241
|
|
|
207
242
|
if (errors.length === 0) return { ok: true, contract };
|
|
243
|
+
|
|
244
|
+
// Persist to the chain-hashed violation log so `construct doctor` and
|
|
245
|
+
// forensic replay see the failure. Best-effort; logging never throws.
|
|
246
|
+
const verdict = postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION';
|
|
247
|
+
logViolation(contract.id, 'output', errors, packet ?? artifact, {
|
|
248
|
+
verdict,
|
|
249
|
+
postconditionFailures,
|
|
250
|
+
});
|
|
251
|
+
|
|
208
252
|
if (enforcement === 'block') return { ok: false, status: 'BLOCKED_CONTRACT', errors, contract };
|
|
209
253
|
return { ok: true, warnings: errors, contract };
|
|
210
254
|
}
|
|
@@ -272,7 +316,7 @@ function claimsHaveCitations(body) {
|
|
|
272
316
|
* disk. Postconditions can be strings (descriptive, ignored here) or objects
|
|
273
317
|
* with { id, check, field?, section? }. Returns an array of error strings.
|
|
274
318
|
*/
|
|
275
|
-
export function
|
|
319
|
+
export function validateArtifactPostconditions({ contract, artifactPath }) {
|
|
276
320
|
if (!contract?.postconditions?.length) return [];
|
|
277
321
|
if (!artifactPath || !existsSync(artifactPath)) {
|
|
278
322
|
const structured = contract.postconditions.filter((p) => typeof p === 'object');
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/contracts/violation-log.mjs — tamper-evident append-only log for
|
|
3
|
+
* specialist-contract violations.
|
|
4
|
+
*
|
|
5
|
+
* Each record carries:
|
|
6
|
+
* ts — ISO timestamp
|
|
7
|
+
* sequence — monotonic per-log counter, gap-detects truncation
|
|
8
|
+
* prev_line_hash — sha256 of the prior line, gap-detects modification
|
|
9
|
+
* agent — last-known active agent (from ~/.cx/last-agent.json)
|
|
10
|
+
* contractId — the contract that fired the violation
|
|
11
|
+
* direction — 'input' | 'output'
|
|
12
|
+
* missing — field names that failed shape validation
|
|
13
|
+
* packet_keys — Object.keys(packet) when packet is an object
|
|
14
|
+
* verdict — 'CONTRACT_VIOLATION' (default) or 'BLOCKED_CONTRACT'
|
|
15
|
+
* (binary postcondition failures)
|
|
16
|
+
* postconditionFailures — [{id, reason}] when verdict is BLOCKED_CONTRACT
|
|
17
|
+
*
|
|
18
|
+
* The hash chain spans rotation: when the active file is empty (post-rotate),
|
|
19
|
+
* the tail is read from the most recent segment (gzipped or plain) via
|
|
20
|
+
* readLastLineAcrossSegments. The sequence counter follows the same path.
|
|
21
|
+
*
|
|
22
|
+
* Single owner of the file. All readers go through recentViolations or
|
|
23
|
+
* verifyChain; all writers go through logViolation.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
27
|
+
import { join, dirname } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
|
|
31
|
+
import { resolveProjectScopedPath } from '../project-root.mjs';
|
|
32
|
+
|
|
33
|
+
const CX_DIR = join(homedir(), '.cx');
|
|
34
|
+
const LAST_AGENT = join(CX_DIR, 'last-agent.json');
|
|
35
|
+
|
|
36
|
+
// contract-violations are PROJECT-SCOPED: resolves to
|
|
37
|
+
// <project>/.cx/contract-violations.jsonl when inside a project, falling
|
|
38
|
+
// back to ~/.cx for standalone invocations. Resolved on every call so
|
|
39
|
+
// cwd/HOME changes inside the same process (tests, harness reuse) route
|
|
40
|
+
// correctly.
|
|
41
|
+
|
|
42
|
+
function logFile() {
|
|
43
|
+
return resolveProjectScopedPath('contract-violations.jsonl', { ensureDir: false });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sha256(input) { return createHash('sha256').update(input).digest('hex'); }
|
|
47
|
+
|
|
48
|
+
function readLastAgent() {
|
|
49
|
+
try { return JSON.parse(readFileSync(LAST_AGENT, 'utf8'))?.agent || 'construct'; }
|
|
50
|
+
catch { return 'construct'; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readTailRecord() {
|
|
54
|
+
const file = logFile();
|
|
55
|
+
const lastLine = readLastLineAcrossSegments(file);
|
|
56
|
+
if (!lastLine) return null;
|
|
57
|
+
try { return JSON.parse(lastLine); }
|
|
58
|
+
catch { return null; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readPrevLineHash() {
|
|
62
|
+
const file = logFile();
|
|
63
|
+
const lastLine = readLastLineAcrossSegments(file);
|
|
64
|
+
return lastLine ? sha256(lastLine) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function nextSequence() {
|
|
68
|
+
const tail = readTailRecord();
|
|
69
|
+
const prior = Number.isInteger(tail?.sequence) ? tail.sequence : 0;
|
|
70
|
+
return prior + 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Append a violation record. Best-effort: file I/O failures are swallowed
|
|
75
|
+
* so logging never crashes the caller.
|
|
76
|
+
*/
|
|
77
|
+
export function logViolation(contractId, direction, missing, packet, extra = {}) {
|
|
78
|
+
try {
|
|
79
|
+
const file = logFile();
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
81
|
+
const record = {
|
|
82
|
+
ts: new Date().toISOString(),
|
|
83
|
+
sequence: nextSequence(),
|
|
84
|
+
agent: readLastAgent(),
|
|
85
|
+
contractId,
|
|
86
|
+
direction,
|
|
87
|
+
missing,
|
|
88
|
+
packet_keys: packet && typeof packet === 'object' ? Object.keys(packet) : null,
|
|
89
|
+
prev_line_hash: readPrevLineHash(),
|
|
90
|
+
...extra,
|
|
91
|
+
};
|
|
92
|
+
appendBounded('contract-violations', file, JSON.stringify(record) + '\n');
|
|
93
|
+
} catch { /* logging is best-effort */ }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read recent violations from the active log segment. Returns oldest-first.
|
|
98
|
+
* Rotated segments are intentionally not included — this is the doctor
|
|
99
|
+
* surface, scoped to the active window.
|
|
100
|
+
*/
|
|
101
|
+
export function recentViolations({ windowMs = 24 * 60 * 60 * 1000 } = {}) {
|
|
102
|
+
const file = logFile();
|
|
103
|
+
if (!existsSync(file)) return [];
|
|
104
|
+
try {
|
|
105
|
+
const cutoff = Date.now() - windowMs;
|
|
106
|
+
return readFileSync(file, 'utf8')
|
|
107
|
+
.trim()
|
|
108
|
+
.split('\n')
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } })
|
|
111
|
+
.filter((r) => r && new Date(r.ts).getTime() >= cutoff);
|
|
112
|
+
} catch { return []; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Walk the active log segment, asserting:
|
|
117
|
+
* - each record's prev_line_hash matches sha256 of the prior line
|
|
118
|
+
* - sequence numbers are monotonic with no gaps
|
|
119
|
+
*
|
|
120
|
+
* Returns { ok, brokenAt? } where brokenAt is { index, reason } on first
|
|
121
|
+
* failure. An empty / missing log is `{ ok: true }` (nothing to break).
|
|
122
|
+
*/
|
|
123
|
+
export function verifyChain() {
|
|
124
|
+
const file = logFile();
|
|
125
|
+
if (!existsSync(file)) return { ok: true };
|
|
126
|
+
try {
|
|
127
|
+
const lines = readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
|
|
128
|
+
let priorLine = null;
|
|
129
|
+
let priorSeq = null;
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
const line = lines[i];
|
|
132
|
+
let record;
|
|
133
|
+
try { record = JSON.parse(line); }
|
|
134
|
+
catch { return { ok: false, brokenAt: { index: i, reason: 'unparseable JSON' } }; }
|
|
135
|
+
|
|
136
|
+
if (priorLine !== null) {
|
|
137
|
+
const expectedHash = sha256(priorLine);
|
|
138
|
+
if (record.prev_line_hash !== expectedHash) {
|
|
139
|
+
return { ok: false, brokenAt: { index: i, reason: 'prev_line_hash mismatch' } };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (Number.isInteger(record.sequence)) {
|
|
143
|
+
if (priorSeq !== null && record.sequence !== priorSeq + 1) {
|
|
144
|
+
return { ok: false, brokenAt: { index: i, reason: `sequence gap (expected ${priorSeq + 1}, got ${record.sequence})` } };
|
|
145
|
+
}
|
|
146
|
+
priorSeq = record.sequence;
|
|
147
|
+
}
|
|
148
|
+
priorLine = line;
|
|
149
|
+
}
|
|
150
|
+
return { ok: true };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return { ok: false, brokenAt: { index: -1, reason: err.message } };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/document-extract/docling-client.mjs — Node side of the docling sidecar.
|
|
3
|
+
*
|
|
4
|
+
* Spawns one long-lived Python process per Node session, framed by newline-
|
|
5
|
+
* delimited JSON over stdin/stdout. Avoids per-call uv/venv warm-up
|
|
6
|
+
* (~2s otherwise) and keeps the docling model resident.
|
|
7
|
+
*
|
|
8
|
+
* Public API:
|
|
9
|
+
* extractViaDocling(filePath) → { markdown, metadata, droppedInfo }
|
|
10
|
+
* shutdownDoclingSidecar() → idempotent process exit
|
|
11
|
+
*
|
|
12
|
+
* The first call provisions the venv on demand via ensureDoclingVenv().
|
|
13
|
+
* Subsequent calls reuse the running sidecar.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { ensureDoclingVenv } from '../runtime/uv-bootstrap.mjs';
|
|
19
|
+
|
|
20
|
+
const SIDECAR_SCRIPT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'docling-sidecar.py');
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 300_000;
|
|
22
|
+
|
|
23
|
+
let activeSidecar = null;
|
|
24
|
+
let requestSeq = 0;
|
|
25
|
+
|
|
26
|
+
function spawnSidecar({ runtimeDir } = {}) {
|
|
27
|
+
const { pythonBin } = ensureDoclingVenv({ runtimeDir });
|
|
28
|
+
const child = spawn(pythonBin, [SIDECAR_SCRIPT], {
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const pending = new Map();
|
|
34
|
+
let stdoutBuffer = '';
|
|
35
|
+
let stderrBuffer = '';
|
|
36
|
+
let exitReason = null;
|
|
37
|
+
|
|
38
|
+
child.stdout.setEncoding('utf8');
|
|
39
|
+
child.stderr.setEncoding('utf8');
|
|
40
|
+
|
|
41
|
+
child.stdout.on('data', (chunk) => {
|
|
42
|
+
stdoutBuffer += chunk;
|
|
43
|
+
let newlineIdx;
|
|
44
|
+
while ((newlineIdx = stdoutBuffer.indexOf('\n')) !== -1) {
|
|
45
|
+
const line = stdoutBuffer.slice(0, newlineIdx);
|
|
46
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIdx + 1);
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
let message;
|
|
49
|
+
try { message = JSON.parse(line); }
|
|
50
|
+
catch { continue; }
|
|
51
|
+
const id = message.id;
|
|
52
|
+
const handler = pending.get(id);
|
|
53
|
+
if (!handler) continue;
|
|
54
|
+
pending.delete(id);
|
|
55
|
+
clearTimeout(handler.timer);
|
|
56
|
+
if (message.error) handler.reject(Object.assign(new Error(message.error.message || 'docling sidecar error'), { code: message.error.code, trace: message.error.trace }));
|
|
57
|
+
else handler.resolve(message.result);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
child.stderr.on('data', (chunk) => { stderrBuffer += chunk; });
|
|
62
|
+
|
|
63
|
+
child.on('exit', (code) => {
|
|
64
|
+
exitReason = `exit code ${code}`;
|
|
65
|
+
for (const [, handler] of pending) {
|
|
66
|
+
clearTimeout(handler.timer);
|
|
67
|
+
handler.reject(new Error(`docling sidecar exited (${exitReason}); stderr: ${stderrBuffer.slice(-500)}`));
|
|
68
|
+
}
|
|
69
|
+
pending.clear();
|
|
70
|
+
if (activeSidecar === sidecar) activeSidecar = null;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function send(method, params = {}) {
|
|
74
|
+
if (child.killed || child.exitCode !== null) {
|
|
75
|
+
return Promise.reject(new Error(`docling sidecar not running: ${exitReason || 'killed'}`));
|
|
76
|
+
}
|
|
77
|
+
const id = ++requestSeq;
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
pending.delete(id);
|
|
81
|
+
reject(new Error(`docling sidecar timeout after ${REQUEST_TIMEOUT_MS}ms (method=${method})`));
|
|
82
|
+
}, REQUEST_TIMEOUT_MS);
|
|
83
|
+
pending.set(id, { resolve, reject, timer });
|
|
84
|
+
child.stdin.write(JSON.stringify({ id, method, params }) + '\n');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sidecar = { send, child, get stderr() { return stderrBuffer; } };
|
|
89
|
+
return sidecar;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getSidecar() {
|
|
93
|
+
if (activeSidecar && activeSidecar.child.exitCode === null && !activeSidecar.child.killed) return activeSidecar;
|
|
94
|
+
activeSidecar = spawnSidecar();
|
|
95
|
+
return activeSidecar;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function extractViaDocling(filePath) {
|
|
99
|
+
const sidecar = getSidecar();
|
|
100
|
+
const result = await sidecar.send('extract', { path: path.resolve(filePath) });
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function shutdownDoclingSidecar() {
|
|
105
|
+
if (!activeSidecar) return;
|
|
106
|
+
const sidecar = activeSidecar;
|
|
107
|
+
activeSidecar = null;
|
|
108
|
+
try { await sidecar.send('shutdown'); } catch { /* sidecar already gone */ }
|
|
109
|
+
if (sidecar.child.exitCode === null && !sidecar.child.killed) {
|
|
110
|
+
sidecar.child.kill('SIGTERM');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.on('exit', () => { if (activeSidecar) activeSidecar.child.kill('SIGTERM'); });
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
lib/document-extract/docling-sidecar.py — long-lived JSON-RPC wrapper around docling.
|
|
4
|
+
|
|
5
|
+
Protocol: newline-delimited JSON over stdin/stdout. Each request is
|
|
6
|
+
{"id": <int>, "method": <str>, "params": <obj>}. Each response is
|
|
7
|
+
{"id": <int>, "result": <obj>} or {"id": <int>, "error": {"code": <str>, "message": <str>}}.
|
|
8
|
+
|
|
9
|
+
Methods:
|
|
10
|
+
- ping → {"ok": true, "doclingVersion": "<x.y.z>"}
|
|
11
|
+
- extract {path} → {"markdown": "...", "metadata": {...}, "droppedInfo": [...]}
|
|
12
|
+
- shutdown → {"ok": true}; process exits after acknowledgement
|
|
13
|
+
|
|
14
|
+
Best-practice notes (2026-06):
|
|
15
|
+
- One sidecar per Node session, kept warm to avoid uv/venv startup (~2s).
|
|
16
|
+
- Stdio JSON-RPC is the leanest Python↔Node IPC for non-LLM use cases;
|
|
17
|
+
MCP rides the same transport but adds protocol overhead unsuitable
|
|
18
|
+
for the parser sidecar.
|
|
19
|
+
- Drops are surfaced explicitly (kind/count/reason/recoverable) so info
|
|
20
|
+
loss is observable to the CLI, not silent.
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
import traceback
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from docling.document_converter import DocumentConverter
|
|
29
|
+
from importlib.metadata import version as _pkg_version
|
|
30
|
+
DOCLING_VERSION = _pkg_version("docling")
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
sys.stderr.write(json.dumps({"fatal": "docling-import-failed", "detail": str(exc)}) + "\n")
|
|
33
|
+
sys.exit(2)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_converter = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_converter():
|
|
40
|
+
global _converter
|
|
41
|
+
if _converter is None:
|
|
42
|
+
_converter = DocumentConverter()
|
|
43
|
+
return _converter
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract(params):
|
|
47
|
+
path_raw = params.get("path")
|
|
48
|
+
if not path_raw:
|
|
49
|
+
raise ValueError("missing 'path' parameter")
|
|
50
|
+
path = Path(path_raw)
|
|
51
|
+
if not path.exists():
|
|
52
|
+
raise FileNotFoundError(f"file not found: {path}")
|
|
53
|
+
|
|
54
|
+
result = get_converter().convert(str(path))
|
|
55
|
+
doc = result.document
|
|
56
|
+
markdown = doc.export_to_markdown()
|
|
57
|
+
|
|
58
|
+
metadata = {
|
|
59
|
+
"format": result.input.format.value if hasattr(result.input, "format") else None,
|
|
60
|
+
"pageCount": len(doc.pages) if hasattr(doc, "pages") and doc.pages is not None else None,
|
|
61
|
+
"doclingVersion": DOCLING_VERSION,
|
|
62
|
+
"sourcePath": str(path),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
dropped_info = []
|
|
66
|
+
try:
|
|
67
|
+
page_count = metadata.get("pageCount") or 0
|
|
68
|
+
if page_count and len(markdown) / max(page_count, 1) < 50:
|
|
69
|
+
dropped_info.append({
|
|
70
|
+
"kind": "low-text-yield",
|
|
71
|
+
"count": page_count,
|
|
72
|
+
"reason": "Extracted text density below 50 chars/page suggests image-heavy or scanned content; OCR may have partial coverage.",
|
|
73
|
+
"recoverable": True,
|
|
74
|
+
})
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"markdown": markdown,
|
|
80
|
+
"metadata": metadata,
|
|
81
|
+
"droppedInfo": dropped_info,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def handle(request):
|
|
86
|
+
method = request.get("method")
|
|
87
|
+
params = request.get("params") or {}
|
|
88
|
+
if method == "ping":
|
|
89
|
+
return {"ok": True, "doclingVersion": DOCLING_VERSION}
|
|
90
|
+
if method == "extract":
|
|
91
|
+
return extract(params)
|
|
92
|
+
if method == "shutdown":
|
|
93
|
+
return {"ok": True}
|
|
94
|
+
raise ValueError(f"unknown method: {method}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main():
|
|
98
|
+
for line in sys.stdin:
|
|
99
|
+
line = line.strip()
|
|
100
|
+
if not line:
|
|
101
|
+
continue
|
|
102
|
+
request_id = None
|
|
103
|
+
try:
|
|
104
|
+
request = json.loads(line)
|
|
105
|
+
request_id = request.get("id")
|
|
106
|
+
result = handle(request)
|
|
107
|
+
sys.stdout.write(json.dumps({"id": request_id, "result": result}) + "\n")
|
|
108
|
+
sys.stdout.flush()
|
|
109
|
+
if request.get("method") == "shutdown":
|
|
110
|
+
return
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
error = {
|
|
113
|
+
"code": type(exc).__name__,
|
|
114
|
+
"message": str(exc),
|
|
115
|
+
"trace": traceback.format_exc() if not isinstance(exc, (ValueError, FileNotFoundError)) else None,
|
|
116
|
+
}
|
|
117
|
+
sys.stdout.write(json.dumps({"id": request_id, "error": error}) + "\n")
|
|
118
|
+
sys.stdout.flush()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/document-extract/whisper-client.mjs — invoke whisper.cpp on audio/video.
|
|
3
|
+
*
|
|
4
|
+
* Returns `{ markdown, metadata, droppedInfo }` matching the shape returned
|
|
5
|
+
* by docling-client for symmetry with the document-extract dispatcher.
|
|
6
|
+
*
|
|
7
|
+
* Strategy: run whisper-cli with the source file → text transcript. The
|
|
8
|
+
* transcript is wrapped as a fenced "## Transcript" section in markdown so
|
|
9
|
+
* the format-converter pipeline downstream gets a uniform shape.
|
|
10
|
+
*
|
|
11
|
+
* 2026-06 notes:
|
|
12
|
+
* - whisper.cpp + Metal hits ~10× real-time on M-series for large-v3.
|
|
13
|
+
* Default model is `base.en` for speed; override via
|
|
14
|
+
* CONSTRUCT_WHISPER_MODEL for non-English or higher-quality runs.
|
|
15
|
+
* - Non-audio formats are rejected up front so the docling sidecar stays
|
|
16
|
+
* the single owner of PDF/Office/HTML parsing.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
22
|
+
import { ensureWhisperRuntime } from '../runtime/whisper-bootstrap.mjs';
|
|
23
|
+
|
|
24
|
+
const CLI_TIMEOUT_MS = 600_000;
|
|
25
|
+
|
|
26
|
+
function runWhisperCli({ binary, modelPath, sourcePath, outputBase }) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const args = [
|
|
29
|
+
'-m', modelPath,
|
|
30
|
+
'-f', sourcePath,
|
|
31
|
+
'-otxt',
|
|
32
|
+
'-of', outputBase,
|
|
33
|
+
'-l', 'auto',
|
|
34
|
+
'-nt',
|
|
35
|
+
];
|
|
36
|
+
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
37
|
+
let stdout = '';
|
|
38
|
+
let stderr = '';
|
|
39
|
+
child.stdout.on('data', (c) => { stdout += c; });
|
|
40
|
+
child.stderr.on('data', (c) => { stderr += c; });
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
child.kill('SIGKILL');
|
|
43
|
+
reject(new Error(`whisper-cli timeout after ${CLI_TIMEOUT_MS}ms`));
|
|
44
|
+
}, CLI_TIMEOUT_MS);
|
|
45
|
+
child.on('exit', (code) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (code !== 0) reject(new Error(`whisper-cli exited ${code}: ${stderr.slice(-500)}`));
|
|
48
|
+
else resolve({ stdout, stderr });
|
|
49
|
+
});
|
|
50
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function extractViaWhisper(filePath, { runtimeDir, model } = {}) {
|
|
55
|
+
const absolutePath = path.resolve(filePath);
|
|
56
|
+
const { binary, modelPath, model: resolvedModel } = await ensureWhisperRuntime({ runtimeDir, model });
|
|
57
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'construct-whisper-'));
|
|
58
|
+
const outputBase = path.join(tmpDir, 'transcript');
|
|
59
|
+
try {
|
|
60
|
+
const { stderr } = await runWhisperCli({ binary, modelPath, sourcePath: absolutePath, outputBase });
|
|
61
|
+
const transcript = readFileSync(`${outputBase}.txt`, 'utf8').trim();
|
|
62
|
+
const detectedLanguage = stderr.match(/auto-detected language: (\w+)/)?.[1] || null;
|
|
63
|
+
const markdown = `## Transcript\n\n${transcript}\n`;
|
|
64
|
+
return {
|
|
65
|
+
markdown,
|
|
66
|
+
metadata: {
|
|
67
|
+
sourcePath: absolutePath,
|
|
68
|
+
whisperModel: resolvedModel,
|
|
69
|
+
binarySource: binary,
|
|
70
|
+
detectedLanguage,
|
|
71
|
+
},
|
|
72
|
+
droppedInfo: transcript.length === 0
|
|
73
|
+
? [{ kind: 'empty-transcript', count: 1, reason: 'whisper-cli returned no text — silent file, unsupported codec, or model mismatch.', recoverable: true }]
|
|
74
|
+
: [],
|
|
75
|
+
};
|
|
76
|
+
} finally {
|
|
77
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* tmp cleanup best-effort */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
package/lib/document-extract.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/document-extract.mjs — shared local document extraction for retrieval and MCP reads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Two extraction paths:
|
|
5
|
+
* - Async (preferred): `extractDocumentTextAsync` routes PDF/Office/HTML
|
|
6
|
+
* through the docling Python sidecar and audio/video through whisper.cpp.
|
|
7
|
+
* Returns { text, markdown, metadata, droppedInfo, ... }.
|
|
8
|
+
* - Sync (legacy): `extractDocumentText` keeps regex/CLI extractors.
|
|
9
|
+
* PDF/Office output is lower fidelity (tables flattened, formulas
|
|
10
|
+
* dropped, scanned pages return empty). Audio/video throws
|
|
11
|
+
* { code: 'ASR_REQUIRED' } on this path.
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* `extractDocumentMetadata` returns frontmatter + title/authors/dates.
|
|
14
|
+
*
|
|
15
|
+
* All paths return droppedInfo as { kind, count, reason, recoverable }[] so
|
|
16
|
+
* info loss is observable rather than silent.
|
|
13
17
|
*/
|
|
14
18
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
15
19
|
import { basename, extname, resolve } from 'node:path';
|
|
@@ -689,3 +693,64 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
|
|
|
689
693
|
if (extracted.skipped) result.skipped = extracted.skipped;
|
|
690
694
|
return result;
|
|
691
695
|
}
|
|
696
|
+
|
|
697
|
+
// Async high-fidelity extraction path. Routes PDF/Office/HTML through the
|
|
698
|
+
// docling Python sidecar (layout-aware markdown) and audio/video through
|
|
699
|
+
// whisper.cpp (Metal-accelerated on macOS). Text/transcript/calendar/email
|
|
700
|
+
// paths reuse the sync extraction since regex-stripping is the right tool
|
|
701
|
+
// for those formats. Returns the same { text, droppedInfo, ... } shape as
|
|
702
|
+
// the sync extractor plus a `markdown` field for paths that produce it.
|
|
703
|
+
//
|
|
704
|
+
// 2026-06 best practice: this is the path new callers should use. The sync
|
|
705
|
+
// extractDocumentText is preserved for backwards compatibility with
|
|
706
|
+
// pre-async callers; PDF/Office output via that path is lower fidelity
|
|
707
|
+
// (tables flattened, formulas dropped) and emits a droppedInfo notice.
|
|
708
|
+
|
|
709
|
+
export async function extractDocumentTextAsync(filePath, { maxChars = null } = {}) {
|
|
710
|
+
const resolvedPath = resolve(filePath);
|
|
711
|
+
if (!existsSync(resolvedPath)) throw new Error(`File not found: ${resolvedPath}`);
|
|
712
|
+
const extension = extname(resolvedPath).toLowerCase();
|
|
713
|
+
if (!EXTRACTABLE_DOCUMENT_EXTS.has(extension)) {
|
|
714
|
+
throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (AUDIO_VIDEO_EXTS.has(extension)) {
|
|
718
|
+
const { extractViaWhisper } = await import('./document-extract/whisper-client.mjs');
|
|
719
|
+
const out = await extractViaWhisper(resolvedPath);
|
|
720
|
+
return finalizeAsyncResult({ resolvedPath, extension, extractionMethod: 'whisper', extracted: out, maxChars });
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const doclingFormats = new Set([
|
|
724
|
+
'.pdf',
|
|
725
|
+
...ZIP_DOCUMENT_EXTS,
|
|
726
|
+
...RICH_TEXT_EXTS,
|
|
727
|
+
...MDLS_DOCUMENT_EXTS,
|
|
728
|
+
]);
|
|
729
|
+
if (doclingFormats.has(extension)) {
|
|
730
|
+
const { extractViaDocling } = await import('./document-extract/docling-client.mjs');
|
|
731
|
+
const out = await extractViaDocling(resolvedPath);
|
|
732
|
+
return finalizeAsyncResult({ resolvedPath, extension, extractionMethod: 'docling', extracted: out, maxChars });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return extractDocumentText(resolvedPath, { maxChars });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function finalizeAsyncResult({ resolvedPath, extension, extractionMethod, extracted, maxChars }) {
|
|
739
|
+
const text = extracted.markdown ?? extracted.text ?? '';
|
|
740
|
+
const limit = Number.isFinite(Number(maxChars)) && Number(maxChars) > 0
|
|
741
|
+
? Math.min(Number(maxChars), 200_000)
|
|
742
|
+
: null;
|
|
743
|
+
const truncated = limit !== null && text.length > limit;
|
|
744
|
+
return {
|
|
745
|
+
filePath: resolvedPath,
|
|
746
|
+
extension,
|
|
747
|
+
extractionMethod,
|
|
748
|
+
text: truncated ? `${text.slice(0, limit)}\n` : text,
|
|
749
|
+
markdown: extracted.markdown ?? null,
|
|
750
|
+
metadata: extracted.metadata ?? null,
|
|
751
|
+
truncated,
|
|
752
|
+
characters: text.length,
|
|
753
|
+
droppedInfo: extracted.droppedInfo ?? [],
|
|
754
|
+
structured: extracted.structured ?? null,
|
|
755
|
+
};
|
|
756
|
+
}
|