@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/storage/embeddings-ollama.mjs — Ollama embedding adapter.
|
|
3
|
+
*
|
|
4
|
+
* Requires OLLAMA_BASE_URL (default: http://localhost:11434).
|
|
5
|
+
* Uses nomic-embed-text model (768d).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MODEL_ID = 'nomic-embed-text';
|
|
9
|
+
const DIMENSIONS = 768;
|
|
10
|
+
|
|
11
|
+
async function ollamaEmbed(baseUrl, text) {
|
|
12
|
+
const res = await fetch(`${baseUrl}/api/embeddings`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
body: JSON.stringify({ model: MODEL_ID, prompt: text }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
const body = await res.text().catch(() => '');
|
|
20
|
+
throw new Error(`Ollama embedding error ${res.status}: ${body}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return res.json();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Embed a single text string.
|
|
28
|
+
* @param {string} text
|
|
29
|
+
* @param {{ env?: object }} opts
|
|
30
|
+
* @returns {Promise<{ embedding: Float32Array, model: string, dimensions: number }>}
|
|
31
|
+
*/
|
|
32
|
+
export async function embed(text, { env = process.env } = {}) {
|
|
33
|
+
const baseUrl = env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
34
|
+
|
|
35
|
+
const data = await ollamaEmbed(baseUrl, text);
|
|
36
|
+
return {
|
|
37
|
+
embedding: new Float32Array(data.embedding),
|
|
38
|
+
model: `ollama/${MODEL_ID}`,
|
|
39
|
+
dimensions: DIMENSIONS,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Embed a batch of texts. Ollama doesn't support native batching,
|
|
45
|
+
* so we process sequentially with a small concurrency limit.
|
|
46
|
+
* @param {string[]} texts
|
|
47
|
+
* @param {{ env?: object }} opts
|
|
48
|
+
* @returns {Promise<{ embedding: Float32Array, model: string, dimensions: number }[]>}
|
|
49
|
+
*/
|
|
50
|
+
export async function embedBatch(texts, { env = process.env } = {}) {
|
|
51
|
+
const baseUrl = env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
52
|
+
const results = [];
|
|
53
|
+
|
|
54
|
+
for (const text of texts) {
|
|
55
|
+
const data = await ollamaEmbed(baseUrl, text);
|
|
56
|
+
results.push({
|
|
57
|
+
embedding: new Float32Array(data.embedding),
|
|
58
|
+
model: `ollama/${MODEL_ID}`,
|
|
59
|
+
dimensions: DIMENSIONS,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return metadata about this embedding model.
|
|
68
|
+
* @param {{ env?: object }} opts
|
|
69
|
+
* @returns {{ id: string, model: string, provider: string, dimensions: number }}
|
|
70
|
+
*/
|
|
71
|
+
export function getModelInfo({ env = process.env } = {}) {
|
|
72
|
+
return {
|
|
73
|
+
id: 'ollama',
|
|
74
|
+
model: `ollama/${MODEL_ID}`,
|
|
75
|
+
provider: 'ollama',
|
|
76
|
+
dimensions: DIMENSIONS,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/storage/embeddings-openai.mjs — OpenAI embedding adapter.
|
|
3
|
+
*
|
|
4
|
+
* Requires OPENAI_API_KEY. Uses text-embedding-3-small (1536d).
|
|
5
|
+
* Supports native batching for up to 2048 inputs per request.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MODEL_ID = 'text-embedding-3-small';
|
|
9
|
+
const DIMENSIONS = 1536;
|
|
10
|
+
const MAX_BATCH_SIZE = 2048;
|
|
11
|
+
|
|
12
|
+
async function openaiEmbed(key, input) {
|
|
13
|
+
const res = await fetch('https://api.openai.com/v1/embeddings', {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: {
|
|
16
|
+
'Authorization': `Bearer ${key}`,
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify({ model: MODEL_ID, input }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
const body = await res.text().catch(() => '');
|
|
24
|
+
throw new Error(`OpenAI embedding error ${res.status}: ${body}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return res.json();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Embed a single text string.
|
|
32
|
+
* @param {string} text
|
|
33
|
+
* @param {{ env?: object }} opts
|
|
34
|
+
* @returns {Promise<{ embedding: Float32Array, model: string, dimensions: number }>}
|
|
35
|
+
*/
|
|
36
|
+
export async function embed(text, { env = process.env } = {}) {
|
|
37
|
+
const key = env.OPENAI_API_KEY;
|
|
38
|
+
if (!key) throw new Error('OPENAI_API_KEY required for OpenAI embeddings');
|
|
39
|
+
|
|
40
|
+
const data = await openaiEmbed(key, text);
|
|
41
|
+
return {
|
|
42
|
+
embedding: new Float32Array(data.data[0].embedding),
|
|
43
|
+
model: `openai/${MODEL_ID}`,
|
|
44
|
+
dimensions: DIMENSIONS,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Embed a batch of texts using OpenAI's native batching.
|
|
50
|
+
* @param {string[]} texts
|
|
51
|
+
* @param {{ env?: object }} opts
|
|
52
|
+
* @returns {Promise<{ embedding: Float32Array, model: string, dimensions: number }[]>}
|
|
53
|
+
*/
|
|
54
|
+
export async function embedBatch(texts, { env = process.env } = {}) {
|
|
55
|
+
const key = env.OPENAI_API_KEY;
|
|
56
|
+
if (!key) throw new Error('OPENAI_API_KEY required for OpenAI embeddings');
|
|
57
|
+
|
|
58
|
+
const results = [];
|
|
59
|
+
for (let i = 0; i < texts.length; i += MAX_BATCH_SIZE) {
|
|
60
|
+
const batch = texts.slice(i, i + MAX_BATCH_SIZE);
|
|
61
|
+
const data = await openaiEmbed(key, batch);
|
|
62
|
+
for (const item of data.data) {
|
|
63
|
+
results.push({
|
|
64
|
+
embedding: new Float32Array(item.embedding),
|
|
65
|
+
model: `openai/${MODEL_ID}`,
|
|
66
|
+
dimensions: DIMENSIONS,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Return metadata about this embedding model.
|
|
75
|
+
* @param {{ env?: object }} opts
|
|
76
|
+
* @returns {{ id: string, model: string, provider: string, dimensions: number }}
|
|
77
|
+
*/
|
|
78
|
+
export function getModelInfo({ env = process.env } = {}) {
|
|
79
|
+
return {
|
|
80
|
+
id: 'openai',
|
|
81
|
+
model: `openai/${MODEL_ID}`,
|
|
82
|
+
provider: 'openai',
|
|
83
|
+
dimensions: DIMENSIONS,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -1,42 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* lib/storage/embeddings.mjs —
|
|
3
|
+
* lib/storage/embeddings.mjs — Pure scoring & tokenization utilities.
|
|
4
|
+
*
|
|
5
|
+
* Holds the model-agnostic primitives only:
|
|
6
|
+
* tokenize, cosineSimilarity, bm25Score, buildTermFrequencies,
|
|
7
|
+
* buildIdf, rankByBm25, scoreByEmbedding.
|
|
8
|
+
*
|
|
9
|
+
* Embedding adapters live alongside the engine (lib/storage/embeddings-engine.mjs
|
|
10
|
+
* and per-model files like embeddings-local.mjs / embeddings-openai.mjs /
|
|
11
|
+
* embeddings-legacy.mjs). Callers obtain embeddings via the engine. Anything
|
|
12
|
+
* reaching for a hashing fallback should call `embeddings-legacy.mjs::embed`
|
|
13
|
+
* directly or set CONSTRUCT_EMBEDDING_MODEL=hashing.
|
|
4
14
|
*/
|
|
5
|
-
import crypto from 'node:crypto';
|
|
6
15
|
|
|
7
|
-
|
|
8
|
-
|
|
16
|
+
const BM25_K1 = 1.5;
|
|
17
|
+
const BM25_B = 0.75;
|
|
9
18
|
|
|
10
|
-
function tokenize(text) {
|
|
19
|
+
export function tokenize(text) {
|
|
11
20
|
return String(text || '')
|
|
12
21
|
.toLowerCase()
|
|
13
22
|
.split(/[^a-z0-9]+/)
|
|
14
|
-
.filter((term) => term.length >=
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function bucketForToken(token, dimensions) {
|
|
18
|
-
const hash = crypto.createHash('sha256').update(token).digest();
|
|
19
|
-
return hash.readUInt32BE(0) % dimensions;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function signForToken(token) {
|
|
23
|
-
const hash = crypto.createHash('sha256').update(token).digest();
|
|
24
|
-
return (hash[4] & 1) === 0 ? 1 : -1;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function embedText(text, dimensions = EMBEDDING_DIMENSIONS) {
|
|
28
|
-
const vector = new Array(dimensions).fill(0);
|
|
29
|
-
const tokens = tokenize(text);
|
|
30
|
-
if (tokens.length === 0) return vector;
|
|
31
|
-
|
|
32
|
-
for (const token of tokens) {
|
|
33
|
-
const bucket = bucketForToken(token, dimensions);
|
|
34
|
-
vector[bucket] += signForToken(token);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const norm = Math.sqrt(vector.reduce((sum, value) => sum + (value * value), 0));
|
|
38
|
-
if (norm === 0) return vector;
|
|
39
|
-
return vector.map((value) => Number((value / norm).toFixed(8)));
|
|
23
|
+
.filter((term) => term.length >= 2);
|
|
40
24
|
}
|
|
41
25
|
|
|
42
26
|
export function cosineSimilarity(left = [], right = []) {
|
|
@@ -56,8 +40,58 @@ export function cosineSimilarity(left = [], right = []) {
|
|
|
56
40
|
return denom > 0 ? dot / denom : 0;
|
|
57
41
|
}
|
|
58
42
|
|
|
59
|
-
|
|
60
|
-
|
|
43
|
+
/**
|
|
44
|
+
* BM25 score for a single document against a query.
|
|
45
|
+
* documents — array of tokenized term-frequency maps built by buildTermFrequencies().
|
|
46
|
+
* avgDocLen — average document length across the corpus.
|
|
47
|
+
* idf — Map<term, number> built by buildIdf().
|
|
48
|
+
*/
|
|
49
|
+
export function bm25Score(queryTerms, docTerms, docLen, avgDocLen, idf) {
|
|
50
|
+
let score = 0;
|
|
51
|
+
for (const term of queryTerms) {
|
|
52
|
+
const tf = docTerms.get(term) || 0;
|
|
53
|
+
if (tf === 0) continue;
|
|
54
|
+
const idfVal = idf.get(term) || 0;
|
|
55
|
+
const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * docLen / Math.max(avgDocLen, 1)));
|
|
56
|
+
score += idfVal * tfNorm;
|
|
57
|
+
}
|
|
58
|
+
return score;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildTermFrequencies(text) {
|
|
62
|
+
const tf = new Map();
|
|
63
|
+
for (const term of tokenize(text)) {
|
|
64
|
+
tf.set(term, (tf.get(term) || 0) + 1);
|
|
65
|
+
}
|
|
66
|
+
return tf;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build IDF values for a corpus of term-frequency maps.
|
|
71
|
+
* Returns Map<term, idf_value> using the Robertson-Sparck Jones variant.
|
|
72
|
+
*/
|
|
73
|
+
export function buildIdf(corpus) {
|
|
74
|
+
const N = corpus.length;
|
|
75
|
+
const docFreq = new Map();
|
|
76
|
+
for (const tfMap of corpus) {
|
|
77
|
+
for (const term of tfMap.keys()) {
|
|
78
|
+
docFreq.set(term, (docFreq.get(term) || 0) + 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const idf = new Map();
|
|
82
|
+
for (const [term, df] of docFreq) {
|
|
83
|
+
idf.set(term, Math.log((N - df + 0.5) / (df + 0.5) + 1));
|
|
84
|
+
}
|
|
85
|
+
return idf;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Rank documents by cosine similarity against a pre-computed query embedding.
|
|
90
|
+
* Decouples scoring from the embedding model — callers obtain the query
|
|
91
|
+
* embedding via the configured engine, then pass it here to score documents
|
|
92
|
+
* that already carry an `embedding` field of the same dimension.
|
|
93
|
+
*/
|
|
94
|
+
export function scoreByEmbedding(documents = [], queryEmbedding = [], { limit = 10 } = {}) {
|
|
61
95
|
return documents
|
|
62
96
|
.map((document) => ({
|
|
63
97
|
document,
|
|
@@ -68,3 +102,28 @@ export function scoreEmbeddedDocuments(documents = [], query, { limit = 10 } = {
|
|
|
68
102
|
.slice(0, limit)
|
|
69
103
|
.map(({ document, score }) => ({ ...document, score }));
|
|
70
104
|
}
|
|
105
|
+
|
|
106
|
+
export function rankByBm25(documents, query, { limit = 10 } = {}) {
|
|
107
|
+
if (!query || documents.length === 0) return [];
|
|
108
|
+
const queryTerms = tokenize(query);
|
|
109
|
+
if (queryTerms.length === 0) return [];
|
|
110
|
+
|
|
111
|
+
const tfMaps = documents.map((doc) => {
|
|
112
|
+
const text = [doc.text, doc.body, doc.summary, doc.content, doc.title].filter(Boolean).join(' ');
|
|
113
|
+
return buildTermFrequencies(text);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const totalLen = tfMaps.reduce((sum, tf) => sum + [...tf.values()].reduce((a, b) => a + b, 0), 0);
|
|
117
|
+
const avgDocLen = totalLen / Math.max(tfMaps.length, 1);
|
|
118
|
+
const idf = buildIdf(tfMaps);
|
|
119
|
+
|
|
120
|
+
return documents
|
|
121
|
+
.map((doc, i) => {
|
|
122
|
+
const docLen = [...tfMaps[i].values()].reduce((a, b) => a + b, 0);
|
|
123
|
+
const score = bm25Score(queryTerms, tfMaps[i], docLen, avgDocLen, idf);
|
|
124
|
+
return { ...doc, score };
|
|
125
|
+
})
|
|
126
|
+
.filter((d) => d.score > 0)
|
|
127
|
+
.sort((a, b) => b.score - a.score)
|
|
128
|
+
.slice(0, limit);
|
|
129
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/storage/file-lock.mjs — Cooperative per-file locking for shared JSON stores.
|
|
3
|
+
*
|
|
4
|
+
* The observation, entity, and session stores are read-modify-written by both
|
|
5
|
+
* CLI invocations and hook processes; concurrent writers can race and produce
|
|
6
|
+
* a corrupted JSON file. This helper provides a thin lock primitive that:
|
|
7
|
+
*
|
|
8
|
+
* - Creates a `<file>.lock` sidecar with O_EXCL semantics (`flag: 'wx'`)
|
|
9
|
+
* - Records the holder PID inside the lock so stale locks (from killed
|
|
10
|
+
* processes) can be detected and stolen
|
|
11
|
+
* - Spins with backoff for up to LOCK_TIMEOUT_MS before giving up
|
|
12
|
+
* - Unlocks on release, on `process.exit`, and on uncaught exceptions
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
*
|
|
16
|
+
* await withFileLock(filePath, async () => {
|
|
17
|
+
* const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
18
|
+
* // ...mutate
|
|
19
|
+
* writeFileSync(filePath, JSON.stringify(data));
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* The lock is best-effort: if the OS, FS, or process is in a state that
|
|
23
|
+
* defeats O_EXCL, the helper falls through after the timeout and lets the
|
|
24
|
+
* caller proceed. Logged regressions are surfaced via construct doctor.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { writeFileSync, readFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
|
|
28
|
+
import { dirname } from 'node:path';
|
|
29
|
+
|
|
30
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
31
|
+
const POLL_INTERVAL_MS = 50;
|
|
32
|
+
|
|
33
|
+
const heldLocks = new Set();
|
|
34
|
+
|
|
35
|
+
function isHolderAlive(holderPid) {
|
|
36
|
+
if (!holderPid || !Number.isFinite(holderPid)) return false;
|
|
37
|
+
try {
|
|
38
|
+
process.kill(holderPid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return err.code === 'EPERM';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tryAcquire(lockPath) {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
48
|
+
writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
49
|
+
heldLocks.add(lockPath);
|
|
50
|
+
return true;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err.code !== 'EEXIST') throw err;
|
|
53
|
+
let holder = null;
|
|
54
|
+
try { holder = Number(readFileSync(lockPath, 'utf8').trim()); } catch { /* unreadable */ }
|
|
55
|
+
if (!isHolderAlive(holder)) {
|
|
56
|
+
try { unlinkSync(lockPath); } catch { /* race */ }
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
59
|
+
heldLocks.add(lockPath);
|
|
60
|
+
return true;
|
|
61
|
+
} catch { return false; }
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function release(lockPath) {
|
|
68
|
+
if (!heldLocks.has(lockPath)) return;
|
|
69
|
+
try { unlinkSync(lockPath); } catch { /* already gone */ }
|
|
70
|
+
heldLocks.delete(lockPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let exitHandlerInstalled = false;
|
|
74
|
+
function installExitHandler() {
|
|
75
|
+
if (exitHandlerInstalled) return;
|
|
76
|
+
exitHandlerInstalled = true;
|
|
77
|
+
const cleanup = () => { for (const p of heldLocks) try { unlinkSync(p); } catch { /* ignore */ } };
|
|
78
|
+
process.on('exit', cleanup);
|
|
79
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
80
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
81
|
+
process.on('uncaughtException', (err) => { cleanup(); throw err; });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run `fn` while holding an exclusive lock on `<filePath>.lock`. Awaits the
|
|
86
|
+
* function's return value, releases the lock, and propagates any error.
|
|
87
|
+
*
|
|
88
|
+
* If the lock cannot be acquired within LOCK_TIMEOUT_MS, runs `fn` anyway —
|
|
89
|
+
* better to lose the lock guarantee than to deadlock the caller. This is
|
|
90
|
+
* cooperative locking; it relies on every writer using this helper.
|
|
91
|
+
*/
|
|
92
|
+
export async function withFileLock(filePath, fn) {
|
|
93
|
+
installExitHandler();
|
|
94
|
+
const lockPath = `${filePath}.lock`;
|
|
95
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
96
|
+
|
|
97
|
+
while (!tryAcquire(lockPath)) {
|
|
98
|
+
if (Date.now() >= deadline) break;
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
return await fn();
|
|
104
|
+
} finally {
|
|
105
|
+
release(lockPath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Synchronous variant. Same contract, but for callers that cannot be async.
|
|
111
|
+
* Uses a busy-wait loop; prefer `withFileLock` whenever possible.
|
|
112
|
+
*/
|
|
113
|
+
export function withFileLockSync(filePath, fn) {
|
|
114
|
+
installExitHandler();
|
|
115
|
+
const lockPath = `${filePath}.lock`;
|
|
116
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
117
|
+
|
|
118
|
+
while (!tryAcquire(lockPath)) {
|
|
119
|
+
if (Date.now() >= deadline) break;
|
|
120
|
+
// Crude sleep — node has no native sync sleep, so we busy-wait briefly.
|
|
121
|
+
const stop = Date.now() + POLL_INTERVAL_MS;
|
|
122
|
+
while (Date.now() < stop) { /* spin */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
return fn();
|
|
127
|
+
} finally {
|
|
128
|
+
release(lockPath);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/storage/fusion.mjs — score fusion for hybrid retrieval.
|
|
3
|
+
*
|
|
4
|
+
* Exposes two fusion strategies for combining ranked results from
|
|
5
|
+
* multiple retrieval signals (lexical, vector, metadata, recency):
|
|
6
|
+
*
|
|
7
|
+
* fuseScores({ vector, lexical, metadata, recency }, weights)
|
|
8
|
+
* — weighted linear combination, returns the final score and
|
|
9
|
+
* component scores so callers can debug retrieval drift.
|
|
10
|
+
*
|
|
11
|
+
* reciprocalRankFusion(rankedLists, { k })
|
|
12
|
+
* — RRF: finalScore = Σ 1 / (k + rank_i) for each list a doc appears in.
|
|
13
|
+
* Order-insensitive across lists; robust to score-scale mismatch
|
|
14
|
+
* between lexical (BM25) and vector (cosine) backends.
|
|
15
|
+
*
|
|
16
|
+
* Default weights match the source plan: vector 0.45, lexical 0.35,
|
|
17
|
+
* metadata 0.15, recency 0.05. Callers override per-query when needed
|
|
18
|
+
* (e.g. weight metadata higher for filter-driven queries).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_WEIGHTS = Object.freeze({
|
|
22
|
+
vector: 0.45,
|
|
23
|
+
lexical: 0.35,
|
|
24
|
+
metadata: 0.15,
|
|
25
|
+
recency: 0.05,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function clamp01(value) {
|
|
29
|
+
if (!Number.isFinite(value)) return 0;
|
|
30
|
+
if (value < 0) return 0;
|
|
31
|
+
if (value > 1) return 1;
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute a fused score from component signals.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} components
|
|
39
|
+
* @param {number} [components.vector] cosine similarity in [0, 1]
|
|
40
|
+
* @param {number} [components.lexical] normalized lexical score in [0, 1]
|
|
41
|
+
* @param {number} [components.metadata] metadata-match score in [0, 1]
|
|
42
|
+
* @param {number} [components.recency] freshness score in [0, 1]
|
|
43
|
+
* @param {object} [weights]
|
|
44
|
+
* @returns {{ vector, lexical, metadata, recency, finalScore, weights }}
|
|
45
|
+
*/
|
|
46
|
+
export function fuseScores(components = {}, weights = DEFAULT_WEIGHTS) {
|
|
47
|
+
const v = clamp01(components.vector);
|
|
48
|
+
const l = clamp01(components.lexical);
|
|
49
|
+
const m = clamp01(components.metadata);
|
|
50
|
+
const r = clamp01(components.recency);
|
|
51
|
+
const w = { ...DEFAULT_WEIGHTS, ...weights };
|
|
52
|
+
const finalScore = clamp01(v * w.vector + l * w.lexical + m * w.metadata + r * w.recency);
|
|
53
|
+
return { vector: v, lexical: l, metadata: m, recency: r, finalScore, weights: w };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reciprocal Rank Fusion across N ranked lists. Each list is an array
|
|
58
|
+
* of `{ id, ... }` objects ordered by descending relevance.
|
|
59
|
+
*
|
|
60
|
+
* @param {Array<Array<object>>} rankedLists
|
|
61
|
+
* @param {object} [opts]
|
|
62
|
+
* @param {number} [opts.k=60]
|
|
63
|
+
* @returns {Array<{ id, rrfScore, appearsIn: number }>}
|
|
64
|
+
*/
|
|
65
|
+
export function reciprocalRankFusion(rankedLists, { k = 60 } = {}) {
|
|
66
|
+
if (!Array.isArray(rankedLists) || rankedLists.length === 0) return [];
|
|
67
|
+
const scores = new Map();
|
|
68
|
+
for (const list of rankedLists) {
|
|
69
|
+
if (!Array.isArray(list)) continue;
|
|
70
|
+
list.forEach((item, idx) => {
|
|
71
|
+
if (!item?.id) return;
|
|
72
|
+
const rrf = 1 / (k + idx + 1);
|
|
73
|
+
const prev = scores.get(item.id) || { id: item.id, rrfScore: 0, appearsIn: 0 };
|
|
74
|
+
prev.rrfScore += rrf;
|
|
75
|
+
prev.appearsIn += 1;
|
|
76
|
+
scores.set(item.id, prev);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return [...scores.values()].sort((a, b) => b.rrfScore - a.rrfScore);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute a normalized recency score in [0, 1] given a timestamp and
|
|
84
|
+
* a half-life in days. A doc updated today scores ~1, a doc older than
|
|
85
|
+
* 4× the half-life scores near 0.
|
|
86
|
+
*/
|
|
87
|
+
export function recencyScore(updatedAt, { halfLifeDays = 30, now = Date.now() } = {}) {
|
|
88
|
+
if (!updatedAt) return 0;
|
|
89
|
+
const ts = updatedAt instanceof Date ? updatedAt.getTime() : Date.parse(updatedAt);
|
|
90
|
+
if (!Number.isFinite(ts)) return 0;
|
|
91
|
+
const ageMs = Math.max(0, now - ts);
|
|
92
|
+
const halfLifeMs = halfLifeDays * 24 * 60 * 60 * 1000;
|
|
93
|
+
if (halfLifeMs <= 0) return 0;
|
|
94
|
+
return clamp01(Math.pow(0.5, ageMs / halfLifeMs));
|
|
95
|
+
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { loadStateSnapshot, summarizeStateSnapshot } from './state-source.mjs';
|
|
6
6
|
import { describeSqlStore } from './sql-store.mjs';
|
|
7
|
-
import { describeVectorStore, vectorSearchLocal } from './vector-store.mjs';
|
|
7
|
+
import { describeVectorStore, searchLocalVectorIndex, vectorSearchLocal } from './vector-store.mjs';
|
|
8
8
|
import { createSqlClient, closeSqlClient, readVectorConfig } from './backend.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { scoreByEmbedding } from './embeddings.mjs';
|
|
10
|
+
import { embedText, getEmbeddingModelInfo } from './embeddings-engine.mjs';
|
|
10
11
|
|
|
11
12
|
function collectFileCandidates(snapshot) {
|
|
12
13
|
const docs = [];
|
|
@@ -22,20 +23,9 @@ function collectFileCandidates(snapshot) {
|
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
if (snapshot.workflow) {
|
|
26
|
-
docs.push({
|
|
27
|
-
id: '.cx/workflow.json',
|
|
28
|
-
kind: 'workflow',
|
|
29
|
-
title: snapshot.workflow.title || 'Workflow state',
|
|
30
|
-
summary: snapshot.workflow.summary || null,
|
|
31
|
-
body: JSON.stringify(snapshot.workflow, null, 2),
|
|
32
|
-
tags: ['workflow', 'state', 'cx'],
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
26
|
if (snapshot.architecture) {
|
|
37
27
|
docs.push({
|
|
38
|
-
id: 'docs/architecture.md',
|
|
28
|
+
id: 'docs/concepts/architecture.md',
|
|
39
29
|
kind: 'architecture',
|
|
40
30
|
title: 'Architecture docs',
|
|
41
31
|
summary: snapshot.architecture.slice(0, 240),
|
|
@@ -60,14 +50,14 @@ function collectFileCandidates(snapshot) {
|
|
|
60
50
|
? 'prd'
|
|
61
51
|
: doc.path.startsWith('docs/meta-prd/')
|
|
62
52
|
? 'meta-prd'
|
|
63
|
-
: '
|
|
53
|
+
: 'knowledge';
|
|
64
54
|
docs.push({
|
|
65
55
|
id: doc.path,
|
|
66
56
|
kind,
|
|
67
57
|
title: doc.title,
|
|
68
58
|
summary: doc.body.slice(0, 240),
|
|
69
59
|
body: doc.body,
|
|
70
|
-
tags: ['
|
|
60
|
+
tags: ['knowledge', kind],
|
|
71
61
|
});
|
|
72
62
|
}
|
|
73
63
|
|
|
@@ -77,10 +67,24 @@ function collectFileCandidates(snapshot) {
|
|
|
77
67
|
export function buildHybridSearchResults(rootDir, query, { limit = 10, env = process.env } = {}) {
|
|
78
68
|
const snapshot = loadStateSnapshot(rootDir);
|
|
79
69
|
const fileCandidates = collectFileCandidates(snapshot);
|
|
80
|
-
const fileHits = vectorSearchLocal(fileCandidates, query, { limit });
|
|
81
|
-
|
|
82
70
|
const sqlStore = describeSqlStore(env);
|
|
83
71
|
const vectorStore = describeVectorStore(env);
|
|
72
|
+
const fileHits = vectorSearchLocal(fileCandidates, query, { limit });
|
|
73
|
+
const localVectorHits = vectorStore.mode === 'local' && vectorStore.indexPath
|
|
74
|
+
? searchLocalVectorIndex(vectorStore.indexPath, query, { limit })
|
|
75
|
+
: [];
|
|
76
|
+
const merged = [...fileHits];
|
|
77
|
+
for (const hit of localVectorHits) {
|
|
78
|
+
if (merged.some((entry) => entry.id === hit.id)) continue;
|
|
79
|
+
merged.push({
|
|
80
|
+
id: hit.id,
|
|
81
|
+
kind: hit.kind,
|
|
82
|
+
title: hit.title,
|
|
83
|
+
summary: hit.summary,
|
|
84
|
+
score: hit.score,
|
|
85
|
+
source_path: hit.source_path,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
84
88
|
|
|
85
89
|
return {
|
|
86
90
|
query,
|
|
@@ -90,7 +94,7 @@ export function buildHybridSearchResults(rootDir, query, { limit = 10, env = pro
|
|
|
90
94
|
sql: sqlStore,
|
|
91
95
|
vector: vectorStore,
|
|
92
96
|
},
|
|
93
|
-
results:
|
|
97
|
+
results: merged.slice(0, limit).map((hit) => ({
|
|
94
98
|
id: hit.id,
|
|
95
99
|
kind: hit.kind,
|
|
96
100
|
title: hit.title,
|
|
@@ -105,20 +109,23 @@ export async function buildHybridSearchResultsAsync(rootDir, query, { limit = 10
|
|
|
105
109
|
const client = createSqlClient(env);
|
|
106
110
|
if (!client) return base;
|
|
107
111
|
|
|
112
|
+
// Resolve the active embedding model so the SQL filter and the query
|
|
113
|
+
// embedding agree on dimensionality and identity.
|
|
114
|
+
const modelInfo = await getEmbeddingModelInfo({ env });
|
|
115
|
+
const embeddingModel = modelInfo.model;
|
|
116
|
+
|
|
108
117
|
try {
|
|
109
118
|
const embeddingRows = await client`
|
|
110
119
|
select d.id, d.kind, d.title, d.summary, d.body, d.source_path, d.tags, e.embedding
|
|
111
120
|
from construct_documents d
|
|
112
121
|
join construct_embeddings e on e.document_id = d.id
|
|
113
|
-
where d.project = 'construct' and e.model = ${
|
|
122
|
+
where d.project = 'construct' and e.model = ${embeddingModel}
|
|
114
123
|
`;
|
|
115
124
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
})),
|
|
121
|
-
query,
|
|
125
|
+
const queryEmbedding = (await embedText(query, { env })).embedding;
|
|
126
|
+
const embeddedHits = scoreByEmbedding(
|
|
127
|
+
embeddingRows.map((row) => ({ ...row, embedding: row.embedding })),
|
|
128
|
+
Array.from(queryEmbedding),
|
|
122
129
|
{ limit },
|
|
123
130
|
);
|
|
124
131
|
|
|
@@ -163,7 +170,7 @@ export async function buildHybridSearchResultsAsync(rootDir, query, { limit = 10
|
|
|
163
170
|
vector: {
|
|
164
171
|
...base.stores.vector,
|
|
165
172
|
...readVectorConfig(env),
|
|
166
|
-
model:
|
|
173
|
+
model: embeddingModel,
|
|
167
174
|
},
|
|
168
175
|
sql: {
|
|
169
176
|
...base.stores.sql,
|