@geraldmaron/construct 1.0.0 → 1.0.2
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 +9 -6
- 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 +1000 -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 +523 -36
- 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,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/storage/backup.mjs — full Construct backup and restore.
|
|
3
|
+
*
|
|
4
|
+
* A backup archive is a gzip-compressed tar containing:
|
|
5
|
+
*
|
|
6
|
+
* manifest.json — version, timestamp, contents list, checksums
|
|
7
|
+
* postgres/ — pg_dump output (if Postgres is reachable)
|
|
8
|
+
* observations/ — ~/.cx/observations/*.json
|
|
9
|
+
* sessions/ — ~/.cx/sessions/*.json
|
|
10
|
+
* config.env — ~/.construct/config.env (secrets stripped unless opts.includeSecrets)
|
|
11
|
+
* registry.json — agents/registry.json snapshot
|
|
12
|
+
*
|
|
13
|
+
* The manifest uses SHA-256 checksums over each included file so
|
|
14
|
+
* `backup verify` can detect corruption without extracting.
|
|
15
|
+
*
|
|
16
|
+
* Restore is atomic: files are written to a temp directory first,
|
|
17
|
+
* then moved into place in one pass. Postgres restore is the last step
|
|
18
|
+
* because it is the hardest to roll back.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
|
|
27
|
+
const HOME = os.homedir();
|
|
28
|
+
const CX_DIR = path.join(HOME, '.cx');
|
|
29
|
+
const CONSTRUCT_DIR = path.join(HOME, '.construct');
|
|
30
|
+
const BACKUP_DIR = path.join(CONSTRUCT_DIR, 'backups');
|
|
31
|
+
|
|
32
|
+
// Secret env var patterns to redact in config.env.
|
|
33
|
+
const SECRET_KEYS = [
|
|
34
|
+
/TOKEN/i, /PASSWORD/i, /SECRET/i, /KEY/i, /CREDENTIAL/i,
|
|
35
|
+
/ANTHROPIC_API/i, /GITHUB_TOKEN/i, /SLACK_BOT/i, /SALESFORCE_ACCESS/i,
|
|
36
|
+
/JIRA_API/i, /LINEAR_API/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function isSecretKey(key) {
|
|
40
|
+
return SECRET_KEYS.some((re) => re.test(key));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function redactConfigEnv(content) {
|
|
44
|
+
return content.split('\n').map((line) => {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (!trimmed || trimmed.startsWith('#')) return line;
|
|
47
|
+
const eq = trimmed.indexOf('=');
|
|
48
|
+
if (eq === -1) return line;
|
|
49
|
+
const key = trimmed.slice(0, eq).trim();
|
|
50
|
+
if (isSecretKey(key)) return `${key}=<redacted>`;
|
|
51
|
+
return line;
|
|
52
|
+
}).join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sha256File(filePath) {
|
|
56
|
+
const hash = crypto.createHash('sha256');
|
|
57
|
+
hash.update(fs.readFileSync(filePath));
|
|
58
|
+
return hash.digest('hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sha256Buffer(buf) {
|
|
62
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function archiveName() {
|
|
66
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
67
|
+
return `construct-backup-${ts}.tar.gz`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a full backup archive.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} [opts]
|
|
74
|
+
* @param {boolean} [opts.includeSecrets] - include raw secret values in config.env
|
|
75
|
+
* @param {string} [opts.registryPath] - path to agents/registry.json
|
|
76
|
+
* @param {string} [opts.destDir] - destination directory (default: ~/.construct/backups)
|
|
77
|
+
* @returns {{ path: string, manifest: object }}
|
|
78
|
+
*/
|
|
79
|
+
export async function createBackup({ includeSecrets = false, registryPath, destDir } = {}) {
|
|
80
|
+
const dest = destDir || BACKUP_DIR;
|
|
81
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'construct-backup-'));
|
|
84
|
+
const files = [];
|
|
85
|
+
const checksums = {};
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// ── Observations ──────────────────────────────────────────────────────
|
|
89
|
+
const obsDir = path.join(CX_DIR, 'observations');
|
|
90
|
+
const stagObs = path.join(stageDir, 'observations');
|
|
91
|
+
if (fs.existsSync(obsDir)) {
|
|
92
|
+
fs.mkdirSync(stagObs, { recursive: true });
|
|
93
|
+
for (const f of fs.readdirSync(obsDir).filter((n) => n.endsWith('.json'))) {
|
|
94
|
+
const src = path.join(obsDir, f);
|
|
95
|
+
const dst = path.join(stagObs, f);
|
|
96
|
+
fs.copyFileSync(src, dst);
|
|
97
|
+
checksums[`observations/${f}`] = sha256File(dst);
|
|
98
|
+
files.push(`observations/${f}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Sessions ──────────────────────────────────────────────────────────
|
|
103
|
+
const sessDir = path.join(CX_DIR, 'sessions');
|
|
104
|
+
const stagSess = path.join(stageDir, 'sessions');
|
|
105
|
+
if (fs.existsSync(sessDir)) {
|
|
106
|
+
fs.mkdirSync(stagSess, { recursive: true });
|
|
107
|
+
for (const f of fs.readdirSync(sessDir).filter((n) => n.endsWith('.json'))) {
|
|
108
|
+
const src = path.join(sessDir, f);
|
|
109
|
+
const dst = path.join(stagSess, f);
|
|
110
|
+
fs.copyFileSync(src, dst);
|
|
111
|
+
checksums[`sessions/${f}`] = sha256File(dst);
|
|
112
|
+
files.push(`sessions/${f}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── config.env ────────────────────────────────────────────────────────
|
|
117
|
+
const configEnv = path.join(CONSTRUCT_DIR, 'config.env');
|
|
118
|
+
if (fs.existsSync(configEnv)) {
|
|
119
|
+
let content = fs.readFileSync(configEnv, 'utf8');
|
|
120
|
+
if (!includeSecrets) content = redactConfigEnv(content);
|
|
121
|
+
const dst = path.join(stageDir, 'config.env');
|
|
122
|
+
fs.writeFileSync(dst, content);
|
|
123
|
+
checksums['config.env'] = sha256Buffer(Buffer.from(content));
|
|
124
|
+
files.push('config.env');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Registry snapshot ─────────────────────────────────────────────────
|
|
128
|
+
const regPath = registryPath || path.join(process.cwd(), 'agents', 'registry.json');
|
|
129
|
+
if (fs.existsSync(regPath)) {
|
|
130
|
+
const dst = path.join(stageDir, 'registry.json');
|
|
131
|
+
fs.copyFileSync(regPath, dst);
|
|
132
|
+
checksums['registry.json'] = sha256File(dst);
|
|
133
|
+
files.push('registry.json');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Postgres dump ─────────────────────────────────────────────────────
|
|
137
|
+
const pgResult = tryPostgresDump(stageDir);
|
|
138
|
+
if (pgResult.success) {
|
|
139
|
+
checksums['postgres/dump.sql'] = sha256File(path.join(stageDir, 'postgres', 'dump.sql'));
|
|
140
|
+
files.push('postgres/dump.sql');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Manifest ──────────────────────────────────────────────────────────
|
|
144
|
+
const manifest = {
|
|
145
|
+
version: 1,
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
includesSecrets: includeSecrets,
|
|
148
|
+
contents: files,
|
|
149
|
+
checksums,
|
|
150
|
+
};
|
|
151
|
+
fs.writeFileSync(path.join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
152
|
+
|
|
153
|
+
// ── Pack to tar.gz ────────────────────────────────────────────────────
|
|
154
|
+
const outPath = path.join(dest, archiveName());
|
|
155
|
+
execFileSync('tar', ['-czf', outPath, '-C', stageDir, '.'], { stdio: 'pipe' });
|
|
156
|
+
|
|
157
|
+
return { path: outPath, manifest };
|
|
158
|
+
} finally {
|
|
159
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function tryPostgresDump(stageDir) {
|
|
164
|
+
const dumpDir = path.join(stageDir, 'postgres');
|
|
165
|
+
const dumpFile = path.join(dumpDir, 'dump.sql');
|
|
166
|
+
|
|
167
|
+
const dbUrl = process.env.DATABASE_URL || process.env.CONSTRUCT_DATABASE_URL;
|
|
168
|
+
if (!dbUrl) return { success: false, reason: 'no DATABASE_URL' };
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
fs.mkdirSync(dumpDir, { recursive: true });
|
|
172
|
+
execFileSync('pg_dump', [dbUrl, '--no-password', '-f', dumpFile], {
|
|
173
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
174
|
+
timeout: 60_000,
|
|
175
|
+
});
|
|
176
|
+
return { success: true };
|
|
177
|
+
} catch (err) {
|
|
178
|
+
return { success: false, reason: err.message };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Verify a backup archive by comparing stored checksums to actual file hashes.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} archivePath
|
|
186
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
187
|
+
*/
|
|
188
|
+
export async function verifyBackup(archivePath) {
|
|
189
|
+
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'construct-verify-'));
|
|
190
|
+
const errors = [];
|
|
191
|
+
try {
|
|
192
|
+
execFileSync('tar', ['-xzf', archivePath, '-C', extractDir], { stdio: 'pipe' });
|
|
193
|
+
|
|
194
|
+
const manifestPath = path.join(extractDir, 'manifest.json');
|
|
195
|
+
if (!fs.existsSync(manifestPath)) {
|
|
196
|
+
return { ok: false, errors: ['manifest.json missing'] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
200
|
+
for (const [rel, expected] of Object.entries(manifest.checksums || {})) {
|
|
201
|
+
const abs = path.join(extractDir, rel);
|
|
202
|
+
if (!fs.existsSync(abs)) {
|
|
203
|
+
errors.push(`missing: ${rel}`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const actual = sha256File(abs);
|
|
207
|
+
if (actual !== expected) errors.push(`checksum mismatch: ${rel}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { ok: errors.length === 0, errors };
|
|
211
|
+
} finally {
|
|
212
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Restore a backup archive. Prompts for confirmation before writing.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} archivePath
|
|
220
|
+
* @param {object} [opts]
|
|
221
|
+
* @param {boolean} [opts.yes] - skip confirmation prompt
|
|
222
|
+
* @returns {{ ok: boolean, restored: string[], errors: string[] }}
|
|
223
|
+
*/
|
|
224
|
+
export async function restoreBackup(archivePath, { yes = false } = {}) {
|
|
225
|
+
const verify = await verifyBackup(archivePath);
|
|
226
|
+
if (!verify.ok) {
|
|
227
|
+
return { ok: false, restored: [], errors: [`verification failed: ${verify.errors.join(', ')}`] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'construct-restore-'));
|
|
231
|
+
const restored = [];
|
|
232
|
+
const errors = [];
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
execFileSync('tar', ['-xzf', archivePath, '-C', extractDir], { stdio: 'pipe' });
|
|
236
|
+
|
|
237
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(extractDir, 'manifest.json'), 'utf8'));
|
|
238
|
+
|
|
239
|
+
// Restore observations
|
|
240
|
+
const stagObs = path.join(extractDir, 'observations');
|
|
241
|
+
if (fs.existsSync(stagObs)) {
|
|
242
|
+
const dest = path.join(CX_DIR, 'observations');
|
|
243
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
244
|
+
for (const f of fs.readdirSync(stagObs)) {
|
|
245
|
+
fs.copyFileSync(path.join(stagObs, f), path.join(dest, f));
|
|
246
|
+
restored.push(`observations/${f}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Restore sessions
|
|
251
|
+
const stagSess = path.join(extractDir, 'sessions');
|
|
252
|
+
if (fs.existsSync(stagSess)) {
|
|
253
|
+
const dest = path.join(CX_DIR, 'sessions');
|
|
254
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
255
|
+
for (const f of fs.readdirSync(stagSess)) {
|
|
256
|
+
fs.copyFileSync(path.join(stagSess, f), path.join(dest, f));
|
|
257
|
+
restored.push(`sessions/${f}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Restore config.env — only if it contained no redacted lines
|
|
262
|
+
const stagConfig = path.join(extractDir, 'config.env');
|
|
263
|
+
if (fs.existsSync(stagConfig)) {
|
|
264
|
+
const content = fs.readFileSync(stagConfig, 'utf8');
|
|
265
|
+
if (!content.includes('<redacted>')) {
|
|
266
|
+
fs.mkdirSync(CONSTRUCT_DIR, { recursive: true });
|
|
267
|
+
fs.writeFileSync(path.join(CONSTRUCT_DIR, 'config.env'), content, { mode: 0o600 });
|
|
268
|
+
restored.push('config.env');
|
|
269
|
+
} else {
|
|
270
|
+
errors.push('config.env has redacted secrets — restore credentials manually');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Restore registry.json
|
|
275
|
+
const stagReg = path.join(extractDir, 'registry.json');
|
|
276
|
+
if (fs.existsSync(stagReg)) {
|
|
277
|
+
const dest = path.join(process.cwd(), 'agents', 'registry.json');
|
|
278
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
279
|
+
fs.copyFileSync(stagReg, dest);
|
|
280
|
+
restored.push('registry.json');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Restore Postgres (last — hardest to undo)
|
|
284
|
+
const stagPg = path.join(extractDir, 'postgres', 'dump.sql');
|
|
285
|
+
if (fs.existsSync(stagPg)) {
|
|
286
|
+
const pgResult = tryPostgresRestore(stagPg);
|
|
287
|
+
if (pgResult.success) {
|
|
288
|
+
restored.push('postgres');
|
|
289
|
+
} else {
|
|
290
|
+
errors.push(`postgres restore failed: ${pgResult.reason}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { ok: errors.length === 0, restored, errors };
|
|
295
|
+
} finally {
|
|
296
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function tryPostgresRestore(dumpFile) {
|
|
301
|
+
const dbUrl = process.env.DATABASE_URL || process.env.CONSTRUCT_DATABASE_URL;
|
|
302
|
+
if (!dbUrl) return { success: false, reason: 'no DATABASE_URL' };
|
|
303
|
+
try {
|
|
304
|
+
execFileSync('psql', [dbUrl, '-f', dumpFile], {
|
|
305
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
306
|
+
timeout: 120_000,
|
|
307
|
+
});
|
|
308
|
+
return { success: true };
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return { success: false, reason: err.message };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* List backup archives in the default backup directory.
|
|
316
|
+
*/
|
|
317
|
+
export function listBackups(dir = BACKUP_DIR) {
|
|
318
|
+
if (!fs.existsSync(dir)) return [];
|
|
319
|
+
return fs.readdirSync(dir)
|
|
320
|
+
.filter((f) => f.startsWith('construct-backup-') && f.endsWith('.tar.gz'))
|
|
321
|
+
.sort()
|
|
322
|
+
.reverse()
|
|
323
|
+
.map((f) => {
|
|
324
|
+
const fullPath = path.join(dir, f);
|
|
325
|
+
const stat = fs.statSync(fullPath);
|
|
326
|
+
return { name: f, path: fullPath, size: stat.size, mtime: stat.mtime };
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Keep the `keep` most recent backups in `dir`; unlink the rest. The return
|
|
331
|
+
// value names each pruned file by archive basename. Per-file unlink errors
|
|
332
|
+
// are swallowed so one locked archive can't abort the whole pass.
|
|
333
|
+
export function pruneBackups({ keep = 10, dir = BACKUP_DIR } = {}) {
|
|
334
|
+
const list = listBackups(dir);
|
|
335
|
+
if (list.length <= keep) return { kept: list.length, removed: [] };
|
|
336
|
+
const toRemove = list.slice(keep);
|
|
337
|
+
const removed = [];
|
|
338
|
+
for (const b of toRemove) {
|
|
339
|
+
try {
|
|
340
|
+
fs.unlinkSync(b.path);
|
|
341
|
+
removed.push(b.name);
|
|
342
|
+
} catch {
|
|
343
|
+
/* best effort — leave file in place if it's locked or unreadable */
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { kept: keep, removed };
|
|
347
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/storage/embeddings-engine.mjs — Model-agnostic embedding engine.
|
|
3
|
+
*
|
|
4
|
+
* Reads CONSTRUCT_EMBEDDING_MODEL env var and delegates to the appropriate adapter.
|
|
5
|
+
* All adapters return: { embedding: Float32Array, model: string, dimensions: number }
|
|
6
|
+
*
|
|
7
|
+
* Supported models:
|
|
8
|
+
* - local → ONNX Transformers (Xenova/all-MiniLM-L6-v2, 384d) — DEFAULT
|
|
9
|
+
* - openai → OpenAI API (text-embedding-3-small, 1536d)
|
|
10
|
+
* - ollama → Ollama server (nomic-embed-text, 768d)
|
|
11
|
+
* - hashing → Deterministic bag-of-words (256d) — fallback / test fixture only
|
|
12
|
+
*
|
|
13
|
+
* OpenAI policy:
|
|
14
|
+
* - When CONSTRUCT_EMBEDDING_MODEL=openai and OPENAI_API_KEY is missing,
|
|
15
|
+
* the engine throws at first call with a precise error.
|
|
16
|
+
* - Setting CONSTRUCT_EMBEDDING_FALLBACK=1 opts into silent fallback to
|
|
17
|
+
* `local`; a single stderr warning is emitted per process when that
|
|
18
|
+
* fallback fires.
|
|
19
|
+
* The policy is opinionated by design: silent fallback hides misconfiguration
|
|
20
|
+
* in production. Operators that want graceful degradation must opt in.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ADAPTERS = {
|
|
24
|
+
local: () => import('./embeddings-local.mjs'),
|
|
25
|
+
'local-onnx': () => import('./embeddings-local.mjs'),
|
|
26
|
+
openai: () => import('./embeddings-openai.mjs'),
|
|
27
|
+
ollama: () => import('./embeddings-ollama.mjs'),
|
|
28
|
+
hashing: () => import('./embeddings-legacy.mjs'),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const DEFAULT_MODEL = 'local';
|
|
32
|
+
|
|
33
|
+
let openaiFallbackWarned = false;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the model id the engine should actually use given the env. Encodes
|
|
37
|
+
* the OpenAI key/fallback policy so every adapter call sees a consistent
|
|
38
|
+
* decision.
|
|
39
|
+
*/
|
|
40
|
+
function resolveModelId(env) {
|
|
41
|
+
const requested = (env.CONSTRUCT_EMBEDDING_MODEL || DEFAULT_MODEL).toLowerCase();
|
|
42
|
+
if (requested !== 'openai') return requested;
|
|
43
|
+
|
|
44
|
+
if (env.OPENAI_API_KEY) return 'openai';
|
|
45
|
+
|
|
46
|
+
if (env.CONSTRUCT_EMBEDDING_FALLBACK === '1') {
|
|
47
|
+
if (!openaiFallbackWarned) {
|
|
48
|
+
openaiFallbackWarned = true;
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
'[construct] CONSTRUCT_EMBEDDING_MODEL=openai but OPENAI_API_KEY is missing; ' +
|
|
51
|
+
'falling back to local because CONSTRUCT_EMBEDDING_FALLBACK=1.\n'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return DEFAULT_MODEL;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new Error(
|
|
58
|
+
'CONSTRUCT_EMBEDDING_MODEL=openai requires OPENAI_API_KEY. ' +
|
|
59
|
+
'Set the key, choose a different model (local|ollama|hashing), or opt into ' +
|
|
60
|
+
'silent fallback with CONSTRUCT_EMBEDDING_FALLBACK=1.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Embed a single text string using the configured model.
|
|
66
|
+
* @param {string} text
|
|
67
|
+
* @param {{ env?: object }} opts
|
|
68
|
+
* @returns {Promise<{ embedding: Float32Array, model: string, dimensions: number }>}
|
|
69
|
+
*/
|
|
70
|
+
export async function embedText(text, { env = process.env } = {}) {
|
|
71
|
+
const modelId = resolveModelId(env);
|
|
72
|
+
const loader = ADAPTERS[modelId] || ADAPTERS.hashing;
|
|
73
|
+
const { embed } = await loader();
|
|
74
|
+
return embed(text, { env });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Embed a batch of texts. Uses model-native batching when available.
|
|
79
|
+
* @param {string[]} texts
|
|
80
|
+
* @param {{ env?: object }} opts
|
|
81
|
+
* @returns {Promise<{ embedding: Float32Array, model: string, dimensions: number }[]>}
|
|
82
|
+
*/
|
|
83
|
+
export async function embedBatch(texts, { env = process.env } = {}) {
|
|
84
|
+
const modelId = resolveModelId(env);
|
|
85
|
+
const loader = ADAPTERS[modelId] || ADAPTERS.hashing;
|
|
86
|
+
const { embedBatch } = await loader();
|
|
87
|
+
return embedBatch(texts, { env });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return metadata about the active embedding model.
|
|
92
|
+
* @param {{ env?: object }} opts
|
|
93
|
+
* @returns {Promise<{ id: string, model: string, provider: string, dimensions: number }>}
|
|
94
|
+
*/
|
|
95
|
+
export async function getEmbeddingModelInfo({ env = process.env } = {}) {
|
|
96
|
+
const modelId = resolveModelId(env);
|
|
97
|
+
const loader = ADAPTERS[modelId] || ADAPTERS.hashing;
|
|
98
|
+
const { getModelInfo } = await loader();
|
|
99
|
+
return getModelInfo({ env });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pre-warm the configured embedding model so the first real call doesn't
|
|
104
|
+
* stall on a one-time ONNX/transformers download. Returns timing + model
|
|
105
|
+
* info; never throws (falls back through the degraded path inside the
|
|
106
|
+
* adapter). Setup calls this so the user sees the wait happen under the
|
|
107
|
+
* setup banner instead of during their first agent query.
|
|
108
|
+
*/
|
|
109
|
+
export async function warmupEmbeddingModel({ env = process.env } = {}) {
|
|
110
|
+
const start = Date.now();
|
|
111
|
+
const info = await getEmbeddingModelInfo({ env });
|
|
112
|
+
const result = await embedText('construct warmup', { env });
|
|
113
|
+
return {
|
|
114
|
+
model: info.model,
|
|
115
|
+
dimensions: info.dimensions,
|
|
116
|
+
durationMs: Date.now() - start,
|
|
117
|
+
degraded: Boolean(result?.degraded),
|
|
118
|
+
fallbackReason: result?.fallbackReason,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Return all available embedding model options.
|
|
124
|
+
* @returns {Array<{ id: string, provider: string, model: string, dimensions: number, description: string }>}
|
|
125
|
+
*/
|
|
126
|
+
export function getAvailableModels() {
|
|
127
|
+
return [
|
|
128
|
+
{ id: 'local', provider: 'onnx', model: 'Xenova/all-MiniLM-L6-v2', dimensions: 384, description: 'Free, offline, runs locally via ONNX Runtime' },
|
|
129
|
+
{ id: 'openai', provider: 'openai', model: 'text-embedding-3-small', dimensions: 1536, description: 'Best quality, requires OPENAI_API_KEY' },
|
|
130
|
+
{ id: 'ollama', provider: 'ollama', model: 'nomic-embed-text', dimensions: 768, description: 'Local Ollama server, requires OLLAMA_BASE_URL' },
|
|
131
|
+
{ id: 'hashing', provider: 'local', model: 'hashing-bow-v1', dimensions: 256, description: 'Legacy deterministic hash — fast but no semantic understanding' },
|
|
132
|
+
];
|
|
133
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/storage/embeddings-legacy.mjs — Hashing-bow-v1 adapter for the embedding engine.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained implementation of a deterministic, dependency-free embedding via
|
|
5
|
+
* SHA256-bucketed bag-of-words. Used only when CONSTRUCT_EMBEDDING_MODEL=hashing.
|
|
6
|
+
* Lower quality than the local ONNX adapter; kept as a fallback for tests and
|
|
7
|
+
* for environments where the ONNX runtime is unavailable.
|
|
8
|
+
*
|
|
9
|
+
* The hashing implementation lives here so that lib/storage/embeddings.mjs
|
|
10
|
+
* can hold only the pure BM25/cosine/tokenize utilities. Embedding adapters
|
|
11
|
+
* live alongside the engine; utilities live in embeddings.mjs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { tokenize } from './embeddings.mjs';
|
|
16
|
+
|
|
17
|
+
export const MODEL_ID = 'hashing-bow-v1';
|
|
18
|
+
export const DIMENSIONS = 256;
|
|
19
|
+
|
|
20
|
+
function bucketForToken(token, dimensions) {
|
|
21
|
+
const hash = crypto.createHash('sha256').update(token).digest();
|
|
22
|
+
return hash.readUInt32BE(0) % dimensions;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function signForToken(token) {
|
|
26
|
+
const hash = crypto.createHash('sha256').update(token).digest();
|
|
27
|
+
return (hash[4] & 1) === 0 ? 1 : -1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hashEmbedding(text, dimensions = DIMENSIONS) {
|
|
31
|
+
const vector = new Array(dimensions).fill(0);
|
|
32
|
+
const tokens = tokenize(text);
|
|
33
|
+
if (tokens.length === 0) return new Float32Array(vector);
|
|
34
|
+
|
|
35
|
+
for (const token of tokens) {
|
|
36
|
+
vector[bucketForToken(token, dimensions)] += signForToken(token);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let normSq = 0;
|
|
40
|
+
for (const v of vector) normSq += v * v;
|
|
41
|
+
const norm = Math.sqrt(normSq);
|
|
42
|
+
if (norm === 0) return new Float32Array(vector);
|
|
43
|
+
|
|
44
|
+
const out = new Float32Array(dimensions);
|
|
45
|
+
for (let i = 0; i < dimensions; i++) out[i] = Number((vector[i] / norm).toFixed(8));
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Synchronous embed helper for legacy callers (observation/entity/session
|
|
51
|
+
* stores, trends, RAG corpus builders) that haven't yet migrated to the
|
|
52
|
+
* async engine. Returns a plain Array of length `DIMENSIONS` so existing
|
|
53
|
+
* callers using `cosineSimilarity(...)` continue to work without changes.
|
|
54
|
+
*
|
|
55
|
+
* New code MUST use `embedText` from `embeddings-engine.mjs` instead — the
|
|
56
|
+
* sync helper here exists only to ease migration of legacy callers.
|
|
57
|
+
*/
|
|
58
|
+
export function embedSync(text) {
|
|
59
|
+
return Array.from(hashEmbedding(String(text || '')));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function embed(text /* , { env = process.env } = {} */) {
|
|
63
|
+
return {
|
|
64
|
+
embedding: hashEmbedding(String(text || '')),
|
|
65
|
+
model: MODEL_ID,
|
|
66
|
+
dimensions: DIMENSIONS,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function embedBatch(texts /* , { env = process.env } = {} */) {
|
|
71
|
+
return texts.map((t) => ({
|
|
72
|
+
embedding: hashEmbedding(String(t || '')),
|
|
73
|
+
model: MODEL_ID,
|
|
74
|
+
dimensions: DIMENSIONS,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getModelInfo(/* { env = process.env } = {} */) {
|
|
79
|
+
return {
|
|
80
|
+
id: 'hashing',
|
|
81
|
+
model: MODEL_ID,
|
|
82
|
+
provider: 'local',
|
|
83
|
+
dimensions: DIMENSIONS,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/storage/embeddings-local.mjs — Local ONNX embedding via @huggingface/transformers.
|
|
3
|
+
*
|
|
4
|
+
* Lazy-loads the model, caches to disk, uses a single-worker queue to avoid
|
|
5
|
+
* parallel inference contention. Falls back to hashing-bow-v1 if the model
|
|
6
|
+
* fails to load.
|
|
7
|
+
*
|
|
8
|
+
* Model: Xenova/all-MiniLM-L6-v2 (384 dimensions, quantized)
|
|
9
|
+
*/
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { mkdirSync, existsSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
let modelPromise = null;
|
|
15
|
+
let extractor = null;
|
|
16
|
+
|
|
17
|
+
const MODEL_ID = 'Xenova/all-MiniLM-L6-v2';
|
|
18
|
+
const DIMENSIONS = 384;
|
|
19
|
+
|
|
20
|
+
async function getExtractor(cacheDir) {
|
|
21
|
+
if (extractor) return extractor;
|
|
22
|
+
if (modelPromise) return modelPromise;
|
|
23
|
+
|
|
24
|
+
const cachePath = cacheDir || join(homedir(), '.construct', 'cache', 'embeddings');
|
|
25
|
+
if (!existsSync(cachePath)) {
|
|
26
|
+
mkdirSync(cachePath, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
modelPromise = (async () => {
|
|
30
|
+
const { pipeline, env: hfEnv } = await import('@huggingface/transformers');
|
|
31
|
+
hfEnv.allowLocalModels = true;
|
|
32
|
+
hfEnv.useBrowserCache = false;
|
|
33
|
+
try {
|
|
34
|
+
const ex = await pipeline('feature-extraction', MODEL_ID, {
|
|
35
|
+
cache_dir: cachePath,
|
|
36
|
+
quantized: true,
|
|
37
|
+
});
|
|
38
|
+
extractor = ex;
|
|
39
|
+
return ex;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
modelPromise = null;
|
|
42
|
+
throw new Error(`Local embedding model failed to load: ${err.message}. Falling back to hashing-bow-v1.`);
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
return modelPromise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function embed(text, { env = process.env } = {}) {
|
|
50
|
+
try {
|
|
51
|
+
if (env.CONSTRUCT_EMBEDDING_DISABLE_LOCAL === '1') {
|
|
52
|
+
throw new Error('local embeddings disabled by CONSTRUCT_EMBEDDING_DISABLE_LOCAL');
|
|
53
|
+
}
|
|
54
|
+
const ex = await getExtractor(env.CONSTRUCT_EMBEDDING_CACHE_DIR);
|
|
55
|
+
const output = await ex(text, { pooling: 'mean', normalize: true });
|
|
56
|
+
return {
|
|
57
|
+
embedding: output.data,
|
|
58
|
+
model: MODEL_ID,
|
|
59
|
+
dimensions: DIMENSIONS,
|
|
60
|
+
};
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const { embed: hashEmbed } = await import('./embeddings-legacy.mjs');
|
|
63
|
+
const fallback = await hashEmbed(text, { env });
|
|
64
|
+
return {
|
|
65
|
+
...fallback,
|
|
66
|
+
degraded: true,
|
|
67
|
+
requestedModel: MODEL_ID,
|
|
68
|
+
fallbackReason: err?.message || 'local embedding model unavailable',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function embedBatch(texts, { env = process.env } = {}) {
|
|
74
|
+
try {
|
|
75
|
+
if (env.CONSTRUCT_EMBEDDING_DISABLE_LOCAL === '1') {
|
|
76
|
+
throw new Error('local embeddings disabled by CONSTRUCT_EMBEDDING_DISABLE_LOCAL');
|
|
77
|
+
}
|
|
78
|
+
const ex = await getExtractor(env.CONSTRUCT_EMBEDDING_CACHE_DIR);
|
|
79
|
+
const results = [];
|
|
80
|
+
for (const text of texts) {
|
|
81
|
+
const output = await ex(text, { pooling: 'mean', normalize: true });
|
|
82
|
+
results.push({
|
|
83
|
+
embedding: output.data,
|
|
84
|
+
model: MODEL_ID,
|
|
85
|
+
dimensions: DIMENSIONS,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return results;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const { embedBatch: hashBatch } = await import('./embeddings-legacy.mjs');
|
|
91
|
+
const fallbacks = await hashBatch(texts, { env });
|
|
92
|
+
return fallbacks.map((fallback) => ({
|
|
93
|
+
...fallback,
|
|
94
|
+
degraded: true,
|
|
95
|
+
requestedModel: MODEL_ID,
|
|
96
|
+
fallbackReason: err?.message || 'local embedding model unavailable',
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getModelInfo({ env = process.env } = {}) {
|
|
102
|
+
return {
|
|
103
|
+
id: 'local',
|
|
104
|
+
model: MODEL_ID,
|
|
105
|
+
provider: 'onnx',
|
|
106
|
+
dimensions: DIMENSIONS,
|
|
107
|
+
};
|
|
108
|
+
}
|