@geraldmaron/construct 1.0.14 → 1.0.16
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 +21 -2
- package/bin/construct +146 -83
- 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/db/schema/010_cx_scores.sql +51 -0
- package/lib/beads-client.mjs +19 -1
- package/lib/cli-commands.mjs +280 -146
- package/lib/comment-lint.mjs +5 -1
- package/lib/context-state.mjs +15 -3
- package/lib/contracts/validate.mjs +72 -18
- package/lib/contracts/violation-log.mjs +169 -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/hooks/stop-notify.mjs +7 -0
- package/lib/ingest/chunker.mjs +94 -0
- package/lib/ingest/pipeline.mjs +53 -0
- package/lib/ingest/store.mjs +82 -0
- package/lib/install/first-invocation.mjs +5 -1
- package/lib/intake/prepare.mjs +22 -4
- package/lib/integrations/intake-integrations.mjs +28 -2
- 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/telemetry.mjs +30 -0
- package/lib/mcp/tools/workflow.mjs +6 -2
- package/lib/observation-store.mjs +14 -5
- package/lib/ollama-manager.mjs +32 -28
- package/lib/orchestration-policy.mjs +15 -0
- package/lib/prompt-composer.js +11 -1
- package/lib/roles/gateway.mjs +30 -1
- package/lib/runtime/uv-bootstrap.mjs +129 -0
- package/lib/runtime/whisper-bootstrap.mjs +102 -0
- package/lib/scheduler/index.mjs +24 -4
- package/lib/server/index.mjs +22 -1
- package/lib/server/insights.mjs +12 -0
- package/lib/server/static/index.html +1 -1
- package/lib/setup.mjs +54 -9
- package/lib/specialists/postconditions.mjs +1 -1
- package/lib/sync/skill-frontmatter.mjs +113 -21
- package/lib/tracking-surfaces.mjs +2 -0
- package/lib/update.mjs +31 -3
- package/lib/upgrade.mjs +31 -4
- package/lib/validators/skills.mjs +86 -54
- package/package.json +5 -5
- package/personas/construct.md +6 -7
- package/platforms/claude/CLAUDE.md +43 -15
- 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/scripts/sync-specialists.mjs +20 -2
- 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
package/lib/comment-lint.mjs
CHANGED
|
@@ -79,11 +79,15 @@ function requiresHeader(rel) {
|
|
|
79
79
|
|
|
80
80
|
const JS_HEADER_RE = /^(?:#![^\n]*\n)?(?:\/\/[^\n]*\n)*\/\*\*[\s\S]*?\*\//;
|
|
81
81
|
const MD_HEADER_RE = /^<!--[\s\S]*?-->/;
|
|
82
|
+
const MD_YAML_HEADER_RE = /^---\n(?:[^\n]*\n)*?description:[\s\S]*?\n---/;
|
|
82
83
|
const SH_HEADER_RE = /^#!.*\n(?:#[^\n]*\n)*/;
|
|
83
84
|
|
|
84
85
|
function hasHeader(content, type) {
|
|
85
86
|
if (type === 'js') return JS_HEADER_RE.test(content.trimStart());
|
|
86
|
-
if (type === 'md')
|
|
87
|
+
if (type === 'md') {
|
|
88
|
+
const trimmed = content.trimStart();
|
|
89
|
+
return MD_HEADER_RE.test(trimmed) || MD_YAML_HEADER_RE.test(trimmed);
|
|
90
|
+
}
|
|
87
91
|
return SH_HEADER_RE.test(content.trimStart());
|
|
88
92
|
}
|
|
89
93
|
|
package/lib/context-state.mjs
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* lib/context-state.mjs — compact project/session context persistence.
|
|
4
|
+
*
|
|
5
|
+
* Writes go through writeContextState, which uses write-to-tmp + atomic
|
|
6
|
+
* rename so concurrent writers from different hooks (pre-compact, Stop,
|
|
7
|
+
* context-window-recovery, tracking-surfaces) cannot leave a half-written
|
|
8
|
+
* JSON or markdown file behind. POSIX rename(2) is atomic for files on
|
|
9
|
+
* the same filesystem; the .cx directory always lives next to the target.
|
|
4
10
|
*/
|
|
5
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
6
12
|
import { join } from 'node:path';
|
|
7
13
|
|
|
8
14
|
export function contextJsonPath(rootDir) {
|
|
@@ -58,6 +64,12 @@ export function inspectContextState(rootDir) {
|
|
|
58
64
|
};
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
function atomicWrite(targetPath, content) {
|
|
68
|
+
const tmpPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
69
|
+
writeFileSync(tmpPath, content, 'utf8');
|
|
70
|
+
renameSync(tmpPath, targetPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
61
73
|
export function writeContextState(rootDir, state, { markdown = null } = {}) {
|
|
62
74
|
const cxDir = join(rootDir, '.cx');
|
|
63
75
|
mkdirSync(cxDir, { recursive: true });
|
|
@@ -67,10 +79,10 @@ export function writeContextState(rootDir, state, { markdown = null } = {}) {
|
|
|
67
79
|
savedAt: new Date().toISOString(),
|
|
68
80
|
...state,
|
|
69
81
|
};
|
|
70
|
-
|
|
82
|
+
atomicWrite(contextJsonPath(rootDir), `${JSON.stringify(payload, null, 2)}\n`);
|
|
71
83
|
|
|
72
84
|
if (markdown !== null) {
|
|
73
|
-
|
|
85
|
+
atomicWrite(contextMarkdownPath(rootDir), markdown.endsWith('\n') ? markdown : `${markdown}\n`);
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
return payload;
|
|
@@ -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,71 @@ 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
|
-
export function validateHandoff({
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
export function validateHandoff(opts = {}) {
|
|
161
|
+
const {
|
|
162
|
+
producer,
|
|
163
|
+
consumer,
|
|
164
|
+
id,
|
|
165
|
+
artifact,
|
|
166
|
+
packet,
|
|
167
|
+
contractsPath = CONTRACTS_PATH,
|
|
168
|
+
repoRoot = REPO_ROOT,
|
|
169
|
+
enforcement = 'block',
|
|
170
|
+
} = opts;
|
|
171
|
+
// Distinguish caller-supplied repoRoot from the default. Only an explicit
|
|
172
|
+
// repoRoot routes the violation log write — the default REPO_ROOT is for
|
|
173
|
+
// joining schema paths in this module, and forwarding it to the logger
|
|
174
|
+
// would override the cwd-based project-scope resolver that the test
|
|
175
|
+
// suite (and runtime project state) relies on.
|
|
176
|
+
const explicitRepoRoot = Object.prototype.hasOwnProperty.call(opts, 'repoRoot') ? repoRoot : undefined;
|
|
177
|
+
|
|
178
|
+
const errors = [];
|
|
179
|
+
const postconditionFailures = [];
|
|
180
|
+
|
|
181
|
+
// Binary postconditions are producer-bound invariants that fire regardless
|
|
182
|
+
// of whether the producer→consumer contract is registered in contracts.json.
|
|
183
|
+
// A producer with rules cannot have its packet validation skipped by
|
|
184
|
+
// claiming an unregistered consumer.
|
|
185
|
+
const hasBinaryRules = Array.isArray(POSTCONDITIONS[producer]) && POSTCONDITIONS[producer].length > 0;
|
|
186
|
+
if (hasBinaryRules) {
|
|
187
|
+
if (packet === undefined || packet === null) {
|
|
188
|
+
errors.push(`producer '${producer}' has binary postconditions; validateHandoff must be called with a packet argument`);
|
|
189
|
+
} else {
|
|
190
|
+
const binary = validateBinaryPostconditions(producer, packet);
|
|
191
|
+
for (const failure of binary.failures) {
|
|
192
|
+
errors.push(`[${failure.id}] ${failure.reason}`);
|
|
193
|
+
postconditionFailures.push(failure);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
161
198
|
const contract = findContract({ producer, consumer, id, contractsPath });
|
|
162
199
|
if (!contract) {
|
|
200
|
+
if (errors.length > 0) {
|
|
201
|
+
logViolation(`${producer}->${consumer}`, 'output', errors, packet ?? artifact, {
|
|
202
|
+
verdict: postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION',
|
|
203
|
+
postconditionFailures,
|
|
204
|
+
repoRoot: explicitRepoRoot,
|
|
205
|
+
});
|
|
206
|
+
return enforcement === 'block'
|
|
207
|
+
? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`, ...errors], contract: null }
|
|
208
|
+
: { ok: true, warnings: [`no contract found for ${producer}→${consumer}`, ...errors], contract: null };
|
|
209
|
+
}
|
|
163
210
|
return enforcement === 'block'
|
|
164
211
|
? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`], contract: null }
|
|
165
212
|
: { ok: true, warnings: [`no contract found for ${producer}→${consumer}`], contract: null };
|
|
166
213
|
}
|
|
167
214
|
|
|
168
|
-
const errors = [];
|
|
169
|
-
|
|
170
215
|
// A handoff carries a producer's output, which is the consumer's input. The
|
|
171
216
|
// input contract is what the consumer expects to receive; check mustContain
|
|
172
217
|
// against that first. Output.schema validation only applies when the artifact
|
|
@@ -193,18 +238,27 @@ export function validateHandoff({
|
|
|
193
238
|
}
|
|
194
239
|
}
|
|
195
240
|
|
|
196
|
-
//
|
|
197
|
-
// handoff envelope declares `artifactPath`. Resolved relative to repoRoot.
|
|
241
|
+
// Disk-artifact postconditions: frontmatter, sections, citations.
|
|
198
242
|
const declaredArtifactPath = artifact?.artifactPath || artifact?.output?.artifactPath;
|
|
199
243
|
if (declaredArtifactPath) {
|
|
200
244
|
const absPath = isAbsolutePath(declaredArtifactPath)
|
|
201
245
|
? declaredArtifactPath
|
|
202
246
|
: join(repoRoot, declaredArtifactPath);
|
|
203
|
-
const pcErrors =
|
|
247
|
+
const pcErrors = validateArtifactPostconditions({ contract, artifactPath: absPath });
|
|
204
248
|
for (const e of pcErrors) errors.push(e);
|
|
205
249
|
}
|
|
206
250
|
|
|
207
251
|
if (errors.length === 0) return { ok: true, contract };
|
|
252
|
+
|
|
253
|
+
// Persist to the chain-hashed violation log so `construct doctor` and
|
|
254
|
+
// forensic replay see the failure. Best-effort; logging never throws.
|
|
255
|
+
const verdict = postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION';
|
|
256
|
+
logViolation(contract.id, 'output', errors, packet ?? artifact, {
|
|
257
|
+
verdict,
|
|
258
|
+
postconditionFailures,
|
|
259
|
+
repoRoot: explicitRepoRoot,
|
|
260
|
+
});
|
|
261
|
+
|
|
208
262
|
if (enforcement === 'block') return { ok: false, status: 'BLOCKED_CONTRACT', errors, contract };
|
|
209
263
|
return { ok: true, warnings: errors, contract };
|
|
210
264
|
}
|
|
@@ -272,7 +326,7 @@ function claimsHaveCitations(body) {
|
|
|
272
326
|
* disk. Postconditions can be strings (descriptive, ignored here) or objects
|
|
273
327
|
* with { id, check, field?, section? }. Returns an array of error strings.
|
|
274
328
|
*/
|
|
275
|
-
export function
|
|
329
|
+
export function validateArtifactPostconditions({ contract, artifactPath }) {
|
|
276
330
|
if (!contract?.postconditions?.length) return [];
|
|
277
331
|
if (!artifactPath || !existsSync(artifactPath)) {
|
|
278
332
|
const structured = contract.postconditions.filter((p) => typeof p === 'object');
|
|
@@ -0,0 +1,169 @@
|
|
|
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(repoRoot) {
|
|
43
|
+
// Explicit repoRoot bypasses the marker-walk in resolveProjectScope: the
|
|
44
|
+
// caller has already decided the scope (test fixture, sandbox, embedded
|
|
45
|
+
// worktree). Without this, a fixture without a `.cx/` marker falls back
|
|
46
|
+
// to ~/.cx/ and pollutes the developer's home log.
|
|
47
|
+
if (repoRoot) return join(repoRoot, '.cx', 'contract-violations.jsonl');
|
|
48
|
+
return resolveProjectScopedPath('contract-violations.jsonl', { ensureDir: false });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Exposed for diagnostic surfaces (e.g. doctor) that need to print the
|
|
52
|
+
// real path. Resolves on every call so cwd/HOME changes inside the same
|
|
53
|
+
// process route correctly. `repoRoot` overrides cwd-based resolution so
|
|
54
|
+
// callers running against a fixture (tests, sandboxes) can isolate their
|
|
55
|
+
// log writes from the developer's project log.
|
|
56
|
+
export function violationLogPath(repoRoot) { return logFile(repoRoot); }
|
|
57
|
+
|
|
58
|
+
function sha256(input) { return createHash('sha256').update(input).digest('hex'); }
|
|
59
|
+
|
|
60
|
+
function readLastAgent() {
|
|
61
|
+
try { return JSON.parse(readFileSync(LAST_AGENT, 'utf8'))?.agent || 'construct'; }
|
|
62
|
+
catch { return 'construct'; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readTailRecord(repoRoot) {
|
|
66
|
+
const file = logFile(repoRoot);
|
|
67
|
+
const lastLine = readLastLineAcrossSegments(file);
|
|
68
|
+
if (!lastLine) return null;
|
|
69
|
+
try { return JSON.parse(lastLine); }
|
|
70
|
+
catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readPrevLineHash(repoRoot) {
|
|
74
|
+
const file = logFile(repoRoot);
|
|
75
|
+
const lastLine = readLastLineAcrossSegments(file);
|
|
76
|
+
return lastLine ? sha256(lastLine) : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nextSequence(repoRoot) {
|
|
80
|
+
const tail = readTailRecord(repoRoot);
|
|
81
|
+
const prior = Number.isInteger(tail?.sequence) ? tail.sequence : 0;
|
|
82
|
+
return prior + 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Append a violation record. Best-effort: file I/O failures are swallowed
|
|
87
|
+
* so logging never crashes the caller. `extra.repoRoot` (extracted, not
|
|
88
|
+
* persisted) routes the write to a fixture log when callers run against
|
|
89
|
+
* a tmpdir — required for test isolation.
|
|
90
|
+
*/
|
|
91
|
+
export function logViolation(contractId, direction, missing, packet, extra = {}) {
|
|
92
|
+
try {
|
|
93
|
+
const { repoRoot, ...persistedExtra } = extra || {};
|
|
94
|
+
const file = logFile(repoRoot);
|
|
95
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
96
|
+
const record = {
|
|
97
|
+
ts: new Date().toISOString(),
|
|
98
|
+
sequence: nextSequence(repoRoot),
|
|
99
|
+
agent: readLastAgent(),
|
|
100
|
+
contractId,
|
|
101
|
+
direction,
|
|
102
|
+
missing,
|
|
103
|
+
packet_keys: packet && typeof packet === 'object' ? Object.keys(packet) : null,
|
|
104
|
+
prev_line_hash: readPrevLineHash(repoRoot),
|
|
105
|
+
...persistedExtra,
|
|
106
|
+
};
|
|
107
|
+
appendBounded('contract-violations', file, JSON.stringify(record) + '\n');
|
|
108
|
+
} catch { /* logging is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read recent violations from the active log segment. Returns oldest-first.
|
|
113
|
+
* Rotated segments are intentionally not included — this is the doctor
|
|
114
|
+
* surface, scoped to the active window.
|
|
115
|
+
*/
|
|
116
|
+
export function recentViolations({ windowMs = 24 * 60 * 60 * 1000 } = {}) {
|
|
117
|
+
const file = logFile();
|
|
118
|
+
if (!existsSync(file)) return [];
|
|
119
|
+
try {
|
|
120
|
+
const cutoff = Date.now() - windowMs;
|
|
121
|
+
return readFileSync(file, 'utf8')
|
|
122
|
+
.trim()
|
|
123
|
+
.split('\n')
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } })
|
|
126
|
+
.filter((r) => r && new Date(r.ts).getTime() >= cutoff);
|
|
127
|
+
} catch { return []; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Walk the active log segment, asserting:
|
|
132
|
+
* - each record's prev_line_hash matches sha256 of the prior line
|
|
133
|
+
* - sequence numbers are monotonic with no gaps
|
|
134
|
+
*
|
|
135
|
+
* Returns { ok, brokenAt? } where brokenAt is { index, reason } on first
|
|
136
|
+
* failure. An empty / missing log is `{ ok: true }` (nothing to break).
|
|
137
|
+
*/
|
|
138
|
+
export function verifyChain() {
|
|
139
|
+
const file = logFile();
|
|
140
|
+
if (!existsSync(file)) return { ok: true };
|
|
141
|
+
try {
|
|
142
|
+
const lines = readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
|
|
143
|
+
let priorLine = null;
|
|
144
|
+
let priorSeq = null;
|
|
145
|
+
for (let i = 0; i < lines.length; i++) {
|
|
146
|
+
const line = lines[i];
|
|
147
|
+
let record;
|
|
148
|
+
try { record = JSON.parse(line); }
|
|
149
|
+
catch { return { ok: false, brokenAt: { index: i, reason: 'unparseable JSON' } }; }
|
|
150
|
+
|
|
151
|
+
if (priorLine !== null) {
|
|
152
|
+
const expectedHash = sha256(priorLine);
|
|
153
|
+
if (record.prev_line_hash !== expectedHash) {
|
|
154
|
+
return { ok: false, brokenAt: { index: i, reason: 'prev_line_hash mismatch' } };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (Number.isInteger(record.sequence)) {
|
|
158
|
+
if (priorSeq !== null && record.sequence !== priorSeq + 1) {
|
|
159
|
+
return { ok: false, brokenAt: { index: i, reason: `sequence gap (expected ${priorSeq + 1}, got ${record.sequence})` } };
|
|
160
|
+
}
|
|
161
|
+
priorSeq = record.sequence;
|
|
162
|
+
}
|
|
163
|
+
priorLine = line;
|
|
164
|
+
}
|
|
165
|
+
return { ok: true };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return { ok: false, brokenAt: { index: -1, reason: err.message } };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -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
|
+
}
|