@geraldmaron/construct 1.0.0 → 1.0.1
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/.env.example +10 -7
- package/LICENSE +201 -21
- package/README.md +132 -257
- package/agents/contracts.json +387 -0
- package/agents/prompts/cx-accessibility.md +8 -0
- package/agents/prompts/cx-ai-engineer.md +92 -0
- package/agents/prompts/cx-architect.md +10 -2
- package/agents/prompts/cx-business-strategist.md +13 -0
- package/agents/prompts/cx-data-analyst.md +80 -0
- package/agents/prompts/cx-data-engineer.md +4 -0
- package/agents/prompts/cx-debugger.md +8 -0
- package/agents/prompts/cx-designer.md +19 -0
- package/agents/prompts/cx-devil-advocate.md +4 -0
- package/agents/prompts/cx-docs-keeper.md +128 -0
- package/agents/prompts/cx-engineer.md +13 -0
- package/agents/prompts/cx-evaluator.md +4 -0
- package/agents/prompts/cx-explorer.md +12 -0
- package/agents/prompts/cx-legal-compliance.md +13 -0
- package/agents/prompts/cx-operations.md +13 -0
- package/agents/prompts/cx-orchestrator.md +107 -4
- package/agents/prompts/cx-platform-engineer.md +75 -0
- package/agents/prompts/cx-product-manager.md +8 -0
- package/agents/prompts/cx-qa.md +100 -0
- package/agents/prompts/cx-rd-lead.md +13 -0
- package/agents/prompts/cx-release-manager.md +8 -0
- package/agents/prompts/cx-researcher.md +21 -1
- package/agents/prompts/cx-reviewer.md +8 -0
- package/agents/prompts/cx-security.md +104 -0
- package/agents/prompts/cx-sre.md +104 -0
- package/agents/prompts/cx-test-automation.md +4 -0
- package/agents/prompts/cx-trace-reviewer.md +7 -3
- package/agents/prompts/cx-ux-researcher.md +4 -0
- package/agents/registry.json +365 -90
- package/agents/role-manifests.json +217 -0
- package/bin/construct +3345 -141
- package/bin/construct-postinstall.mjs +87 -0
- package/commands/build/feature.md +6 -6
- package/commands/plan/feature.md +6 -5
- package/commands/plan/requirements.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/work/drive.md +3 -3
- package/commands/work/optimize-prompts.md +3 -3
- package/db/{migrations → schema}/001_init.sql +2 -0
- package/db/schema/002_pgvector.sql +182 -0
- package/db/schema/003_intake.sql +47 -0
- package/examples/README.md +85 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +30 -0
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +23 -0
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +30 -0
- package/examples/internal/roles/engineer/golden/read-before-write.md +28 -0
- package/examples/internal/roles/orchestrator/bad/everything-becomes-multi-agent.md +29 -0
- package/examples/internal/roles/orchestrator/golden/minimal-dispatch.md +22 -0
- package/examples/internal/roles/qa/bad/coverage-theater.md +30 -0
- package/examples/internal/roles/qa/golden/regression-gate.md +23 -0
- package/examples/internal/roles/reviewer/bad/lgtm-without-verification.md +29 -0
- package/examples/internal/roles/reviewer/golden/find-structural-risk-first.md +28 -0
- package/examples/personas/construct/adversarial/ignore-instruction-to-skip-approval.md +22 -0
- package/examples/personas/construct/bad/commit-without-approval.md +30 -0
- package/examples/personas/construct/boundary/blocked-needs-main-input.md +29 -0
- package/examples/personas/construct/golden/branch-approval-before-mutation.md +36 -0
- package/examples/personas/construct/golden/focused-direct-answer.md +28 -0
- package/examples/provider-plugin/README.md +34 -0
- package/examples/provider-plugin/index.mjs +74 -0
- package/examples/provider-plugin/package.json +15 -0
- package/examples/seed-observations/README.md +38 -0
- package/examples/seed-observations/anti-patterns.md +44 -0
- package/examples/seed-observations/decisions.md +36 -0
- package/examples/seed-observations/patterns.md +42 -0
- package/lib/agent-contracts-enforce.mjs +158 -0
- package/lib/agent-contracts.mjs +231 -0
- package/lib/agents/postconditions.mjs +126 -0
- package/lib/agents/schema.mjs +124 -0
- package/lib/artifact-capture.mjs +183 -0
- package/lib/audit-trail.mjs +149 -0
- package/lib/auto-docs.mjs +245 -65
- package/lib/beads/auto-close.mjs +126 -0
- package/lib/beads/drift.mjs +171 -0
- package/lib/beads-automation.mjs +542 -0
- package/lib/beads-client.mjs +518 -0
- package/lib/beads-lock.mjs +377 -0
- package/lib/beads-optimistic.mjs +365 -0
- package/lib/bootstrap/built-ins.mjs +136 -0
- package/lib/bootstrap/lazy-install.mjs +161 -0
- package/lib/bootstrap/resources.mjs +120 -0
- package/lib/bootstrap.mjs +105 -0
- package/lib/cache-governor.js +213 -0
- package/lib/cache-strategy-anthropic.js +62 -0
- package/lib/cache-strategy-google.js +79 -0
- package/lib/cache-strategy-none.js +30 -0
- package/lib/cache-strategy-openai.js +48 -0
- package/lib/cache-strategy.js +91 -0
- package/lib/claude-allow.mjs +149 -0
- package/lib/cli-commands.mjs +413 -258
- package/lib/codex-config.mjs +3 -1
- package/lib/comment-lint.mjs +55 -6
- package/lib/completions.mjs +21 -6
- package/lib/config/alias.mjs +56 -0
- package/lib/config/project-config.mjs +335 -0
- package/lib/config/schema.mjs +159 -0
- package/lib/context-router.mjs +308 -0
- package/lib/cost-ledger.mjs +177 -0
- package/lib/cost.mjs +171 -10
- package/lib/dashboard-static.mjs +158 -0
- package/lib/deployment-mode.mjs +86 -0
- package/lib/deprecate.mjs +49 -0
- package/lib/dispatch-batch.js +183 -0
- package/lib/distill.mjs +21 -8
- package/lib/doc-stamp.mjs +164 -0
- package/lib/doc-verify.mjs +119 -0
- package/lib/docs-routing.mjs +89 -0
- package/lib/docs-verify.mjs +417 -0
- package/lib/doctor/audit.mjs +71 -0
- package/lib/doctor/cli.mjs +99 -0
- package/lib/doctor/escalate.mjs +29 -0
- package/lib/doctor/index.mjs +140 -0
- package/lib/doctor/report.mjs +170 -0
- package/lib/doctor/watchers/bd-watch.mjs +117 -0
- package/lib/doctor/watchers/cost.mjs +130 -0
- package/lib/doctor/watchers/disk.mjs +122 -0
- package/lib/doctor/watchers/handoffs.mjs +33 -0
- package/lib/doctor/watchers/process-pressure.mjs +60 -0
- package/lib/doctor/watchers/service-health.mjs +188 -0
- package/lib/document-extract.mjs +288 -0
- package/lib/document-ingest.mjs +230 -0
- package/lib/drop.mjs +282 -0
- package/lib/embed/approval-queue.mjs +176 -0
- package/lib/embed/artifact.mjs +349 -0
- package/lib/embed/authority-guard.mjs +155 -0
- package/lib/embed/cli.mjs +408 -0
- package/lib/embed/config.mjs +355 -0
- package/lib/embed/conflict-detection.mjs +264 -0
- package/lib/embed/customer-profiles.mjs +480 -0
- package/lib/embed/daemon.mjs +1309 -0
- package/lib/embed/demand-fetch.mjs +449 -0
- package/lib/embed/docs-lifecycle.mjs +349 -0
- package/lib/embed/inbox-live-watcher.mjs +119 -0
- package/lib/embed/inbox.mjs +343 -0
- package/lib/embed/intake-metrics.mjs +190 -0
- package/lib/embed/jobs/vector-sync.mjs +198 -0
- package/lib/embed/notifications.mjs +75 -0
- package/lib/embed/output.mjs +79 -0
- package/lib/embed/providers/github.mjs +295 -0
- package/lib/embed/providers/jira.mjs +192 -0
- package/lib/embed/providers/linear.mjs +186 -0
- package/lib/embed/providers/registry.mjs +115 -0
- package/lib/embed/providers/slack.mjs +203 -0
- package/lib/embed/recommendation-store.mjs +378 -0
- package/lib/embed/roadmap.mjs +374 -0
- package/lib/embed/role-framing.mjs +110 -0
- package/lib/embed/scheduler.mjs +99 -0
- package/lib/embed/semantic.mjs +325 -0
- package/lib/embed/snapshot.mjs +191 -0
- package/lib/embed/supervision.mjs +235 -0
- package/lib/embed/target-resolver.mjs +186 -0
- package/lib/embed/worker.mjs +62 -0
- package/lib/embed/workspaces.mjs +297 -0
- package/lib/engine/chunker-headings.mjs +110 -0
- package/lib/engine/compressor-heuristic.mjs +100 -0
- package/lib/engine/consolidate.mjs +287 -0
- package/lib/engine/contracts.mjs +126 -0
- package/lib/engine/defaults.mjs +129 -0
- package/lib/engine/eval-retrieval.mjs +148 -0
- package/lib/engine/fuser-rrf.mjs +62 -0
- package/lib/engine/index.mjs +37 -0
- package/lib/engine/registry.mjs +146 -0
- package/lib/engine/reranker-mmr.mjs +90 -0
- package/lib/engine/tokens.mjs +77 -0
- package/lib/entity-store.mjs +280 -0
- package/lib/env-config.mjs +69 -4
- package/lib/evals/retrieval-bench.mjs +159 -0
- package/lib/evaluator-optimizer.mjs +317 -0
- package/lib/features.mjs +159 -29
- package/lib/gates-audit.mjs +236 -0
- package/lib/git-hooks/prepare-commit-msg +58 -0
- package/lib/handoffs/cleanup.mjs +159 -0
- package/lib/handoffs/contract.mjs +162 -0
- package/lib/handoffs/inventory.mjs +120 -0
- package/lib/headhunt.mjs +28 -47
- package/lib/health-check.mjs +399 -0
- package/lib/hook-health.mjs +442 -0
- package/lib/hooks/_lib/log.mjs +82 -0
- package/lib/hooks/adaptive-lint.mjs +26 -2
- package/lib/hooks/agent-tracker.mjs +171 -14
- package/lib/hooks/audit-reads.mjs +109 -0
- package/lib/hooks/audit-trail.mjs +153 -0
- package/lib/hooks/bash-output-logger.mjs +71 -0
- package/lib/hooks/block-no-verify.mjs +41 -0
- package/lib/hooks/ci-status-check.mjs +82 -0
- package/lib/hooks/comment-lint.mjs +29 -7
- package/lib/hooks/config-protection.mjs +39 -18
- package/lib/hooks/context-watch.mjs +137 -0
- package/lib/hooks/context-window-recovery.mjs +4 -20
- package/lib/hooks/dep-audit.mjs +16 -0
- package/lib/hooks/doc-coupling-check.mjs +80 -0
- package/lib/hooks/edit-accumulator.mjs +3 -0
- package/lib/hooks/edit-error-recovery.mjs +3 -0
- package/lib/hooks/edit-guard.mjs +45 -4
- package/lib/hooks/env-check.mjs +3 -0
- package/lib/hooks/guard-bash.mjs +58 -1
- package/lib/hooks/mcp-audit.mjs +18 -20
- package/lib/hooks/mcp-health-check.mjs +36 -0
- package/lib/hooks/model-fallback.mjs +42 -56
- package/lib/hooks/policy-engine.mjs +209 -0
- package/lib/hooks/post-merge-docs-check.mjs +63 -0
- package/lib/hooks/pre-compact.mjs +4 -24
- package/lib/hooks/pre-push-gate.mjs +200 -37
- package/lib/hooks/proactive-activation.mjs +284 -0
- package/lib/hooks/read-tracker.mjs +27 -4
- package/lib/hooks/readme-age-check.mjs +77 -0
- package/lib/hooks/registry-sync.mjs +12 -5
- package/lib/hooks/scan-secrets.mjs +50 -7
- package/lib/hooks/session-optimize.mjs +311 -0
- package/lib/hooks/session-start.mjs +287 -28
- package/lib/hooks/stop-notify.mjs +236 -96
- package/lib/hooks/stop-typecheck.mjs +13 -1
- package/lib/hooks/test-watch.mjs +68 -0
- package/lib/host-capabilities.mjs +31 -10
- package/lib/init-docs.mjs +731 -291
- package/lib/init-unified.mjs +1116 -0
- package/lib/init-update.mjs +168 -0
- package/lib/init.mjs +107 -0
- package/lib/install/first-invocation.mjs +119 -0
- package/lib/install/stage-project.mjs +69 -0
- package/lib/intake/classify.mjs +278 -0
- package/lib/intake/feedback.mjs +273 -0
- package/lib/intake/filesystem-queue.mjs +158 -0
- package/lib/intake/intake-config.mjs +132 -0
- package/lib/intake/postgres-queue.mjs +198 -0
- package/lib/intake/prepare.mjs +139 -0
- package/lib/intake/queue.mjs +83 -0
- package/lib/intake/session-prelude.mjs +50 -0
- package/lib/integrations/intake-integrations.mjs +740 -0
- package/lib/intent-classifier.mjs +253 -0
- package/lib/knowledge/layout.mjs +72 -0
- package/lib/knowledge/rag.mjs +331 -0
- package/lib/knowledge/search.mjs +315 -0
- package/lib/knowledge/trends.mjs +261 -0
- package/lib/logger.mjs +85 -0
- package/lib/mcp/broker.mjs +124 -0
- package/lib/mcp/server.mjs +554 -854
- package/lib/mcp/tools/document.mjs +132 -0
- package/lib/mcp/tools/memory.mjs +209 -0
- package/lib/mcp/tools/project.mjs +349 -0
- package/lib/mcp/tools/skills.mjs +409 -0
- package/lib/mcp/tools/storage.mjs +60 -0
- package/lib/mcp/tools/telemetry.mjs +315 -0
- package/lib/mcp/tools/workflow.mjs +112 -0
- package/lib/mcp-catalog.json +54 -3
- package/lib/mcp-manager.mjs +96 -48
- package/lib/mcp-platform-config.mjs +22 -22
- package/lib/memory-stats.mjs +121 -0
- package/lib/mode-commands.mjs +124 -0
- package/lib/model-free-selector.mjs +184 -0
- package/lib/model-pricing.mjs +152 -0
- package/lib/model-registry.mjs +226 -0
- package/lib/model-router.mjs +362 -386
- package/lib/observation-store.mjs +391 -0
- package/lib/ollama-manager.mjs +428 -0
- package/lib/opencode-config.mjs +13 -3
- package/lib/opencode-runtime-plugin.mjs +70 -38
- package/lib/opencode-telemetry.mjs +64 -6
- package/lib/orchestration-policy.mjs +509 -6
- package/lib/overrides/resolver.mjs +207 -0
- package/lib/parity.mjs +147 -0
- package/lib/paths.mjs +33 -0
- package/lib/performance/generate.mjs +212 -0
- package/lib/plugin-registry.mjs +268 -0
- package/lib/policy/engine.mjs +130 -0
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-detection.mjs +129 -0
- package/lib/project-init-shared.mjs +272 -0
- package/lib/project-profile.mjs +447 -0
- package/lib/prompt-composer.js +434 -0
- package/lib/prompt-metadata.mjs +1 -1
- package/lib/provider-capabilities-anthropic.js +44 -0
- package/lib/provider-capabilities-deepseek.js +37 -0
- package/lib/provider-capabilities-generic.js +26 -0
- package/lib/provider-capabilities-google.js +47 -0
- package/lib/provider-capabilities-openai.js +45 -0
- package/lib/provider-capabilities.js +142 -0
- package/lib/providers/atlassian-confluence/index.mjs +103 -0
- package/lib/providers/atlassian-jira/index.mjs +100 -0
- package/lib/providers/auth-manager.mjs +126 -0
- package/lib/providers/circuit-breaker.mjs +124 -0
- package/lib/providers/contract.mjs +93 -0
- package/lib/providers/github/index.mjs +126 -0
- package/lib/providers/registry.mjs +184 -0
- package/lib/providers/salesforce/index.mjs +100 -0
- package/lib/providers/slack/index.mjs +80 -0
- package/lib/reflect.mjs +137 -0
- package/lib/research-lint.mjs +164 -0
- package/lib/resources/budget.mjs +259 -0
- package/lib/role-preload.mjs +21 -6
- package/lib/roles/approval-surface.mjs +54 -0
- package/lib/roles/cli.mjs +118 -0
- package/lib/roles/event-bus.mjs +79 -0
- package/lib/roles/fence.mjs +84 -0
- package/lib/roles/gateway.mjs +260 -0
- package/lib/roles/hook-emit.mjs +37 -0
- package/lib/roles/manifest.mjs +48 -0
- package/lib/roles/router.mjs +27 -0
- package/lib/runtime-pressure.mjs +360 -0
- package/lib/schema-artifact.mjs +134 -0
- package/lib/schema-infer.mjs +551 -0
- package/lib/server/auth.mjs +168 -0
- package/lib/server/chat.mjs +336 -0
- package/lib/server/cors.mjs +77 -0
- package/lib/server/csrf.mjs +91 -0
- package/lib/server/index.mjs +1927 -78
- package/lib/server/insights.mjs +765 -0
- package/lib/server/rate-limit.mjs +91 -0
- package/lib/server/static/assets/index-ab25c707.js +70 -0
- package/lib/server/static/assets/index-f0c80a2b.css +1 -0
- package/lib/server/static/index.html +12 -817
- package/lib/server/telemetry-login.mjs +108 -0
- package/lib/server/webhook.mjs +510 -0
- package/lib/service-manager.mjs +522 -58
- package/lib/services/pattern-promotion-service.mjs +167 -0
- package/lib/services/telemetry-backend.mjs +178 -0
- package/lib/session-store.mjs +374 -0
- package/lib/setup-prompts.mjs +96 -0
- package/lib/setup.mjs +525 -38
- package/lib/skills-apply.mjs +280 -0
- package/lib/skills-scope.mjs +118 -0
- package/lib/status.mjs +261 -70
- package/lib/storage/admin.mjs +355 -0
- package/lib/storage/backend.mjs +2 -1
- package/lib/storage/backup.mjs +347 -0
- package/lib/storage/embeddings-engine.mjs +133 -0
- package/lib/storage/embeddings-legacy.mjs +85 -0
- package/lib/storage/embeddings-local.mjs +108 -0
- package/lib/storage/embeddings-ollama.mjs +78 -0
- package/lib/storage/embeddings-openai.mjs +85 -0
- package/lib/storage/embeddings.mjs +92 -33
- package/lib/storage/file-lock.mjs +130 -0
- package/lib/storage/fusion.mjs +95 -0
- package/lib/storage/hybrid-query.mjs +34 -27
- package/lib/storage/migrations.mjs +187 -0
- package/lib/storage/postgres-backup.mjs +124 -0
- package/lib/storage/sql-store.mjs +5 -15
- package/lib/storage/state-source.mjs +12 -13
- package/lib/storage/store-version.mjs +115 -0
- package/lib/storage/sync.mjs +144 -35
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/storage/vector-client.mjs +286 -0
- package/lib/storage/vector-store.mjs +71 -30
- package/lib/task-graph/generate.mjs +135 -0
- package/lib/task-graph/schema.mjs +81 -0
- package/lib/task-graph/store.mjs +71 -0
- package/lib/telemetry/backends/local.mjs +62 -0
- package/lib/telemetry/backends/{langfuse.mjs → remote.mjs} +27 -14
- package/lib/telemetry/backfill.mjs +180 -0
- package/lib/telemetry/eval-datasets.mjs +203 -0
- package/lib/telemetry/{langfuse-ingest.mjs → ingest.mjs} +26 -20
- package/lib/telemetry/intent-verifications.mjs +86 -0
- package/lib/telemetry/llm-judge.mjs +350 -0
- package/lib/telemetry/model-pricing-catalog.mjs +557 -0
- package/lib/telemetry/setup.mjs +151 -0
- package/lib/telemetry/skill-calls.mjs +78 -0
- package/lib/telemetry/team-rollup.mjs +4 -4
- package/lib/token-engine.js +117 -0
- package/lib/token-estimator-anthropic.js +15 -0
- package/lib/token-estimator-deepseek.js +13 -0
- package/lib/token-estimator-default.js +13 -0
- package/lib/token-estimator-google.js +13 -0
- package/lib/token-estimator-openai.js +13 -0
- package/lib/toolkit-env.mjs +1 -1
- package/lib/tty-prompts.mjs +211 -0
- package/lib/uninstall/uninstall.mjs +423 -0
- package/lib/update.mjs +115 -0
- package/lib/upgrade.mjs +141 -0
- package/lib/validator.mjs +51 -2
- package/lib/validators/skills.mjs +142 -0
- package/lib/wireframe.mjs +422 -0
- package/lib/worker/entrypoint.mjs +241 -0
- package/lib/worker/evidence.mjs +107 -0
- package/lib/worker/run.mjs +154 -0
- package/lib/worker/trace.mjs +182 -0
- package/lib/workflow-state.mjs +14 -18
- package/package.json +21 -8
- package/personas/construct.md +53 -51
- package/platforms/claude/CLAUDE.md +1 -1
- package/platforms/claude/settings.template.json +115 -68
- package/platforms/opencode/config.template.json +3 -7
- package/rules/common/agents.md +11 -38
- package/rules/common/beads-hygiene.md +75 -0
- package/rules/common/code-review.md +11 -100
- package/rules/common/coding-style.md +5 -3
- package/rules/common/comments.md +30 -111
- package/rules/common/commit-approval.md +53 -0
- package/rules/common/cx-agent-routing.md +1 -0
- package/rules/common/development-workflow.md +23 -41
- package/rules/common/doc-ownership.md +54 -0
- package/rules/common/efficiency.md +57 -0
- package/rules/common/framing.md +76 -0
- package/rules/common/git-workflow.md +2 -4
- package/rules/common/patterns.md +3 -7
- package/rules/common/performance.md +15 -31
- package/rules/common/release-gates.md +69 -0
- package/rules/common/research.md +107 -0
- package/rules/common/security.md +6 -6
- package/rules/common/skill-composition.md +67 -0
- package/rules/common/testing.md +15 -27
- package/rules/golang/hooks.md +0 -4
- package/rules/policy/bootstrap.yaml +11 -0
- package/rules/policy/drive.yaml +8 -0
- package/rules/policy/task.yaml +9 -0
- package/rules/policy/workflow.yaml +9 -0
- package/rules/python/hooks.md +0 -4
- package/rules/swift/hooks.md +0 -4
- package/rules/typescript/hooks.md +0 -4
- package/rules/web/hooks.md +0 -2
- package/{sync-agents.mjs → scripts/sync-agents.mjs} +306 -61
- package/skills/ai/prompt-optimizer.md +7 -7
- package/skills/compliance/ai-disclosure.md +58 -0
- package/skills/compliance/data-privacy.md +46 -0
- package/skills/compliance/license-audit.md +40 -0
- package/skills/compliance/regulatory-review.md +61 -0
- package/skills/docs/document-ingest-workflow.md +52 -0
- package/skills/docs/evidence-ingest-workflow.md +9 -0
- package/skills/docs/init-docs.md +51 -18
- package/skills/docs/product-intelligence-workflow.md +12 -0
- package/skills/docs/product-signal-workflow.md +4 -0
- package/skills/docs/research-workflow.md +23 -8
- package/skills/docs/runbook-workflow.md +1 -1
- package/skills/operating/orchestration-reference.md +151 -0
- package/skills/roles/architect.md +5 -0
- package/skills/roles/engineer.md +32 -0
- package/skills/roles/operator.md +12 -0
- package/skills/roles/reviewer.md +23 -0
- package/skills/routing.md +1 -1
- package/templates/devcontainer/Dockerfile.devcontainer +38 -0
- package/templates/devcontainer/devcontainer.json +31 -0
- package/templates/distribution/bootstrap.ps1 +142 -0
- package/templates/distribution/bootstrap.sh +196 -0
- package/templates/distribution/run.mjs +187 -0
- package/templates/docs/adr.md +44 -8
- package/templates/docs/changelog-entry.md +43 -0
- package/templates/docs/construct_guide.md +149 -0
- package/templates/docs/evidence-brief.md +2 -2
- package/templates/docs/meta-prd.md +140 -19
- package/templates/docs/onboarding.md +57 -0
- package/templates/docs/prd.md +159 -15
- package/templates/docs/research-brief.md +4 -4
- package/templates/homebrew/construct.rb +67 -0
- package/langfuse/docker-compose.yml +0 -82
- package/lib/eval-harness.mjs +0 -59
- package/lib/hooks/bootstrap-guard.mjs +0 -90
- package/lib/hooks/console-warn.mjs +0 -43
- package/lib/hooks/continuation-enforcer.mjs +0 -72
- package/lib/hooks/drive-guard.mjs +0 -89
- package/lib/hooks/mcp-task-scope.mjs +0 -47
- package/lib/hooks/task-completed-guard.mjs +0 -43
- package/lib/hooks/teammate-idle-guard.mjs +0 -54
- package/lib/hooks/workflow-guard.mjs +0 -62
- package/lib/prompt-composer.mjs +0 -196
- package/lib/review.mjs +0 -429
- package/lib/server/static/app.js +0 -841
- package/lib/telemetry/langfuse-model-sync.mjs +0 -270
- package/rules/common/hooks.md +0 -35
- package/rules/zh/README.md +0 -113
- package/rules/zh/agents.md +0 -55
- package/rules/zh/code-review.md +0 -129
- package/rules/zh/coding-style.md +0 -53
- package/rules/zh/development-workflow.md +0 -49
- package/rules/zh/git-workflow.md +0 -29
- package/rules/zh/hooks.md +0 -35
- package/rules/zh/patterns.md +0 -36
- package/rules/zh/performance.md +0 -60
- package/rules/zh/security.md +0 -34
- package/rules/zh/testing.md +0 -34
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/schema-infer.mjs — infer a structured field schema from extracted document text.
|
|
3
|
+
*
|
|
4
|
+
* Calls the model with a structured-output prompt and returns a normalised
|
|
5
|
+
* SchemaInferenceResult. Works with any document type already handled by
|
|
6
|
+
* lib/document-extract.mjs. Falls back gracefully when no API key is present.
|
|
7
|
+
* Uses the configured fast-tier model from model-router rather than a hardcoded ID.
|
|
8
|
+
*
|
|
9
|
+
* Output shape (SchemaInferenceResult):
|
|
10
|
+
* {
|
|
11
|
+
* document_type: string, // e.g. "invoice", "resume", "table", "report"
|
|
12
|
+
* fields: SchemaField[],
|
|
13
|
+
* relationships: Relationship[], // cross-field or cross-record links
|
|
14
|
+
* confidence: number, // 0.0 – 1.0
|
|
15
|
+
* notes: string[], // caveats about truncation, ambiguity, etc.
|
|
16
|
+
* model: string, // model used for inference
|
|
17
|
+
* inferred_at: string, // ISO timestamp
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* SchemaField:
|
|
21
|
+
* { name, type, description, required, pattern, example, enum, format, nested_fields }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { extractDocumentText } from './document-extract.mjs';
|
|
25
|
+
import { readCurrentModels, readOpenRouterApiKeyFromOpenCodeConfig } from './model-router.mjs';
|
|
26
|
+
import { getUserEnvPath } from './env-config.mjs';
|
|
27
|
+
import { pollFreeModels, topForTier } from './model-free-selector.mjs';
|
|
28
|
+
import { execSync } from 'node:child_process';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Telemetry helpers — fire-and-forget, never throw into the caller.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function resolveSessionMeta() {
|
|
35
|
+
const userId = process.env.USER || process.env.USERNAME || process.env.LOGNAME || undefined;
|
|
36
|
+
const sessionId = process.env.CONSTRUCT_SESSION_ID
|
|
37
|
+
|| process.env.CLAUDE_SESSION_ID
|
|
38
|
+
|| process.env.CX_SESSION_ID
|
|
39
|
+
|| process.env.OPENCODE_SESSION_ID
|
|
40
|
+
|| undefined;
|
|
41
|
+
const environment = process.env.NODE_ENV || process.env.CONSTRUCT_ENV || 'development';
|
|
42
|
+
let release;
|
|
43
|
+
try {
|
|
44
|
+
release = execSync('git rev-parse --short HEAD', { stdio: 'pipe', timeout: 2000 }).toString().trim() || undefined;
|
|
45
|
+
} catch { /* best effort */ }
|
|
46
|
+
return { userId, sessionId, environment, release };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function telemetryAvailable() {
|
|
50
|
+
return Boolean(process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY && process.env.CONSTRUCT_TELEMETRY_SECRET_KEY);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function telemetryBaseUrl() {
|
|
54
|
+
return (process.env.CONSTRUCT_TELEMETRY_URL ?? '').replace(/\/$/, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function telemetryHeaders() {
|
|
58
|
+
const key = process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY;
|
|
59
|
+
const secret = process.env.CONSTRUCT_TELEMETRY_SECRET_KEY;
|
|
60
|
+
return {
|
|
61
|
+
Authorization: `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`,
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function telIngest(body) {
|
|
67
|
+
if (!telemetryAvailable()) return;
|
|
68
|
+
try {
|
|
69
|
+
await fetch(`${telemetryBaseUrl()}/api/public/ingestion`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: telemetryHeaders(),
|
|
72
|
+
body: JSON.stringify({ batch: Array.isArray(body) ? body : [body] }),
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
// Observability must never break the caller.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function telTrace({ id, name, input, metadata, sessionId, userId, release, environment }) {
|
|
80
|
+
await telIngest({
|
|
81
|
+
id: crypto.randomUUID(),
|
|
82
|
+
type: 'trace-create',
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
body: { id, name, input, metadata, sessionId, userId, release, environment, tags: ['schema-infer'] },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function telGeneration({ traceId, id, name, model, input, output, startTime, endTime, level, statusMessage, usage }) {
|
|
89
|
+
await telIngest({
|
|
90
|
+
id: crypto.randomUUID(),
|
|
91
|
+
type: 'generation-create',
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
body: {
|
|
94
|
+
id,
|
|
95
|
+
traceId,
|
|
96
|
+
name,
|
|
97
|
+
model,
|
|
98
|
+
input,
|
|
99
|
+
output,
|
|
100
|
+
startTime,
|
|
101
|
+
endTime,
|
|
102
|
+
level: level ?? 'DEFAULT',
|
|
103
|
+
statusMessage,
|
|
104
|
+
...(usage ? { usage: { input: usage.input ?? 0, output: usage.output ?? 0, total: usage.total ?? 0, unit: 'TOKENS' } } : {}),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function telScore({ traceId, name, value, comment }) {
|
|
110
|
+
if (!telemetryAvailable()) return;
|
|
111
|
+
try {
|
|
112
|
+
await fetch(`${telemetryBaseUrl()}/api/public/scores`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: telemetryHeaders(),
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
id: crypto.randomUUID(),
|
|
117
|
+
traceId,
|
|
118
|
+
name,
|
|
119
|
+
value,
|
|
120
|
+
dataType: 'NUMERIC',
|
|
121
|
+
comment,
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// Observability must never break the caller.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function lfUpdateTrace({ id, output, metadata }) {
|
|
130
|
+
await telIngest({
|
|
131
|
+
id: crypto.randomUUID(),
|
|
132
|
+
type: 'trace-create',
|
|
133
|
+
timestamp: new Date().toISOString(),
|
|
134
|
+
body: { id, output, metadata },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SYSTEM_PROMPT = `You are a data modeling expert. Analyse the document text provided and infer a structured schema.
|
|
139
|
+
|
|
140
|
+
Return ONLY a valid JSON object matching this exact shape — no markdown fences, no commentary:
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
"document_type": "<concise label, e.g. invoice | resume | table | report | contract | form | log | config>",
|
|
144
|
+
"fields": [
|
|
145
|
+
{
|
|
146
|
+
"name": "<normalized snake_case field name>",
|
|
147
|
+
"type": "<string | number | boolean | date | datetime | array | object | enum | unknown>",
|
|
148
|
+
"description": "<what this field represents>",
|
|
149
|
+
"required": <true|false>,
|
|
150
|
+
"pattern": "<regex pattern if applicable, or null>",
|
|
151
|
+
"example": "<representative value from the document, or null>",
|
|
152
|
+
"enum": [<allowed values if finite set, or null>],
|
|
153
|
+
"format": "<e.g. currency:USD | phone | email | url | iso-date | iso-datetime | or null>",
|
|
154
|
+
"nested_fields": [<recursive SchemaField array for object/array types, or null>]
|
|
155
|
+
}
|
|
156
|
+
],
|
|
157
|
+
"relationships": [
|
|
158
|
+
{
|
|
159
|
+
"from_field": "<field name>",
|
|
160
|
+
"to_field": "<field name>",
|
|
161
|
+
"type": "<reference | composition | aggregation | derivation>",
|
|
162
|
+
"description": "<what the relationship means>"
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
"confidence": <0.0 to 1.0>,
|
|
166
|
+
"notes": ["<caveat or observation about the schema, e.g. truncation, ambiguous fields>"]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
Rules:
|
|
170
|
+
- Infer field names from labels, headers, column names, or key patterns in the text.
|
|
171
|
+
- For tabular data (CSV, spreadsheet, table), every column becomes a top-level field.
|
|
172
|
+
- For structured documents (invoices, forms), identify all distinct data points.
|
|
173
|
+
- For narrative documents, identify the key entities and their attributes.
|
|
174
|
+
- Set confidence lower when the document is truncated, sparse, or ambiguous.
|
|
175
|
+
- Do NOT invent fields not evidenced in the text.
|
|
176
|
+
- Keep field names snake_case, lowercase.`;
|
|
177
|
+
|
|
178
|
+
const INFERENCE_TIMEOUT_MS = 30_000;
|
|
179
|
+
|
|
180
|
+
async function fetchWithTimeout(url, options) {
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timer = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
|
|
183
|
+
try {
|
|
184
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
185
|
+
} finally {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function callModel(userContent, { traceId } = {}) {
|
|
191
|
+
const orKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
|
|
192
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
193
|
+
|
|
194
|
+
if (!orKey && !anthropicKey) {
|
|
195
|
+
throw new Error('No API key found. Set ANTHROPIC_API_KEY or OPENROUTER_API_KEY to use schema inference.');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build a ranked candidate list: live free models (top 5) + configured fast tier as final fallback.
|
|
199
|
+
let candidates = [];
|
|
200
|
+
if (orKey) {
|
|
201
|
+
const freeModels = await pollFreeModels(orKey);
|
|
202
|
+
const envPath = getUserEnvPath();
|
|
203
|
+
const configured = readCurrentModels(envPath, {});
|
|
204
|
+
candidates = topForTier(freeModels, 'fast', 5).map((m) => m.id);
|
|
205
|
+
|
|
206
|
+
// Append configured fast tier as final fallback if not already in the list.
|
|
207
|
+
if (configured.fast && !candidates.includes(configured.fast)) {
|
|
208
|
+
candidates.push(configured.fast);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// If only Anthropic key available, use configured fast tier directly.
|
|
213
|
+
if (candidates.length === 0 && anthropicKey) {
|
|
214
|
+
const envPath = getUserEnvPath();
|
|
215
|
+
candidates = [readCurrentModels(envPath, {}).fast];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let lastError;
|
|
219
|
+
for (const modelId of candidates) {
|
|
220
|
+
const isAnthropicDirect = /^anthropic\//.test(modelId) && !/^openrouter\//.test(modelId);
|
|
221
|
+
const isOpenRouter = /^openrouter\//.test(modelId);
|
|
222
|
+
|
|
223
|
+
const anthropicModelId = modelId.replace(/^anthropic\//, '');
|
|
224
|
+
const openRouterModelId = isOpenRouter ? modelId.replace(/^openrouter\//, '') : modelId;
|
|
225
|
+
|
|
226
|
+
const genId = crypto.randomUUID();
|
|
227
|
+
const startTime = new Date().toISOString();
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
if (isAnthropicDirect && anthropicKey) {
|
|
231
|
+
const res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: {
|
|
234
|
+
'x-api-key': anthropicKey,
|
|
235
|
+
'anthropic-version': '2023-06-01',
|
|
236
|
+
'content-type': 'application/json',
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
model: anthropicModelId,
|
|
240
|
+
max_tokens: 4096,
|
|
241
|
+
system: SYSTEM_PROMPT,
|
|
242
|
+
messages: [{ role: 'user', content: userContent }],
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
if (res.status === 429) {
|
|
246
|
+
lastError = new Error(`Anthropic rate-limited on ${modelId}`);
|
|
247
|
+
void telGeneration({ traceId, id: genId, name: 'schema-infer-attempt', model: modelId, input: userContent, startTime, endTime: new Date().toISOString(), level: 'WARNING', statusMessage: 'rate-limited' });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${await res.text()}`);
|
|
251
|
+
const data = await res.json();
|
|
252
|
+
const text = data.content[0].text;
|
|
253
|
+
const usage = data.usage ? { input: data.usage.input_tokens ?? 0, output: data.usage.output_tokens ?? 0, total: (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0) } : undefined;
|
|
254
|
+
void telGeneration({ traceId, id: genId, name: 'schema-infer-attempt', model: modelId, input: userContent, output: text, startTime, endTime: new Date().toISOString(), usage });
|
|
255
|
+
return { text, model: modelId };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (isOpenRouter && orKey) {
|
|
259
|
+
const res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: {
|
|
262
|
+
Authorization: `Bearer ${orKey}`,
|
|
263
|
+
'content-type': 'application/json',
|
|
264
|
+
'HTTP-Referer': 'https://github.com/construct',
|
|
265
|
+
},
|
|
266
|
+
body: JSON.stringify({
|
|
267
|
+
model: openRouterModelId,
|
|
268
|
+
max_tokens: 4096,
|
|
269
|
+
messages: [
|
|
270
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
271
|
+
{ role: 'user', content: userContent },
|
|
272
|
+
],
|
|
273
|
+
}),
|
|
274
|
+
});
|
|
275
|
+
if (res.status === 429) {
|
|
276
|
+
lastError = new Error(`OpenRouter rate-limited on ${modelId}`);
|
|
277
|
+
void telGeneration({ traceId, id: genId, name: 'schema-infer-attempt', model: modelId, input: userContent, startTime, endTime: new Date().toISOString(), level: 'WARNING', statusMessage: 'rate-limited' });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!res.ok) throw new Error(`OpenRouter API ${res.status}: ${await res.text()}`);
|
|
281
|
+
const data = await res.json();
|
|
282
|
+
const content = data.choices?.[0]?.message?.content;
|
|
283
|
+
if (!content) {
|
|
284
|
+
lastError = new Error(`OpenRouter returned empty response from ${modelId}`);
|
|
285
|
+
void telGeneration({ traceId, id: genId, name: 'schema-infer-attempt', model: modelId, input: userContent, startTime, endTime: new Date().toISOString(), level: 'WARNING', statusMessage: 'empty-response' });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const usage = data.usage ? { input: data.usage.prompt_tokens ?? 0, output: data.usage.completion_tokens ?? 0, total: data.usage.total_tokens ?? 0 } : undefined;
|
|
289
|
+
void telGeneration({ traceId, id: genId, name: 'schema-infer-attempt', model: modelId, input: userContent, output: content, startTime, endTime: new Date().toISOString(), usage });
|
|
290
|
+
return { text: content, model: modelId };
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// Re-throw non-rate-limit, non-timeout errors immediately.
|
|
294
|
+
if (!/rate.limit|429/i.test(err.message) && err.name !== 'AbortError') throw err;
|
|
295
|
+
lastError = err.name === 'AbortError'
|
|
296
|
+
? new Error(`Timed out waiting for ${modelId} after ${INFERENCE_TIMEOUT_MS}ms`)
|
|
297
|
+
: err;
|
|
298
|
+
void telGeneration({ traceId, id: genId, name: 'schema-infer-attempt', model: modelId, input: userContent, startTime, endTime: new Date().toISOString(), level: 'ERROR', statusMessage: lastError.message });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
throw lastError ?? new Error('All model candidates exhausted for schema inference');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parseInferenceResponse(raw) {
|
|
306
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
307
|
+
|
|
308
|
+
// Strip markdown fences if the model emits them despite instructions.
|
|
309
|
+
const stripped = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim();
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const parsed = JSON.parse(stripped);
|
|
313
|
+
if (!parsed || typeof parsed !== 'object') throw new Error('not an object');
|
|
314
|
+
return parsed;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
throw new Error(`Schema inference returned invalid JSON: ${err.message}\n---\n${text.slice(0, 500)}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function buildUserPrompt(extracted) {
|
|
321
|
+
const truncationNote = extracted.truncated
|
|
322
|
+
? `\n\n[NOTE: document was truncated to ${extracted.characters.toLocaleString()} characters]`
|
|
323
|
+
: '';
|
|
324
|
+
|
|
325
|
+
return [
|
|
326
|
+
`Document path: ${extracted.filePath}`,
|
|
327
|
+
`Document type hint: ${extracted.extension}`,
|
|
328
|
+
`Extraction method: ${extracted.extractionMethod}`,
|
|
329
|
+
'',
|
|
330
|
+
'--- BEGIN DOCUMENT TEXT ---',
|
|
331
|
+
extracted.text,
|
|
332
|
+
'--- END DOCUMENT TEXT ---',
|
|
333
|
+
truncationNote,
|
|
334
|
+
].join('\n');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Infer a structured schema from a document file.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} filePath Absolute or relative path to the source document.
|
|
341
|
+
* @param {object} opts
|
|
342
|
+
* @param {number} [opts.maxChars=40000] Characters of document text to send to the model.
|
|
343
|
+
* @returns {Promise<SchemaInferenceResult>}
|
|
344
|
+
*/
|
|
345
|
+
export async function inferDocumentSchema(filePath, { maxChars = 40_000 } = {}) {
|
|
346
|
+
const traceId = crypto.randomUUID();
|
|
347
|
+
const traceName = 'schema-infer';
|
|
348
|
+
const { userId, sessionId, environment, release } = resolveSessionMeta();
|
|
349
|
+
|
|
350
|
+
void telTrace({
|
|
351
|
+
id: traceId,
|
|
352
|
+
name: traceName,
|
|
353
|
+
input: { filePath, maxChars },
|
|
354
|
+
metadata: { source: 'schema-infer', filePath },
|
|
355
|
+
sessionId,
|
|
356
|
+
userId,
|
|
357
|
+
release,
|
|
358
|
+
environment,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const extracted = extractDocumentText(filePath, { maxChars });
|
|
362
|
+
if (!extracted.text || extracted.text.trim().length === 0) {
|
|
363
|
+
void lfUpdateTrace({ id: traceId, output: { error: 'no-text-extracted' }, metadata: { status: 'error' } });
|
|
364
|
+
throw new Error(`No text could be extracted from: ${filePath}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const userPrompt = buildUserPrompt(extracted);
|
|
368
|
+
|
|
369
|
+
let callResult;
|
|
370
|
+
try {
|
|
371
|
+
callResult = await callModel(userPrompt, { traceId });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
void lfUpdateTrace({ id: traceId, output: { error: err.message }, metadata: { status: 'error' } });
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const parsed = parseInferenceResponse(callResult.text);
|
|
378
|
+
|
|
379
|
+
const result = {
|
|
380
|
+
document_type: String(parsed.document_type || 'unknown'),
|
|
381
|
+
fields: Array.isArray(parsed.fields) ? parsed.fields : [],
|
|
382
|
+
relationships: Array.isArray(parsed.relationships) ? parsed.relationships : [],
|
|
383
|
+
confidence: typeof parsed.confidence === 'number' ? Math.min(1, Math.max(0, parsed.confidence)) : 0,
|
|
384
|
+
notes: Array.isArray(parsed.notes) ? parsed.notes : [],
|
|
385
|
+
source_path: extracted.filePath,
|
|
386
|
+
source_extension: extracted.extension,
|
|
387
|
+
source_characters: extracted.characters,
|
|
388
|
+
source_truncated: extracted.truncated,
|
|
389
|
+
model: callResult.model,
|
|
390
|
+
inferred_at: new Date().toISOString(),
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
void lfUpdateTrace({
|
|
394
|
+
id: traceId,
|
|
395
|
+
output: { document_type: result.document_type, field_count: result.fields.length, confidence: result.confidence, model: result.model },
|
|
396
|
+
metadata: { status: 'success', model: result.model },
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
void telScore({ traceId, name: 'confidence', value: result.confidence, comment: `${result.fields.length} fields inferred from ${result.source_extension ?? 'unknown'} document` });
|
|
400
|
+
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Infer schemas from multiple documents and return a reconciled unified schema.
|
|
406
|
+
*
|
|
407
|
+
* Useful when N documents share a format (e.g. 50 invoices → one canonical schema).
|
|
408
|
+
* Fields that appear in ≥ threshold fraction of documents are kept; required is set
|
|
409
|
+
* to true only when required across all sampled documents.
|
|
410
|
+
*
|
|
411
|
+
* @param {string[]} filePaths
|
|
412
|
+
* @param {object} opts
|
|
413
|
+
* @param {number} [opts.maxChars=40000]
|
|
414
|
+
* @param {number} [opts.sampleSize=10] Max documents to sample.
|
|
415
|
+
* @param {number} [opts.threshold=0.5] Field inclusion threshold (0–1).
|
|
416
|
+
* @returns {Promise<UnifiedSchemaResult>}
|
|
417
|
+
*/
|
|
418
|
+
export async function inferUnifiedSchema(filePaths, { maxChars = 40_000, sampleSize = 10, threshold = 0.5 } = {}) {
|
|
419
|
+
if (!Array.isArray(filePaths) || filePaths.length === 0) {
|
|
420
|
+
throw new Error('At least one file path is required');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const traceId = crypto.randomUUID();
|
|
424
|
+
const { userId, sessionId, environment, release } = resolveSessionMeta();
|
|
425
|
+
|
|
426
|
+
void telTrace({
|
|
427
|
+
id: traceId,
|
|
428
|
+
name: 'schema-infer-unified',
|
|
429
|
+
input: { filePaths, maxChars, sampleSize, threshold },
|
|
430
|
+
metadata: { source: 'schema-infer', fileCount: filePaths.length },
|
|
431
|
+
sessionId,
|
|
432
|
+
userId,
|
|
433
|
+
release,
|
|
434
|
+
environment,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const sample = filePaths.slice(0, sampleSize);
|
|
438
|
+
const results = await Promise.allSettled(sample.map((fp) => inferDocumentSchema(fp, { maxChars })));
|
|
439
|
+
|
|
440
|
+
const successes = results
|
|
441
|
+
.filter((r) => r.status === 'fulfilled')
|
|
442
|
+
.map((r) => r.value);
|
|
443
|
+
|
|
444
|
+
const failures = results
|
|
445
|
+
.filter((r) => r.status === 'rejected')
|
|
446
|
+
.map((r, i) => ({ file: sample[i], error: r.reason?.message || String(r.reason) }));
|
|
447
|
+
|
|
448
|
+
if (successes.length === 0) {
|
|
449
|
+
void lfUpdateTrace({ id: traceId, output: { error: 'all-documents-failed' }, metadata: { status: 'error' } });
|
|
450
|
+
throw new Error('Schema inference failed for all sampled documents');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Reconcile: count field occurrences across all successful inferences.
|
|
454
|
+
const fieldMap = new Map();
|
|
455
|
+
for (const result of successes) {
|
|
456
|
+
const seen = new Set();
|
|
457
|
+
for (const field of result.fields) {
|
|
458
|
+
const key = field.name;
|
|
459
|
+
if (!key || seen.has(key)) continue;
|
|
460
|
+
seen.add(key);
|
|
461
|
+
|
|
462
|
+
if (!fieldMap.has(key)) {
|
|
463
|
+
fieldMap.set(key, { field: { ...field }, count: 0, requiredCount: 0 });
|
|
464
|
+
}
|
|
465
|
+
const entry = fieldMap.get(key);
|
|
466
|
+
entry.count += 1;
|
|
467
|
+
if (field.required) entry.requiredCount += 1;
|
|
468
|
+
|
|
469
|
+
// Accumulate examples and enums.
|
|
470
|
+
if (field.example && !entry.field.example) entry.field.example = field.example;
|
|
471
|
+
if (Array.isArray(field.enum) && field.enum.length > 0) {
|
|
472
|
+
entry.field.enum = [...new Set([...(entry.field.enum || []), ...field.enum])];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const minCount = Math.ceil(successes.length * threshold);
|
|
478
|
+
const unifiedFields = [];
|
|
479
|
+
for (const [, entry] of fieldMap) {
|
|
480
|
+
if (entry.count >= minCount) {
|
|
481
|
+
unifiedFields.push({
|
|
482
|
+
...entry.field,
|
|
483
|
+
required: entry.requiredCount === successes.length,
|
|
484
|
+
occurrence_rate: Math.round((entry.count / successes.length) * 100) / 100,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Sort by occurrence_rate descending then name ascending.
|
|
490
|
+
unifiedFields.sort((a, b) => (b.occurrence_rate - a.occurrence_rate) || a.name.localeCompare(b.name));
|
|
491
|
+
|
|
492
|
+
const avgConfidence = successes.reduce((s, r) => s + r.confidence, 0) / successes.length;
|
|
493
|
+
const documentTypes = [...new Set(successes.map((r) => r.document_type).filter(Boolean))];
|
|
494
|
+
|
|
495
|
+
const unified = {
|
|
496
|
+
document_type: documentTypes.length === 1 ? documentTypes[0] : documentTypes.join(' | '),
|
|
497
|
+
document_types_seen: documentTypes,
|
|
498
|
+
fields: unifiedFields,
|
|
499
|
+
confidence: Math.round(avgConfidence * 100) / 100,
|
|
500
|
+
sampled: sample.length,
|
|
501
|
+
succeeded: successes.length,
|
|
502
|
+
failed: failures.length,
|
|
503
|
+
failures,
|
|
504
|
+
threshold,
|
|
505
|
+
inferred_at: new Date().toISOString(),
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
void lfUpdateTrace({
|
|
509
|
+
id: traceId,
|
|
510
|
+
output: { document_type: unified.document_type, field_count: unifiedFields.length, confidence: unified.confidence, succeeded: unified.succeeded, failed: unified.failed },
|
|
511
|
+
metadata: { status: 'success' },
|
|
512
|
+
});
|
|
513
|
+
void telScore({ traceId, name: 'confidence', value: unified.confidence, comment: `${unifiedFields.length} unified fields from ${unified.succeeded}/${sample.length} documents` });
|
|
514
|
+
|
|
515
|
+
return unified;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* CLI entrypoint for `construct infer`.
|
|
520
|
+
*/
|
|
521
|
+
export async function runInferCli(argv = process.argv.slice(2), { cwd = process.cwd() } = {}) {
|
|
522
|
+
const inputs = [];
|
|
523
|
+
let maxChars = 40_000;
|
|
524
|
+
let unified = false;
|
|
525
|
+
let sampleSize = 10;
|
|
526
|
+
let threshold = 0.5;
|
|
527
|
+
|
|
528
|
+
for (const arg of argv) {
|
|
529
|
+
if (arg.startsWith('--max-chars=')) maxChars = Number(arg.split('=')[1]) || 40_000;
|
|
530
|
+
else if (arg.startsWith('--sample=')) sampleSize = Number(arg.split('=')[1]) || 10;
|
|
531
|
+
else if (arg.startsWith('--threshold=')) threshold = Number(arg.split('=')[1]) || 0.5;
|
|
532
|
+
else if (arg === '--unified') unified = true;
|
|
533
|
+
else inputs.push(arg);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (inputs.length === 0) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
'Usage: construct infer <file> [more files] [--unified] [--max-chars=N] [--sample=N] [--threshold=0.5]'
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Resolve all inputs relative to cwd.
|
|
543
|
+
const { resolve: resolvePath } = await import('node:path');
|
|
544
|
+
const resolved = inputs.map((p) => (p.startsWith('/') ? p : resolvePath(cwd, p)));
|
|
545
|
+
|
|
546
|
+
if (unified || resolved.length > 1) {
|
|
547
|
+
return inferUnifiedSchema(resolved, { maxChars, sampleSize, threshold });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return inferDocumentSchema(resolved[0], { maxChars });
|
|
551
|
+
}
|