@geraldmaron/construct 1.0.12 → 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 +14 -2
- package/bin/construct +262 -14
- package/bin/construct-postinstall.mjs +23 -2
- 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/auto-docs.mjs +11 -7
- 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/dashboard-static.mjs +7 -4
- package/lib/doc-stamp.mjs +16 -0
- package/lib/docs-verify.mjs +1 -8
- package/lib/doctor/watchers/bd-watch.mjs +6 -2
- 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 +26 -1
- package/lib/embed/docs-lifecycle.mjs +19 -0
- package/lib/embed/inbox.mjs +85 -2
- package/lib/embed/recommendation-store.mjs +29 -0
- package/lib/flavors/loader.mjs +1 -1
- package/lib/gates-audit.mjs +18 -12
- package/lib/handoffs/contract.mjs +2 -2
- package/lib/hooks/_lib/input.mjs +52 -0
- package/lib/hooks/adaptive-lint.mjs +4 -0
- package/lib/hooks/agent-tracker.mjs +59 -15
- package/lib/hooks/audit-reads.mjs +83 -42
- package/lib/hooks/audit-trail.mjs +28 -18
- package/lib/hooks/bash-output-logger.mjs +8 -2
- package/lib/hooks/block-no-verify.mjs +4 -0
- package/lib/hooks/ci-status-check.mjs +4 -0
- package/lib/hooks/comment-lint.mjs +6 -4
- package/lib/hooks/config-protection.mjs +4 -0
- package/lib/hooks/context-watch.mjs +4 -0
- package/lib/hooks/context-window-recovery.mjs +4 -0
- package/lib/hooks/dep-audit.mjs +12 -5
- package/lib/hooks/doc-coupling-check.mjs +5 -1
- package/lib/hooks/edit-accumulator.mjs +25 -10
- package/lib/hooks/edit-error-recovery.mjs +4 -0
- package/lib/hooks/edit-guard.mjs +4 -0
- package/lib/hooks/guard-bash.mjs +4 -0
- package/lib/hooks/mcp-audit.mjs +4 -0
- package/lib/hooks/mcp-health-check.mjs +4 -0
- package/lib/hooks/model-fallback.mjs +7 -11
- package/lib/hooks/policy-engine.mjs +4 -0
- package/lib/hooks/post-merge-docs-check.mjs +4 -0
- package/lib/hooks/post-merge-tracking.mjs +82 -0
- package/lib/hooks/pre-compact.mjs +4 -0
- package/lib/hooks/pre-push-gate.mjs +84 -231
- package/lib/hooks/proactive-activation.mjs +5 -2
- package/lib/hooks/readme-age-check.mjs +4 -0
- package/lib/hooks/registry-sync.mjs +35 -20
- package/lib/hooks/rule-verifier.mjs +3 -0
- package/lib/hooks/scan-secrets.mjs +4 -0
- package/lib/hooks/session-optimize.mjs +4 -0
- package/lib/hooks/session-reflect.mjs +4 -0
- package/lib/hooks/session-start.mjs +49 -2
- package/lib/hooks/session-tracking-refresh.mjs +70 -0
- package/lib/hooks/stop-notify.mjs +13 -2
- package/lib/hooks/stop-typecheck.mjs +4 -0
- package/lib/hooks/test-watch.mjs +8 -4
- package/lib/ingest/chunker.mjs +94 -0
- package/lib/ingest/pipeline.mjs +53 -0
- package/lib/ingest/store.mjs +82 -0
- package/lib/init-unified.mjs +90 -23
- package/lib/intake/attribution.mjs +77 -0
- package/lib/intake/intake-config.mjs +3 -0
- package/lib/intake/manifest.mjs +107 -0
- package/lib/intake/poll-lock.mjs +136 -0
- package/lib/intake/prepare.mjs +42 -4
- package/lib/knowledge/search.mjs +12 -0
- package/lib/logging/rotate.mjs +394 -0
- package/lib/mcp/server.mjs +142 -1
- package/lib/mcp/tools/project.mjs +2 -2
- package/lib/mcp/tools/skills.mjs +6 -3
- package/lib/mcp/tools/telemetry.mjs +1 -1
- package/lib/mcp/tools/workflow.mjs +6 -2
- package/lib/observation-store.mjs +14 -5
- package/lib/ollama-manager.mjs +32 -28
- package/lib/opencode-config.mjs +10 -3
- package/lib/orchestration/routing-tables.mjs +176 -0
- package/lib/orchestration-policy.mjs +18 -106
- package/lib/parity.mjs +48 -7
- package/lib/profiles/lifecycle.mjs +19 -1
- package/lib/project-init-shared.mjs +11 -5
- package/lib/project-root.mjs +104 -0
- package/lib/prompt-composer.js +11 -1
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/event-bus.mjs +29 -9
- package/lib/roles/router.mjs +8 -7
- package/lib/runtime/uv-bootstrap.mjs +129 -0
- package/lib/runtime/whisper-bootstrap.mjs +102 -0
- package/lib/server/index.mjs +53 -10
- package/lib/server/static/index.html +1 -15
- package/lib/specialists/postconditions.mjs +1 -1
- package/lib/status.mjs +1 -1
- package/lib/storage/backup.mjs +2 -2
- package/lib/sync/skill-frontmatter.mjs +113 -21
- package/lib/telemetry/intent-verifications.mjs +16 -3
- package/lib/telemetry/skill-calls.mjs +12 -3
- package/lib/tracking-surfaces.mjs +377 -0
- package/lib/validators/skills.mjs +86 -54
- package/lib/worker/trace.mjs +19 -2
- package/package.json +10 -6
- package/personas/construct.md +4 -7
- package/platforms/claude/settings.template.json +29 -28
- 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 +269 -75
- 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/specialists/registry.json +158 -13
- package/lib/hooks/env-check.mjs +0 -83
- package/lib/hooks/read-tracker.mjs +0 -61
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/server/static/assets/index-ab25c707.js +0 -70
- package/lib/server/static/assets/index-f0c80a2b.css +0 -1
- package/lib/specialist-contracts-enforce.mjs +0 -158
- package/rules/common/agents.md +0 -28
- package/rules/common/development-workflow.md +0 -32
- package/rules/common/performance.md +0 -55
package/lib/doc-stamp.mjs
CHANGED
|
@@ -102,12 +102,17 @@ function splitStamp(content) {
|
|
|
102
102
|
* sessionId {string} - Construct session ID if available
|
|
103
103
|
* model {string} - Model name/version if available
|
|
104
104
|
* preserve_id {boolean} - Keep existing cx_doc_id if present (default: true)
|
|
105
|
+
* attribution {object} - Optional { createdBy, createdByAgent } pair. When
|
|
106
|
+
* provided, written into the stamp and (on stamp
|
|
107
|
+
* refresh) carried forward via existing_fields so a
|
|
108
|
+
* later write does not erase the original author.
|
|
105
109
|
*/
|
|
106
110
|
export function stampFrontmatter(content, {
|
|
107
111
|
generator = 'construct',
|
|
108
112
|
sessionId = null,
|
|
109
113
|
model = null,
|
|
110
114
|
preserve_id = true,
|
|
115
|
+
attribution = null,
|
|
111
116
|
} = {}) {
|
|
112
117
|
const existing = hasStamp(content) ? splitStamp(content) : null;
|
|
113
118
|
const existingFields = existing ? parseStamp(content) : {};
|
|
@@ -116,6 +121,11 @@ export function stampFrontmatter(content, {
|
|
|
116
121
|
const createdAt = existingFields.created_at || new Date().toISOString();
|
|
117
122
|
const updatedAt = new Date().toISOString();
|
|
118
123
|
|
|
124
|
+
const createdBy = existingFields.created_by ?? attribution?.createdBy ?? null;
|
|
125
|
+
const createdByAgent = existingFields.created_by_agent ?? attribution?.createdByAgent ?? null;
|
|
126
|
+
const lastModifiedBy = attribution?.createdBy ?? existingFields.last_modified_by ?? null;
|
|
127
|
+
const lastModifiedByAgent = attribution?.createdByAgent ?? existingFields.last_modified_by_agent ?? null;
|
|
128
|
+
|
|
119
129
|
// Body is everything after stamp (or all content if no prior stamp)
|
|
120
130
|
const body = existing ? existing.body : content;
|
|
121
131
|
const hash = bodyHash(body);
|
|
@@ -127,6 +137,12 @@ export function stampFrontmatter(content, {
|
|
|
127
137
|
`updated_at: ${updatedAt}`,
|
|
128
138
|
`generator: ${generator}`,
|
|
129
139
|
];
|
|
140
|
+
if (createdBy) lines.push(`created_by: ${createdBy}`);
|
|
141
|
+
if (createdByAgent) lines.push(`created_by_agent: ${createdByAgent}`);
|
|
142
|
+
if (lastModifiedBy && lastModifiedBy !== createdBy) lines.push(`last_modified_by: ${lastModifiedBy}`);
|
|
143
|
+
if (lastModifiedByAgent && lastModifiedByAgent !== createdByAgent) {
|
|
144
|
+
lines.push(`last_modified_by_agent: ${lastModifiedByAgent}`);
|
|
145
|
+
}
|
|
130
146
|
if (model) lines.push(`model: ${model}`);
|
|
131
147
|
if (sessionId) lines.push(`session_id: ${sessionId}`);
|
|
132
148
|
lines.push(`body_hash: ${hash}`);
|
package/lib/docs-verify.mjs
CHANGED
|
@@ -52,18 +52,11 @@ if (stagedMode) {
|
|
|
52
52
|
if (changedCode.length === 0) process.exit(0);
|
|
53
53
|
if (changedDocs.length > 0) process.exit(0);
|
|
54
54
|
|
|
55
|
-
if (process.env.CONSTRUCT_SKIP_DOCS === "1") {
|
|
56
|
-
process.stderr.write(
|
|
57
|
-
`[docs:verify --staged] CONSTRUCT_SKIP_DOCS=1 — skipping doc-coupling check for ${changedCode.length} code file(s).\n`,
|
|
58
|
-
);
|
|
59
|
-
process.exit(0);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
55
|
const sample = changedCode.slice(0, 6).map((f) => ` - ${f}`).join("\n");
|
|
63
56
|
process.stderr.write(
|
|
64
57
|
`[docs:verify --staged] ${changedCode.length} code file(s) staged but no CHANGELOG.md / docs/* / .cx/context.* updates.\n` +
|
|
65
58
|
`${sample}${changedCode.length > 6 ? `\n ... (${changedCode.length - 6} more)` : ""}\n` +
|
|
66
|
-
`Update docs in this commit
|
|
59
|
+
`Update docs in this commit before pushing.\n`,
|
|
67
60
|
);
|
|
68
61
|
process.exit(1);
|
|
69
62
|
}
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* as a label, regardless of how it got there (manual, ai, automated).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync,
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
16
16
|
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { appendBounded } from '../../logging/rotate.mjs';
|
|
18
|
+
import { resolveProjectScope } from '../../project-root.mjs';
|
|
17
19
|
import { homedir } from 'node:os';
|
|
18
20
|
import { join } from 'node:path';
|
|
19
21
|
|
|
@@ -86,6 +88,7 @@ export async function tick() {
|
|
|
86
88
|
if (seen[key]) continue;
|
|
87
89
|
seen[key] = { ts: Date.now(), id: issue.id };
|
|
88
90
|
const manifest = loadManifest(personaId);
|
|
91
|
+
const scope = resolveProjectScope();
|
|
89
92
|
const entry = {
|
|
90
93
|
ts: Date.now(),
|
|
91
94
|
personaId,
|
|
@@ -96,8 +99,9 @@ export async function tick() {
|
|
|
96
99
|
summary: `bd ${issue.id} labeled next:cx-${personaId} — ${(issue.title || '').slice(0, 120)}`,
|
|
97
100
|
killSwitchEnv: manifest?.killSwitchEnv || '',
|
|
98
101
|
source: 'bd-watch',
|
|
102
|
+
...(scope?.projectId ? { projectId: scope.projectId } : {}),
|
|
99
103
|
};
|
|
100
|
-
|
|
104
|
+
appendBounded('role-pending', PENDING_PATH, JSON.stringify(entry) + '\n');
|
|
101
105
|
record({
|
|
102
106
|
kind: 'action',
|
|
103
107
|
watcher: name,
|
|
@@ -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
|
+
}
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from 'node:path';
|
|
10
|
-
import { extractDocumentText, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
|
|
10
|
+
import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
|
|
11
11
|
import { syncFileStateToSql } from './storage/sync.mjs';
|
|
12
12
|
import { stampFrontmatter } from './doc-stamp.mjs';
|
|
13
13
|
import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
|
|
@@ -127,6 +127,8 @@ export async function ingestDocuments(inputPaths, {
|
|
|
127
127
|
outputDir = null,
|
|
128
128
|
target = 'knowledge/internal',
|
|
129
129
|
sync = false,
|
|
130
|
+
strict = false,
|
|
131
|
+
highFidelity = true,
|
|
130
132
|
env = process.env,
|
|
131
133
|
} = {}) {
|
|
132
134
|
if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
|
|
@@ -142,8 +144,12 @@ export async function ingestDocuments(inputPaths, {
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
const results = [];
|
|
147
|
+
let totalDrops = 0;
|
|
145
148
|
for (const sourcePath of files) {
|
|
146
|
-
const extracted =
|
|
149
|
+
const extracted = highFidelity
|
|
150
|
+
? await extractDocumentTextAsync(sourcePath, { maxChars: 200_000 })
|
|
151
|
+
: extractDocumentText(sourcePath, { maxChars: 200_000 });
|
|
152
|
+
totalDrops += (extracted.droppedInfo ?? []).reduce((a, d) => a + (Number(d.count) || 1), 0);
|
|
147
153
|
const metadata = extractDocumentMetadata(sourcePath);
|
|
148
154
|
const targetPath = resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target });
|
|
149
155
|
ensureDir(dirname(targetPath));
|
|
@@ -184,6 +190,13 @@ export async function ingestDocuments(inputPaths, {
|
|
|
184
190
|
});
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
if (strict && totalDrops > 0) {
|
|
194
|
+
const err = new Error(`--strict: ${totalDrops} dropped items across ${results.filter(r => r.droppedInfo.length).length} files`);
|
|
195
|
+
err.code = 'INGEST_DROPS';
|
|
196
|
+
err.droppedInfo = results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath })));
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
|
|
187
200
|
return {
|
|
188
201
|
status: 'ok',
|
|
189
202
|
target: outputPath ? 'custom' : target,
|
|
@@ -197,6 +210,9 @@ export async function ingestDocuments(inputPaths, {
|
|
|
197
210
|
indexedLocally: true,
|
|
198
211
|
storageSync: syncResult,
|
|
199
212
|
files: results,
|
|
213
|
+
droppedInfo: results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath }))),
|
|
214
|
+
droppedCount: totalDrops,
|
|
215
|
+
highFidelity,
|
|
200
216
|
};
|
|
201
217
|
}
|
|
202
218
|
|
|
@@ -206,19 +222,25 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
206
222
|
let outputDir = null;
|
|
207
223
|
let target = 'knowledge/internal';
|
|
208
224
|
let sync = false;
|
|
225
|
+
let strict = false;
|
|
226
|
+
let highFidelity = true;
|
|
209
227
|
|
|
210
228
|
for (const arg of argv) {
|
|
211
229
|
if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
|
|
212
230
|
else if (arg.startsWith('--out-dir=')) outputDir = arg.split('=').slice(1).join('=');
|
|
213
231
|
else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
|
|
214
232
|
else if (arg === '--sync') sync = true;
|
|
233
|
+
else if (arg === '--strict') strict = true;
|
|
234
|
+
else if (arg === '--legacy-extractor') highFidelity = false;
|
|
215
235
|
else inputs.push(arg);
|
|
216
236
|
}
|
|
217
237
|
|
|
218
238
|
if (inputs.length === 0) {
|
|
219
239
|
throw new Error(
|
|
220
240
|
`Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
|
|
221
|
-
`[--target=sibling|knowledge/<subdir>] [--sync]\n` +
|
|
241
|
+
`[--target=sibling|knowledge/<subdir>] [--sync] [--strict] [--legacy-extractor]\n` +
|
|
242
|
+
` --strict: exit non-zero if any extraction drops occur\n` +
|
|
243
|
+
` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
|
|
222
244
|
` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
|
|
223
245
|
);
|
|
224
246
|
}
|
|
@@ -229,5 +251,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
229
251
|
);
|
|
230
252
|
}
|
|
231
253
|
|
|
232
|
-
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, env });
|
|
254
|
+
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, env });
|
|
233
255
|
}
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { dirname, join } from 'node:path';
|
|
|
30
30
|
|
|
31
31
|
import { loadEmbedConfig } from './config.mjs';
|
|
32
32
|
import { Scheduler } from './scheduler.mjs';
|
|
33
|
+
import { rotateIfOversized } from '../logging/rotate.mjs';
|
|
33
34
|
import { SnapshotEngine, renderMarkdown } from './snapshot.mjs';
|
|
34
35
|
import { ApprovalQueue } from './approval-queue.mjs';
|
|
35
36
|
import { dispatchOutputs } from './output.mjs';
|
|
@@ -594,6 +595,25 @@ export class EmbedDaemon {
|
|
|
594
595
|
},
|
|
595
596
|
);
|
|
596
597
|
|
|
598
|
+
// ── Job 6: daemon-log-rotation ────────────────────────────────────────────
|
|
599
|
+
// OS supervisors (launchd/systemd/schtasks) redirect daemon stdout to
|
|
600
|
+
// ~/.cx/runtime/embed-daemon.log with no rotation. A stuck telemetry
|
|
601
|
+
// log line filled it to 34 GB on one local install; rotate proactively.
|
|
602
|
+
// CONSTRUCT_EMBED_LOG_MAX_MB overrides the default 50 MB cap.
|
|
603
|
+
|
|
604
|
+
this.#scheduler.register(
|
|
605
|
+
'daemon-log-rotation',
|
|
606
|
+
60_000,
|
|
607
|
+
async () => {
|
|
608
|
+
const logPath = join(homedir(), '.cx', 'runtime', 'embed-daemon.log');
|
|
609
|
+
const capMb = Number(process.env.CONSTRUCT_EMBED_LOG_MAX_MB) || 50;
|
|
610
|
+
const maxBytes = Math.floor(capMb * 1024 * 1024);
|
|
611
|
+
const maxSegments = Number(process.env.CONSTRUCT_EMBED_LOG_MAX_SEGMENTS) || 5;
|
|
612
|
+
await rotateIfOversized(logPath, { maxBytes, maxSegments, gzip: true });
|
|
613
|
+
},
|
|
614
|
+
{ runImmediately: true },
|
|
615
|
+
);
|
|
616
|
+
|
|
597
617
|
// ── Job 0: telemetry-setup ───────────────────────────────────────────────────
|
|
598
618
|
// On startup — ensure annotation queues and eval configs exist (idempotent)
|
|
599
619
|
this.#scheduler.register(
|
|
@@ -1075,7 +1095,12 @@ this.#scheduler.register(
|
|
|
1075
1095
|
outputTokens = data.usage?.completion_tokens || 0;
|
|
1076
1096
|
}
|
|
1077
1097
|
}
|
|
1078
|
-
} catch {
|
|
1098
|
+
} catch (probeErr) {
|
|
1099
|
+
// Probe call failed; record the timing entry but surface the
|
|
1100
|
+
// failure to the daemon log so operators can distinguish a 0ms
|
|
1101
|
+
// success from a hard error. Don't crash the daemon.
|
|
1102
|
+
process.stderr.write(`[embed] Probe call failed: ${probeErr.message}\n`);
|
|
1103
|
+
}
|
|
1079
1104
|
|
|
1080
1105
|
const endTime = Date.now();
|
|
1081
1106
|
|