@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,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/fuser-rrf.mjs — Reciprocal Rank Fusion (RRF) Fuser plugin.
|
|
3
|
+
*
|
|
4
|
+
* Combines multiple ranked lists into a single ranking using:
|
|
5
|
+
*
|
|
6
|
+
* RRF(d) = Σ 1 / (k + rank_i(d))
|
|
7
|
+
*
|
|
8
|
+
* where rank_i(d) is the 1-based position of document d in the i-th ranker's
|
|
9
|
+
* output (or treated as if d were past the end of the list when absent), and
|
|
10
|
+
* k is a smoothing constant (60 by default, the value used in the original
|
|
11
|
+
* Cormack et al. 2009 paper and in production search systems like
|
|
12
|
+
* Elasticsearch/Vespa).
|
|
13
|
+
*
|
|
14
|
+
* RRF is corpus-agnostic — only ranks matter, not raw scores — so it avoids
|
|
15
|
+
* the correctness asymmetry that affects weighted-sum fusion when one ranker
|
|
16
|
+
* (e.g. BM25) only scores a top-K window: chunks outside that window would
|
|
17
|
+
* contribute 0 from that ranker even when they scored high on the other.
|
|
18
|
+
*
|
|
19
|
+
* Plugin contract (Fuser):
|
|
20
|
+
* meta: { id, k }
|
|
21
|
+
* fuse(rankedLists, opts) → ranked[]
|
|
22
|
+
*
|
|
23
|
+
* `rankedLists` is either an array of arrays (each pre-sorted, highest-first)
|
|
24
|
+
* or a Record<channelName, ranked[]>. Both shapes are supported so legacy
|
|
25
|
+
* callers using the weighted-sum default can swap in RRF without rewriting
|
|
26
|
+
* their call site.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const DEFAULT_K = 60;
|
|
30
|
+
|
|
31
|
+
function toLists(input) {
|
|
32
|
+
if (Array.isArray(input)) return input;
|
|
33
|
+
if (input && typeof input === 'object') return Object.values(input);
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function create({ k = DEFAULT_K } = {}) {
|
|
38
|
+
return {
|
|
39
|
+
meta: { id: 'rrf', k },
|
|
40
|
+
fuse(rankedLists, opts = {}) {
|
|
41
|
+
const kEff = opts.k ?? k;
|
|
42
|
+
const lists = toLists(rankedLists);
|
|
43
|
+
const merged = new Map();
|
|
44
|
+
|
|
45
|
+
for (const list of lists) {
|
|
46
|
+
if (!Array.isArray(list)) continue;
|
|
47
|
+
for (let rank = 0; rank < list.length; rank++) {
|
|
48
|
+
const doc = list[rank];
|
|
49
|
+
if (!doc?.id) continue;
|
|
50
|
+
const contribution = 1 / (kEff + rank + 1);
|
|
51
|
+
const prev = merged.get(doc.id) || { ...doc, score: 0 };
|
|
52
|
+
prev.score = (prev.score || 0) + contribution;
|
|
53
|
+
merged.set(doc.id, prev);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return [...merged.values()].sort((a, b) => b.score - a.score);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default create;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/index.mjs — Public entry point for the Construct retrieval engine.
|
|
3
|
+
*
|
|
4
|
+
* Single import surface for callers that need plugin-resolved layers
|
|
5
|
+
* (Embedder, Chunker, Indexer, Fuser, Reranker, Compressor). Resolution is
|
|
6
|
+
* cached per (rootDir) — engines are reusable across calls in the same process,
|
|
7
|
+
* so we avoid re-loading ONNX models or re-reading plugins.json on every query.
|
|
8
|
+
*
|
|
9
|
+
* Use `getEngine({ rootDir })` to fetch the cached engine. `resetEngine()`
|
|
10
|
+
* forces a re-resolution (useful for tests and `construct doctor` after a
|
|
11
|
+
* plugin config change).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { resolveEngine, describeEngine } from './registry.mjs';
|
|
15
|
+
import { LAYERS, assertContract, checkContract } from './contracts.mjs';
|
|
16
|
+
|
|
17
|
+
const CACHE = new Map();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return the resolved engine for a project root. Cached per rootDir.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {string} [opts.rootDir]
|
|
24
|
+
* @returns {Promise<{ layers, sources, errors }>}
|
|
25
|
+
*/
|
|
26
|
+
export async function getEngine({ rootDir = process.cwd() } = {}) {
|
|
27
|
+
if (!CACHE.has(rootDir)) {
|
|
28
|
+
CACHE.set(rootDir, resolveEngine({ rootDir }));
|
|
29
|
+
}
|
|
30
|
+
return CACHE.get(rootDir);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resetEngine() {
|
|
34
|
+
CACHE.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { describeEngine, LAYERS, assertContract, checkContract };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/registry.mjs — Plugin resolver for the Construct retrieval engine.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (last-write wins):
|
|
5
|
+
* 1. Built-in defaults (lib/engine/defaults.mjs)
|
|
6
|
+
* 2. ~/.construct/plugins.json (operator-level overrides)
|
|
7
|
+
* 3. <project>/.cx/plugins.json (repo-level overrides)
|
|
8
|
+
* 4. CONSTRUCT_PLUGIN_<LAYER> environment variables (one-off overrides)
|
|
9
|
+
*
|
|
10
|
+
* Plugin entry shape (in plugins.json):
|
|
11
|
+
* { "layer": "embedder", "package": "github:user/repo#sha", "options": {} }
|
|
12
|
+
*
|
|
13
|
+
* `package` is one of:
|
|
14
|
+
* - bare module name resolved via standard Node ESM resolution
|
|
15
|
+
* - absolute path to a local module
|
|
16
|
+
* - "github:owner/repo#ref" string (resolved via npm/yarn — caller installs out of band)
|
|
17
|
+
*
|
|
18
|
+
* Failures (load error, contract mismatch) fall back to the default impl for
|
|
19
|
+
* that layer and are surfaced via construct doctor / hook telemetry.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join, isAbsolute, resolve } from 'node:path';
|
|
25
|
+
import { pathToFileURL } from 'node:url';
|
|
26
|
+
import { LAYERS, assertContract } from './contracts.mjs';
|
|
27
|
+
import { loadDefaults } from './defaults.mjs';
|
|
28
|
+
|
|
29
|
+
const ENV_PREFIX = 'CONSTRUCT_PLUGIN_';
|
|
30
|
+
|
|
31
|
+
function readJsonIfExists(path) {
|
|
32
|
+
try {
|
|
33
|
+
if (!existsSync(path)) return null;
|
|
34
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Build the layered list of override entries from disk and env. Later entries
|
|
41
|
+
// take precedence per layer.
|
|
42
|
+
|
|
43
|
+
function gatherOverrides(rootDir) {
|
|
44
|
+
const overrides = [];
|
|
45
|
+
|
|
46
|
+
const userConfig = readJsonIfExists(join(homedir(), '.construct', 'plugins.json'));
|
|
47
|
+
if (userConfig?.plugins) overrides.push(...userConfig.plugins);
|
|
48
|
+
|
|
49
|
+
const projectConfig = readJsonIfExists(join(rootDir, '.cx', 'plugins.json'));
|
|
50
|
+
if (projectConfig?.plugins) overrides.push(...projectConfig.plugins);
|
|
51
|
+
|
|
52
|
+
for (const layer of LAYERS) {
|
|
53
|
+
const envKey = `${ENV_PREFIX}${layer.toUpperCase()}`;
|
|
54
|
+
if (process.env[envKey]) {
|
|
55
|
+
overrides.push({ layer, package: process.env[envKey], options: {} });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return overrides;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function importPlugin(pkg, rootDir) {
|
|
63
|
+
if (typeof pkg !== 'string' || !pkg) {
|
|
64
|
+
throw new Error(`plugin package must be a non-empty string`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (pkg.startsWith('github:')) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`github: plugin spec '${pkg}' is not auto-installed. Install via 'npm install ${pkg}' first, then reference by package name.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isAbsolute(pkg) || pkg.startsWith('./') || pkg.startsWith('../')) {
|
|
74
|
+
const abs = isAbsolute(pkg) ? pkg : resolve(rootDir, pkg);
|
|
75
|
+
return import(pathToFileURL(abs).href);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return import(pkg);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function tryLoadOverride(entry, rootDir) {
|
|
82
|
+
const { layer, package: pkg, options = {} } = entry || {};
|
|
83
|
+
if (!LAYERS.includes(layer)) {
|
|
84
|
+
return { ok: false, layer, error: `unknown layer: ${layer}` };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const mod = await importPlugin(pkg, rootDir);
|
|
88
|
+
const factory = mod?.create || mod?.default;
|
|
89
|
+
if (typeof factory !== 'function') {
|
|
90
|
+
return { ok: false, layer, error: `plugin '${pkg}' must export 'create' or default factory` };
|
|
91
|
+
}
|
|
92
|
+
const plugin = await factory(options);
|
|
93
|
+
assertContract(layer, plugin);
|
|
94
|
+
return { ok: true, layer, plugin, source: pkg };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return { ok: false, layer, error: `plugin '${pkg}' failed: ${err.message}` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve every layer to an active plugin.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} opts
|
|
104
|
+
* @param {string} [opts.rootDir]
|
|
105
|
+
* @returns {Promise<{
|
|
106
|
+
* layers: Record<string, object>,
|
|
107
|
+
* sources: Record<string, string>,
|
|
108
|
+
* errors: Array<{ layer: string, error: string }>,
|
|
109
|
+
* }>}
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveEngine({ rootDir = process.cwd() } = {}) {
|
|
112
|
+
const defaults = await loadDefaults();
|
|
113
|
+
const layers = { ...defaults };
|
|
114
|
+
const sources = Object.fromEntries(LAYERS.map((l) => [l, 'default']));
|
|
115
|
+
const errors = [];
|
|
116
|
+
|
|
117
|
+
for (const layer of LAYERS) {
|
|
118
|
+
assertContract(layer, layers[layer]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const entry of gatherOverrides(rootDir)) {
|
|
122
|
+
const result = await tryLoadOverride(entry, rootDir);
|
|
123
|
+
if (result.ok) {
|
|
124
|
+
layers[result.layer] = result.plugin;
|
|
125
|
+
sources[result.layer] = result.source;
|
|
126
|
+
} else {
|
|
127
|
+
errors.push({ layer: result.layer, error: result.error });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { layers, sources, errors };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Diagnostic summary for `construct doctor` — does not throw.
|
|
136
|
+
*/
|
|
137
|
+
export async function describeEngine({ rootDir = process.cwd() } = {}) {
|
|
138
|
+
const { layers, sources, errors } = await resolveEngine({ rootDir });
|
|
139
|
+
const summary = LAYERS.map((layer) => ({
|
|
140
|
+
layer,
|
|
141
|
+
id: layers[layer]?.meta?.id || '(missing)',
|
|
142
|
+
source: sources[layer],
|
|
143
|
+
capabilities: { ...layers[layer]?.meta },
|
|
144
|
+
}));
|
|
145
|
+
return { summary, errors };
|
|
146
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/reranker-mmr.mjs — Maximal Marginal Relevance (MMR) Reranker plugin.
|
|
3
|
+
*
|
|
4
|
+
* Re-orders a fused candidate list to balance relevance against diversity:
|
|
5
|
+
*
|
|
6
|
+
* MMR(d) = λ · sim(d, q) − (1 − λ) · max_{d'∈S} sim(d, d')
|
|
7
|
+
*
|
|
8
|
+
* where S is the set of already-selected docs. λ defaults to 0.7 — favoring
|
|
9
|
+
* relevance, but enough diversity weight to drop near-duplicates. λ=1 gives
|
|
10
|
+
* pure relevance ranking; λ=0 gives pure novelty.
|
|
11
|
+
*
|
|
12
|
+
* Two similarity sources are supported on the candidate objects:
|
|
13
|
+
* - `embedding` (Array | Float32Array) — cosine similarity to the query
|
|
14
|
+
* embedding (passed via opts.queryEmbedding) and to other candidates'
|
|
15
|
+
* embeddings. Highest fidelity.
|
|
16
|
+
* - text fallback — Jaccard similarity over tokenized title+body when no
|
|
17
|
+
* embedding is available. Path for callers not yet on the engine surface.
|
|
18
|
+
*
|
|
19
|
+
* The function returns at most `topK` items in MMR order with a `.mmrScore`
|
|
20
|
+
* field added. Callers using this purely for deduplication can pass
|
|
21
|
+
* `topK = candidates.length` to keep all items but reorder for diversity.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { tokenize, cosineSimilarity } from '../storage/embeddings.mjs';
|
|
25
|
+
|
|
26
|
+
function jaccard(a, b) {
|
|
27
|
+
if (!a || !b) return 0;
|
|
28
|
+
const setA = new Set(tokenize(`${a.title || ''} ${a.body || ''}`));
|
|
29
|
+
const setB = new Set(tokenize(`${b.title || ''} ${b.body || ''}`));
|
|
30
|
+
if (setA.size === 0 || setB.size === 0) return 0;
|
|
31
|
+
let intersect = 0;
|
|
32
|
+
for (const t of setA) if (setB.has(t)) intersect++;
|
|
33
|
+
return intersect / (setA.size + setB.size - intersect);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function similarity(a, b) {
|
|
37
|
+
if (a?.embedding && b?.embedding) {
|
|
38
|
+
return cosineSimilarity(a.embedding, b.embedding);
|
|
39
|
+
}
|
|
40
|
+
return jaccard(a, b);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function relevance(candidate, queryEmbedding) {
|
|
44
|
+
if (queryEmbedding && candidate?.embedding) {
|
|
45
|
+
return cosineSimilarity(queryEmbedding, candidate.embedding);
|
|
46
|
+
}
|
|
47
|
+
return candidate?.score ?? 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function create({ lambda = 0.7 } = {}) {
|
|
51
|
+
return {
|
|
52
|
+
meta: { id: 'mmr', lambda },
|
|
53
|
+
async rerank(query, candidates, opts = {}) {
|
|
54
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return [];
|
|
55
|
+
const lambdaEff = opts.lambda ?? lambda;
|
|
56
|
+
const topK = opts.topK ?? candidates.length;
|
|
57
|
+
const queryEmbedding = opts.queryEmbedding || null;
|
|
58
|
+
|
|
59
|
+
const remaining = [...candidates];
|
|
60
|
+
const selected = [];
|
|
61
|
+
|
|
62
|
+
while (selected.length < topK && remaining.length > 0) {
|
|
63
|
+
let bestIndex = 0;
|
|
64
|
+
let bestScore = -Infinity;
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
67
|
+
const cand = remaining[i];
|
|
68
|
+
const rel = relevance(cand, queryEmbedding);
|
|
69
|
+
let maxSim = 0;
|
|
70
|
+
for (const sel of selected) {
|
|
71
|
+
const s = similarity(cand, sel);
|
|
72
|
+
if (s > maxSim) maxSim = s;
|
|
73
|
+
}
|
|
74
|
+
const score = lambdaEff * rel - (1 - lambdaEff) * maxSim;
|
|
75
|
+
if (score > bestScore) {
|
|
76
|
+
bestScore = score;
|
|
77
|
+
bestIndex = i;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const [chosen] = remaining.splice(bestIndex, 1);
|
|
82
|
+
selected.push({ ...chosen, mmrScore: bestScore });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return selected;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default create;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/tokens.mjs — Accurate token counting with a graceful fallback.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the `tiktoken` package when it's installed and reachable; otherwise
|
|
5
|
+
* falls back to the conventional `chars/4` approximation that the codebase
|
|
6
|
+
* has used historically. Operators that care about precise budgets should
|
|
7
|
+
* `npm install tiktoken` and the wrapper will pick it up automatically.
|
|
8
|
+
*
|
|
9
|
+
* The module is intentionally async-first: tiktoken's encoder loads its
|
|
10
|
+
* BPE table lazily, so the surface stays uniform whether tiktoken is present
|
|
11
|
+
* or not. Synchronous estimation is available via `estimateChars`.
|
|
12
|
+
*
|
|
13
|
+
* The module is the C6 pin-point for the rebuild plan: callers that enforce
|
|
14
|
+
* a token budget should call `countTokens(text)` and fail closed if the
|
|
15
|
+
* resulting count exceeds the budget. Without tiktoken, the count is an
|
|
16
|
+
* estimate; we don't lie about that — the returned object includes a
|
|
17
|
+
* `mode: 'tiktoken' | 'estimate'` field so callers can decide whether the
|
|
18
|
+
* approximation is acceptable for their use case.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
let encoderPromise = null;
|
|
22
|
+
let encoderUnavailable = false;
|
|
23
|
+
|
|
24
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Synchronous estimator. Always succeeds, never as accurate as tiktoken.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} text
|
|
30
|
+
* @returns {number} estimated token count
|
|
31
|
+
*/
|
|
32
|
+
export function estimateChars(text) {
|
|
33
|
+
return Math.ceil(String(text || '').length / APPROX_CHARS_PER_TOKEN);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getEncoder() {
|
|
37
|
+
if (encoderUnavailable) return null;
|
|
38
|
+
if (encoderPromise) return encoderPromise;
|
|
39
|
+
encoderPromise = (async () => {
|
|
40
|
+
try {
|
|
41
|
+
const mod = await import('tiktoken');
|
|
42
|
+
const enc = (mod.encoding_for_model || mod.default?.encoding_for_model)?.('gpt-4o-mini')
|
|
43
|
+
?? (mod.get_encoding || mod.default?.get_encoding)?.('cl100k_base');
|
|
44
|
+
if (!enc) throw new Error('tiktoken module did not expose a recognised factory');
|
|
45
|
+
return enc;
|
|
46
|
+
} catch {
|
|
47
|
+
encoderUnavailable = true;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
return encoderPromise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Async accurate counter. Returns `{ tokens, mode }`.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {Promise<{ tokens: number, mode: 'tiktoken' | 'estimate' }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function countTokens(text) {
|
|
61
|
+
const enc = await getEncoder();
|
|
62
|
+
if (!enc) return { tokens: estimateChars(text), mode: 'estimate' };
|
|
63
|
+
try {
|
|
64
|
+
const ids = enc.encode(String(text || ''));
|
|
65
|
+
return { tokens: ids.length, mode: 'tiktoken' };
|
|
66
|
+
} catch {
|
|
67
|
+
return { tokens: estimateChars(text), mode: 'estimate' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Drop the cached encoder — test hook for re-running after toggling availability.
|
|
73
|
+
*/
|
|
74
|
+
export function _resetEncoderForTests() {
|
|
75
|
+
encoderPromise = null;
|
|
76
|
+
encoderUnavailable = false;
|
|
77
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/entity-store.mjs — Entity tracking for the observation store.
|
|
3
|
+
*
|
|
4
|
+
* Tracks recurring entities (components, services, APIs, dependencies)
|
|
5
|
+
* that specialists encounter across sessions. Links entities to
|
|
6
|
+
* observations for "what do we know about X?" queries.
|
|
7
|
+
*
|
|
8
|
+
* Storage: .cx/observations/entities.json — flat JSON array.
|
|
9
|
+
* .cx/observations/entity-vectors.json — vector index for semantic search.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { cosineSimilarity, rankByBm25 } from './storage/embeddings.mjs';
|
|
14
|
+
import { embedSync as embedText } from './storage/embeddings-legacy.mjs';
|
|
15
|
+
import { withFileLockSync } from './storage/file-lock.mjs';
|
|
16
|
+
|
|
17
|
+
const OBS_DIR = '.cx/observations';
|
|
18
|
+
const ENTITIES_FILE = 'entities.json';
|
|
19
|
+
const ENTITY_VECTORS_FILE = 'entity-vectors.json';
|
|
20
|
+
const MAX_ENTITIES = 500;
|
|
21
|
+
const MAX_SUMMARY = 500;
|
|
22
|
+
const MAX_OBSERVATIONS_PER_ENTITY = 50;
|
|
23
|
+
const MAX_RELATED = 20;
|
|
24
|
+
|
|
25
|
+
const VALID_TYPES = new Set([
|
|
26
|
+
'component', 'service', 'dependency', 'api', 'concept', 'file-group',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function ensureDir(dir) {
|
|
30
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function entitiesPath(rootDir) {
|
|
34
|
+
return path.join(rootDir, OBS_DIR, ENTITIES_FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function entityVectorsPath(rootDir) {
|
|
38
|
+
return path.join(rootDir, OBS_DIR, ENTITY_VECTORS_FILE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readEntityVectors(rootDir) {
|
|
42
|
+
const p = entityVectorsPath(rootDir);
|
|
43
|
+
if (!fs.existsSync(p)) return [];
|
|
44
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return []; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeEntityVectors(rootDir, records) {
|
|
48
|
+
ensureDir(path.join(rootDir, OBS_DIR));
|
|
49
|
+
const target = entityVectorsPath(rootDir);
|
|
50
|
+
withFileLockSync(target, () => {
|
|
51
|
+
fs.writeFileSync(target, JSON.stringify(records.slice(0, MAX_ENTITIES), null, 2) + '\n');
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildEntitySearchText(entity) {
|
|
56
|
+
return [entity.name, entity.summary, entity.type].filter(Boolean).join(' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function upsertEntityVector(rootDir, entity) {
|
|
60
|
+
const text = buildEntitySearchText(entity);
|
|
61
|
+
if (!text) return;
|
|
62
|
+
const embedding = embedText(text);
|
|
63
|
+
const vectors = readEntityVectors(rootDir);
|
|
64
|
+
const idx = vectors.findIndex((v) => v.name === entity.name);
|
|
65
|
+
const record = { name: entity.name, embedding, type: entity.type || null, project: entity.project || null };
|
|
66
|
+
if (idx >= 0) vectors[idx] = record;
|
|
67
|
+
else vectors.unshift(record);
|
|
68
|
+
writeEntityVectors(rootDir, vectors);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clamp(str, max) {
|
|
72
|
+
if (!str || str.length <= max) return str || null;
|
|
73
|
+
return str.slice(0, max - 1) + '\u2026';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readEntities(rootDir) {
|
|
77
|
+
const p = entitiesPath(rootDir);
|
|
78
|
+
if (!fs.existsSync(p)) return [];
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeEntities(rootDir, entities) {
|
|
87
|
+
ensureDir(path.join(rootDir, OBS_DIR));
|
|
88
|
+
const trimmed = entities.slice(0, MAX_ENTITIES);
|
|
89
|
+
const target = entitiesPath(rootDir);
|
|
90
|
+
withFileLockSync(target, () => {
|
|
91
|
+
fs.writeFileSync(target, JSON.stringify(trimmed, null, 2) + '\n');
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create or update an entity. If an entity with the same name exists,
|
|
97
|
+
* its summary and type are updated; observations are merged.
|
|
98
|
+
*/
|
|
99
|
+
export function createEntity(rootDir, {
|
|
100
|
+
name = '',
|
|
101
|
+
type = 'concept',
|
|
102
|
+
summary = '',
|
|
103
|
+
project = null,
|
|
104
|
+
observationIds = [],
|
|
105
|
+
} = {}) {
|
|
106
|
+
if (!name) return null;
|
|
107
|
+
|
|
108
|
+
const entities = readEntities(rootDir);
|
|
109
|
+
const effectiveType = VALID_TYPES.has(type) ? type : 'concept';
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
const normalizedName = String(name).trim().toLowerCase();
|
|
112
|
+
|
|
113
|
+
const existing = entities.find((e) => e.name === normalizedName);
|
|
114
|
+
|
|
115
|
+
if (existing) {
|
|
116
|
+
existing.type = effectiveType;
|
|
117
|
+
existing.summary = clamp(String(summary || existing.summary), MAX_SUMMARY);
|
|
118
|
+
existing.lastSeen = now;
|
|
119
|
+
if (project) existing.project = String(project);
|
|
120
|
+
|
|
121
|
+
const ids = new Set(existing.observations || []);
|
|
122
|
+
for (const id of observationIds) ids.add(String(id));
|
|
123
|
+
existing.observations = [...ids].slice(0, MAX_OBSERVATIONS_PER_ENTITY);
|
|
124
|
+
|
|
125
|
+
writeEntities(rootDir, entities);
|
|
126
|
+
upsertEntityVector(rootDir, existing);
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const entity = {
|
|
131
|
+
name: normalizedName,
|
|
132
|
+
type: effectiveType,
|
|
133
|
+
summary: clamp(String(summary), MAX_SUMMARY),
|
|
134
|
+
observations: (Array.isArray(observationIds) ? observationIds : [])
|
|
135
|
+
.map(String)
|
|
136
|
+
.slice(0, MAX_OBSERVATIONS_PER_ENTITY),
|
|
137
|
+
relatedEntities: [],
|
|
138
|
+
project: project ? String(project) : null,
|
|
139
|
+
lastSeen: now,
|
|
140
|
+
createdAt: now,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
entities.unshift(entity);
|
|
144
|
+
writeEntities(rootDir, entities);
|
|
145
|
+
upsertEntityVector(rootDir, entity);
|
|
146
|
+
return entity;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Link an observation ID to an existing entity.
|
|
151
|
+
*/
|
|
152
|
+
export function addObservationToEntity(rootDir, entityName, observationId) {
|
|
153
|
+
const entities = readEntities(rootDir);
|
|
154
|
+
const normalizedName = String(entityName).trim().toLowerCase();
|
|
155
|
+
const entity = entities.find((e) => e.name === normalizedName);
|
|
156
|
+
if (!entity) return null;
|
|
157
|
+
|
|
158
|
+
const ids = new Set(entity.observations || []);
|
|
159
|
+
ids.add(String(observationId));
|
|
160
|
+
entity.observations = [...ids].slice(0, MAX_OBSERVATIONS_PER_ENTITY);
|
|
161
|
+
entity.lastSeen = new Date().toISOString();
|
|
162
|
+
|
|
163
|
+
writeEntities(rootDir, entities);
|
|
164
|
+
return entity;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Add a related entity link (bidirectional).
|
|
169
|
+
*/
|
|
170
|
+
export function addRelatedEntity(rootDir, entityNameA, entityNameB) {
|
|
171
|
+
const entities = readEntities(rootDir);
|
|
172
|
+
const nameA = String(entityNameA).trim().toLowerCase();
|
|
173
|
+
const nameB = String(entityNameB).trim().toLowerCase();
|
|
174
|
+
if (nameA === nameB) return null;
|
|
175
|
+
|
|
176
|
+
const entityA = entities.find((e) => e.name === nameA);
|
|
177
|
+
const entityB = entities.find((e) => e.name === nameB);
|
|
178
|
+
|
|
179
|
+
if (entityA) {
|
|
180
|
+
const related = new Set(entityA.relatedEntities || []);
|
|
181
|
+
related.add(nameB);
|
|
182
|
+
entityA.relatedEntities = [...related].slice(0, MAX_RELATED);
|
|
183
|
+
}
|
|
184
|
+
if (entityB) {
|
|
185
|
+
const related = new Set(entityB.relatedEntities || []);
|
|
186
|
+
related.add(nameA);
|
|
187
|
+
entityB.relatedEntities = [...related].slice(0, MAX_RELATED);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
writeEntities(rootDir, entities);
|
|
191
|
+
return { a: entityA, b: entityB };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Search entities by keyword in name, summary, or type.
|
|
196
|
+
*/
|
|
197
|
+
export function searchEntities(rootDir, query, { type = null, project = null } = {}) {
|
|
198
|
+
if (!query) return [];
|
|
199
|
+
const lower = String(query).toLowerCase();
|
|
200
|
+
|
|
201
|
+
let entities = readEntities(rootDir);
|
|
202
|
+
if (type) entities = entities.filter((e) => e.type === type);
|
|
203
|
+
if (project) entities = entities.filter((e) => e.project === project);
|
|
204
|
+
|
|
205
|
+
const vectors = readEntityVectors(rootDir);
|
|
206
|
+
if (vectors.length === 0) {
|
|
207
|
+
return entities.filter((e) =>
|
|
208
|
+
(e.name && e.name.includes(lower)) ||
|
|
209
|
+
(e.summary && e.summary.toLowerCase().includes(lower)) ||
|
|
210
|
+
(e.type && e.type.includes(lower)),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const queryEmbedding = embedText(String(query));
|
|
215
|
+
const filteredVectors = vectors.filter((v) =>
|
|
216
|
+
(!type || v.type === type) && (!project || v.project === project)
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const cosineScored = filteredVectors
|
|
220
|
+
.map((v) => ({ name: v.name, score: cosineSimilarity(queryEmbedding, v.embedding || []) }))
|
|
221
|
+
.filter(({ score }) => score > 0.05);
|
|
222
|
+
|
|
223
|
+
const candidateNames = new Set(cosineScored.map((v) => v.name));
|
|
224
|
+
for (const e of entities) {
|
|
225
|
+
if ((e.name && e.name.includes(lower)) ||
|
|
226
|
+
(e.summary && e.summary.toLowerCase().includes(lower)) ||
|
|
227
|
+
(e.type && e.type.includes(lower))) {
|
|
228
|
+
candidateNames.add(e.name);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const candidates = [...candidateNames]
|
|
233
|
+
.map((name) => entities.find((e) => e.name === name))
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.map((e) => ({ ...e, text: buildEntitySearchText(e) }));
|
|
236
|
+
|
|
237
|
+
const bm25Scored = rankByBm25(candidates, query, { limit: 20 });
|
|
238
|
+
const bm25Max = bm25Scored[0]?.score || 1;
|
|
239
|
+
|
|
240
|
+
const scoreMap = new Map();
|
|
241
|
+
for (const { name, score } of cosineScored) scoreMap.set(name, score);
|
|
242
|
+
for (const item of bm25Scored) {
|
|
243
|
+
const prev = scoreMap.get(item.name) || 0;
|
|
244
|
+
scoreMap.set(item.name, Math.max(prev, Math.min(item.score / bm25Max, 1)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return [...scoreMap.entries()]
|
|
248
|
+
.sort((a, b) => b[1] - a[1])
|
|
249
|
+
.map(([name]) => entities.find((e) => e.name === name))
|
|
250
|
+
.filter(Boolean);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get a single entity by name.
|
|
255
|
+
*/
|
|
256
|
+
export function getEntity(rootDir, name) {
|
|
257
|
+
const normalizedName = String(name).trim().toLowerCase();
|
|
258
|
+
const entities = readEntities(rootDir);
|
|
259
|
+
return entities.find((e) => e.name === normalizedName) || null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* List all entities with optional filters.
|
|
264
|
+
*/
|
|
265
|
+
export function listEntities(rootDir, { type = null, project = null, limit = 50 } = {}) {
|
|
266
|
+
let entities = readEntities(rootDir);
|
|
267
|
+
if (type) entities = entities.filter((e) => e.type === type);
|
|
268
|
+
if (project) entities = entities.filter((e) => e.project === project);
|
|
269
|
+
return entities.slice(0, limit);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Count entities.
|
|
274
|
+
*/
|
|
275
|
+
export function countEntities(rootDir, { type = null, project = null } = {}) {
|
|
276
|
+
let entities = readEntities(rootDir);
|
|
277
|
+
if (type) entities = entities.filter((e) => e.type === type);
|
|
278
|
+
if (project) entities = entities.filter((e) => e.project === project);
|
|
279
|
+
return entities.length;
|
|
280
|
+
}
|