agent-nuvira 3.3.12 → 3.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -1
- package/dist/agents/agents/planner.d.ts.map +1 -1
- package/dist/agents/agents/planner.js +9 -0
- package/dist/agents/agents/planner.js.map +1 -1
- package/dist/agents/agents/reasoner.d.ts.map +1 -1
- package/dist/agents/agents/reasoner.js +13 -0
- package/dist/agents/agents/reasoner.js.map +1 -1
- package/dist/agents/orchestrator.d.ts +31 -0
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +103 -135
- package/dist/agents/orchestrator.js.map +1 -1
- package/dist/cli/chat.d.ts +89 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +798 -39
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/cli-program.d.ts.map +1 -1
- package/dist/cli/cli-program.js +20 -0
- package/dist/cli/cli-program.js.map +1 -1
- package/dist/cli/config.d.ts +12 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +107 -3
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/decisions.d.ts +28 -0
- package/dist/cli/decisions.d.ts.map +1 -0
- package/dist/cli/decisions.js +140 -0
- package/dist/cli/decisions.js.map +1 -0
- package/dist/cli/doctor.d.ts +6 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +46 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/execute.d.ts.map +1 -1
- package/dist/cli/execute.js +6 -0
- package/dist/cli/execute.js.map +1 -1
- package/dist/cli/failover-runner.d.ts.map +1 -1
- package/dist/cli/failover-runner.js +19 -1
- package/dist/cli/failover-runner.js.map +1 -1
- package/dist/cli/feedback.d.ts.map +1 -1
- package/dist/cli/feedback.js +5 -1
- package/dist/cli/feedback.js.map +1 -1
- package/dist/cli/hooks.d.ts +30 -0
- package/dist/cli/hooks.d.ts.map +1 -0
- package/dist/cli/hooks.js +224 -0
- package/dist/cli/hooks.js.map +1 -0
- package/dist/cli/knowledge.d.ts +31 -0
- package/dist/cli/knowledge.d.ts.map +1 -0
- package/dist/cli/knowledge.js +223 -0
- package/dist/cli/knowledge.js.map +1 -0
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +92 -18
- package/dist/cli/loop-executor.js.map +1 -1
- package/dist/cli/model.d.ts +20 -0
- package/dist/cli/model.d.ts.map +1 -1
- package/dist/cli/model.js +294 -14
- package/dist/cli/model.js.map +1 -1
- package/dist/cli/omniroute-control.d.ts +88 -0
- package/dist/cli/omniroute-control.d.ts.map +1 -0
- package/dist/cli/omniroute-control.js +179 -0
- package/dist/cli/omniroute-control.js.map +1 -0
- package/dist/cli/omniroute.d.ts +21 -0
- package/dist/cli/omniroute.d.ts.map +1 -0
- package/dist/cli/omniroute.js +67 -0
- package/dist/cli/omniroute.js.map +1 -0
- package/dist/cli/rate.d.ts +78 -0
- package/dist/cli/rate.d.ts.map +1 -0
- package/dist/cli/rate.js +294 -0
- package/dist/cli/rate.js.map +1 -0
- package/dist/config/process-env.d.ts.map +1 -1
- package/dist/config/process-env.js +10 -0
- package/dist/config/process-env.js.map +1 -1
- package/dist/config/types.d.ts +8 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/history.d.ts +21 -0
- package/dist/context/history.d.ts.map +1 -1
- package/dist/context/history.js +50 -0
- package/dist/context/history.js.map +1 -1
- package/dist/enterprise/vault.d.ts.map +1 -1
- package/dist/enterprise/vault.js +5 -21
- package/dist/enterprise/vault.js.map +1 -1
- package/dist/gateway/hook-contract.d.ts +206 -0
- package/dist/gateway/hook-contract.d.ts.map +1 -0
- package/dist/gateway/hook-contract.js +449 -0
- package/dist/gateway/hook-contract.js.map +1 -0
- package/dist/gateway/hooks.d.ts.map +1 -1
- package/dist/gateway/hooks.js +14 -0
- package/dist/gateway/hooks.js.map +1 -1
- package/dist/inference/gemini-adapter.d.ts.map +1 -1
- package/dist/inference/gemini-adapter.js +4 -0
- package/dist/inference/gemini-adapter.js.map +1 -1
- package/dist/inference/interface.d.ts +54 -0
- package/dist/inference/interface.d.ts.map +1 -1
- package/dist/inference/model-id-validation.d.ts +57 -0
- package/dist/inference/model-id-validation.d.ts.map +1 -0
- package/dist/inference/model-id-validation.js +127 -0
- package/dist/inference/model-id-validation.js.map +1 -0
- package/dist/inference/native-tools.d.ts +16 -1
- package/dist/inference/native-tools.d.ts.map +1 -1
- package/dist/inference/native-tools.js +34 -10
- package/dist/inference/native-tools.js.map +1 -1
- package/dist/inference/openai-compat-adapter.d.ts.map +1 -1
- package/dist/inference/openai-compat-adapter.js +12 -1
- package/dist/inference/openai-compat-adapter.js.map +1 -1
- package/dist/inference/provider-catalog.d.ts +11 -0
- package/dist/inference/provider-catalog.d.ts.map +1 -1
- package/dist/inference/provider-catalog.js +80 -0
- package/dist/inference/provider-catalog.js.map +1 -1
- package/dist/inference/route-resolver.d.ts +32 -8
- package/dist/inference/route-resolver.d.ts.map +1 -1
- package/dist/inference/route-resolver.js +175 -1
- package/dist/inference/route-resolver.js.map +1 -1
- package/dist/inference/tool-call-utils.d.ts.map +1 -1
- package/dist/inference/tool-call-utils.js +10 -8
- package/dist/inference/tool-call-utils.js.map +1 -1
- package/dist/inference/tools.d.ts +17 -1
- package/dist/inference/tools.d.ts.map +1 -1
- package/dist/inference/tools.js +69 -19
- package/dist/inference/tools.js.map +1 -1
- package/dist/learning/acceptance-model.d.ts +247 -0
- package/dist/learning/acceptance-model.d.ts.map +1 -0
- package/dist/learning/acceptance-model.js +708 -0
- package/dist/learning/acceptance-model.js.map +1 -0
- package/dist/learning/auto-router.d.ts +121 -24
- package/dist/learning/auto-router.d.ts.map +1 -1
- package/dist/learning/auto-router.js +374 -96
- package/dist/learning/auto-router.js.map +1 -1
- package/dist/learning/autonomy-policy.d.ts +12 -16
- package/dist/learning/autonomy-policy.d.ts.map +1 -1
- package/dist/learning/autonomy-policy.js +50 -48
- package/dist/learning/autonomy-policy.js.map +1 -1
- package/dist/learning/behavioural-labels.d.ts +86 -0
- package/dist/learning/behavioural-labels.d.ts.map +1 -0
- package/dist/learning/behavioural-labels.js +166 -0
- package/dist/learning/behavioural-labels.js.map +1 -0
- package/dist/learning/capability-evidence.d.ts +167 -0
- package/dist/learning/capability-evidence.d.ts.map +1 -0
- package/dist/learning/capability-evidence.js +210 -0
- package/dist/learning/capability-evidence.js.map +1 -0
- package/dist/learning/capability-readiness.d.ts +64 -0
- package/dist/learning/capability-readiness.d.ts.map +1 -0
- package/dist/learning/capability-readiness.js +74 -0
- package/dist/learning/capability-readiness.js.map +1 -0
- package/dist/learning/capability-types.d.ts +169 -0
- package/dist/learning/capability-types.d.ts.map +1 -0
- package/dist/learning/capability-types.js +20 -0
- package/dist/learning/capability-types.js.map +1 -0
- package/dist/learning/catalog-feed.d.ts +89 -0
- package/dist/learning/catalog-feed.d.ts.map +1 -0
- package/dist/learning/catalog-feed.js +211 -0
- package/dist/learning/catalog-feed.js.map +1 -0
- package/dist/learning/cli-intent-effects.d.ts +60 -0
- package/dist/learning/cli-intent-effects.d.ts.map +1 -0
- package/dist/learning/cli-intent-effects.js +153 -0
- package/dist/learning/cli-intent-effects.js.map +1 -0
- package/dist/learning/command-adaptation.d.ts +41 -0
- package/dist/learning/command-adaptation.d.ts.map +1 -0
- package/dist/learning/command-adaptation.js +122 -0
- package/dist/learning/command-adaptation.js.map +1 -0
- package/dist/learning/consent-picture.d.ts +42 -0
- package/dist/learning/consent-picture.d.ts.map +1 -0
- package/dist/learning/consent-picture.js +53 -0
- package/dist/learning/consent-picture.js.map +1 -0
- package/dist/learning/context-budget.d.ts +37 -6
- package/dist/learning/context-budget.d.ts.map +1 -1
- package/dist/learning/context-budget.js +38 -9
- package/dist/learning/context-budget.js.map +1 -1
- package/dist/learning/decision-log.d.ts +114 -0
- package/dist/learning/decision-log.d.ts.map +1 -0
- package/dist/learning/decision-log.js +296 -0
- package/dist/learning/decision-log.js.map +1 -0
- package/dist/learning/deliverable-class.d.ts.map +1 -1
- package/dist/learning/deliverable-class.js +19 -0
- package/dist/learning/deliverable-class.js.map +1 -1
- package/dist/learning/deliverable-corpus.d.ts +117 -0
- package/dist/learning/deliverable-corpus.d.ts.map +1 -0
- package/dist/learning/deliverable-corpus.js +176 -0
- package/dist/learning/deliverable-corpus.js.map +1 -0
- package/dist/learning/error-repair.d.ts +45 -2
- package/dist/learning/error-repair.d.ts.map +1 -1
- package/dist/learning/error-repair.js +95 -0
- package/dist/learning/error-repair.js.map +1 -1
- package/dist/learning/exhaustion-report.d.ts +136 -0
- package/dist/learning/exhaustion-report.d.ts.map +1 -0
- package/dist/learning/exhaustion-report.js +239 -0
- package/dist/learning/exhaustion-report.js.map +1 -0
- package/dist/learning/failure-bookkeeping.d.ts +16 -0
- package/dist/learning/failure-bookkeeping.d.ts.map +1 -1
- package/dist/learning/failure-bookkeeping.js +51 -5
- package/dist/learning/failure-bookkeeping.js.map +1 -1
- package/dist/learning/gate-friction.d.ts +39 -0
- package/dist/learning/gate-friction.d.ts.map +1 -0
- package/dist/learning/gate-friction.js +91 -0
- package/dist/learning/gate-friction.js.map +1 -0
- package/dist/learning/harness-narration.d.ts +34 -0
- package/dist/learning/harness-narration.d.ts.map +1 -0
- package/dist/learning/harness-narration.js +58 -0
- package/dist/learning/harness-narration.js.map +1 -0
- package/dist/learning/hub-skill-catalog.d.ts.map +1 -1
- package/dist/learning/hub-skill-catalog.js +1 -2
- package/dist/learning/hub-skill-catalog.js.map +1 -1
- package/dist/learning/hybrid-router.d.ts +65 -0
- package/dist/learning/hybrid-router.d.ts.map +1 -1
- package/dist/learning/hybrid-router.js +186 -0
- package/dist/learning/hybrid-router.js.map +1 -1
- package/dist/learning/knowledge-base.d.ts +350 -0
- package/dist/learning/knowledge-base.d.ts.map +1 -0
- package/dist/learning/knowledge-base.js +906 -0
- package/dist/learning/knowledge-base.js.map +1 -0
- package/dist/learning/knowledge-eval.d.ts +171 -0
- package/dist/learning/knowledge-eval.d.ts.map +1 -0
- package/dist/learning/knowledge-eval.js +196 -0
- package/dist/learning/knowledge-eval.js.map +1 -0
- package/dist/learning/knowledge-turn.d.ts +92 -0
- package/dist/learning/knowledge-turn.d.ts.map +1 -0
- package/dist/learning/knowledge-turn.js +190 -0
- package/dist/learning/knowledge-turn.js.map +1 -0
- package/dist/learning/learned-commands.d.ts +64 -0
- package/dist/learning/learned-commands.d.ts.map +1 -0
- package/dist/learning/learned-commands.js +108 -0
- package/dist/learning/learned-commands.js.map +1 -0
- package/dist/learning/lexical-search.d.ts +50 -0
- package/dist/learning/lexical-search.d.ts.map +1 -0
- package/dist/learning/lexical-search.js +111 -0
- package/dist/learning/lexical-search.js.map +1 -0
- package/dist/learning/machine-facts.d.ts +71 -0
- package/dist/learning/machine-facts.d.ts.map +1 -0
- package/dist/learning/machine-facts.js +164 -0
- package/dist/learning/machine-facts.js.map +1 -0
- package/dist/learning/markdown-outline.d.ts +73 -0
- package/dist/learning/markdown-outline.d.ts.map +1 -0
- package/dist/learning/markdown-outline.js +136 -0
- package/dist/learning/markdown-outline.js.map +1 -0
- package/dist/learning/model-first-router.d.ts +33 -1
- package/dist/learning/model-first-router.d.ts.map +1 -1
- package/dist/learning/model-first-router.js +123 -9
- package/dist/learning/model-first-router.js.map +1 -1
- package/dist/learning/model-identity.d.ts +80 -0
- package/dist/learning/model-identity.d.ts.map +1 -0
- package/dist/learning/model-identity.js +115 -0
- package/dist/learning/model-identity.js.map +1 -0
- package/dist/learning/model-registry.d.ts +141 -2
- package/dist/learning/model-registry.d.ts.map +1 -1
- package/dist/learning/model-registry.js +324 -38
- package/dist/learning/model-registry.js.map +1 -1
- package/dist/learning/model-scoring.d.ts.map +1 -1
- package/dist/learning/model-scoring.js +8 -1
- package/dist/learning/model-scoring.js.map +1 -1
- package/dist/learning/model-selection.d.ts +2 -1
- package/dist/learning/model-selection.d.ts.map +1 -1
- package/dist/learning/model-selection.js +76 -2
- package/dist/learning/model-selection.js.map +1 -1
- package/dist/learning/outcome-observation.d.ts +49 -0
- package/dist/learning/outcome-observation.d.ts.map +1 -0
- package/dist/learning/outcome-observation.js +95 -0
- package/dist/learning/outcome-observation.js.map +1 -0
- package/dist/learning/pair-entitlement.d.ts +184 -0
- package/dist/learning/pair-entitlement.d.ts.map +1 -0
- package/dist/learning/pair-entitlement.js +214 -0
- package/dist/learning/pair-entitlement.js.map +1 -0
- package/dist/learning/project-docs.d.ts +83 -0
- package/dist/learning/project-docs.d.ts.map +1 -0
- package/dist/learning/project-docs.js +132 -0
- package/dist/learning/project-docs.js.map +1 -0
- package/dist/learning/provider-fallback.d.ts +23 -1
- package/dist/learning/provider-fallback.d.ts.map +1 -1
- package/dist/learning/provider-fallback.js +24 -0
- package/dist/learning/provider-fallback.js.map +1 -1
- package/dist/learning/quota-ledger.d.ts +13 -0
- package/dist/learning/quota-ledger.d.ts.map +1 -1
- package/dist/learning/quota-ledger.js +28 -0
- package/dist/learning/quota-ledger.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +65 -1
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js +123 -1
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/learning/requirement-probe.d.ts +55 -0
- package/dist/learning/requirement-probe.d.ts.map +1 -0
- package/dist/learning/requirement-probe.js +77 -0
- package/dist/learning/requirement-probe.js.map +1 -0
- package/dist/learning/resilient-call.d.ts +20 -1
- package/dist/learning/resilient-call.d.ts.map +1 -1
- package/dist/learning/resilient-call.js +42 -4
- package/dist/learning/resilient-call.js.map +1 -1
- package/dist/learning/response-usability.d.ts +85 -0
- package/dist/learning/response-usability.d.ts.map +1 -0
- package/dist/learning/response-usability.js +135 -0
- package/dist/learning/response-usability.js.map +1 -0
- package/dist/learning/router-bandit.d.ts +132 -0
- package/dist/learning/router-bandit.d.ts.map +1 -1
- package/dist/learning/router-bandit.js +253 -0
- package/dist/learning/router-bandit.js.map +1 -1
- package/dist/learning/routing-history.d.ts +41 -2
- package/dist/learning/routing-history.d.ts.map +1 -1
- package/dist/learning/routing-history.js.map +1 -1
- package/dist/learning/run-trace.d.ts +21 -0
- package/dist/learning/run-trace.d.ts.map +1 -1
- package/dist/learning/run-trace.js +38 -0
- package/dist/learning/run-trace.js.map +1 -1
- package/dist/learning/seeded-benchmark.d.ts +14 -0
- package/dist/learning/seeded-benchmark.d.ts.map +1 -1
- package/dist/learning/seeded-benchmark.js +43 -4
- package/dist/learning/seeded-benchmark.js.map +1 -1
- package/dist/learning/session-grant.d.ts +75 -0
- package/dist/learning/session-grant.d.ts.map +1 -0
- package/dist/learning/session-grant.js +112 -0
- package/dist/learning/session-grant.js.map +1 -0
- package/dist/learning/task-model-continuity.d.ts +73 -0
- package/dist/learning/task-model-continuity.d.ts.map +1 -0
- package/dist/learning/task-model-continuity.js +87 -0
- package/dist/learning/task-model-continuity.js.map +1 -0
- package/dist/learning/turn-feedback.d.ts +93 -0
- package/dist/learning/turn-feedback.d.ts.map +1 -0
- package/dist/learning/turn-feedback.js +127 -0
- package/dist/learning/turn-feedback.js.map +1 -0
- package/dist/learning/turn-report.d.ts +104 -2
- package/dist/learning/turn-report.d.ts.map +1 -1
- package/dist/learning/turn-report.js +161 -6
- package/dist/learning/turn-report.js.map +1 -1
- package/dist/learning/verdict-routing.d.ts +51 -0
- package/dist/learning/verdict-routing.d.ts.map +1 -0
- package/dist/learning/verdict-routing.js +122 -0
- package/dist/learning/verdict-routing.js.map +1 -0
- package/dist/mcp/client.d.ts.map +1 -1
- package/dist/mcp/client.js +5 -0
- package/dist/mcp/client.js.map +1 -1
- package/dist/mcp/manager.d.ts +10 -0
- package/dist/mcp/manager.d.ts.map +1 -1
- package/dist/mcp/manager.js +20 -0
- package/dist/mcp/manager.js.map +1 -1
- package/dist/mcp/mcp-discovery.d.ts +34 -0
- package/dist/mcp/mcp-discovery.d.ts.map +1 -0
- package/dist/mcp/mcp-discovery.js +71 -0
- package/dist/mcp/mcp-discovery.js.map +1 -0
- package/dist/mcp/mcp-schema-cache.d.ts +17 -0
- package/dist/mcp/mcp-schema-cache.d.ts.map +1 -1
- package/dist/mcp/mcp-schema-cache.js +47 -3
- package/dist/mcp/mcp-schema-cache.js.map +1 -1
- package/dist/mcp/types.d.ts +28 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/mcp/types.js.map +1 -1
- package/dist/security/secret-scan.d.ts +156 -0
- package/dist/security/secret-scan.d.ts.map +1 -0
- package/dist/security/secret-scan.js +525 -0
- package/dist/security/secret-scan.js.map +1 -0
- package/dist/tools/ask-user.d.ts.map +1 -1
- package/dist/tools/ask-user.js +4 -0
- package/dist/tools/ask-user.js.map +1 -1
- package/dist/tools/capability-registry.d.ts +150 -0
- package/dist/tools/capability-registry.d.ts.map +1 -0
- package/dist/tools/capability-registry.js +400 -0
- package/dist/tools/capability-registry.js.map +1 -0
- package/dist/tools/child-agent-runtime.d.ts.map +1 -1
- package/dist/tools/child-agent-runtime.js +19 -5
- package/dist/tools/child-agent-runtime.js.map +1 -1
- package/dist/tools/coding-tools.d.ts +17 -5
- package/dist/tools/coding-tools.d.ts.map +1 -1
- package/dist/tools/coding-tools.js +80 -11
- package/dist/tools/coding-tools.js.map +1 -1
- package/dist/tools/command-memo.d.ts +116 -0
- package/dist/tools/command-memo.d.ts.map +1 -0
- package/dist/tools/command-memo.js +202 -0
- package/dist/tools/command-memo.js.map +1 -0
- package/dist/tools/git-tool.d.ts.map +1 -1
- package/dist/tools/git-tool.js +13 -3
- package/dist/tools/git-tool.js.map +1 -1
- package/dist/tools/harness-directive.d.ts +42 -0
- package/dist/tools/harness-directive.d.ts.map +1 -0
- package/dist/tools/harness-directive.js +45 -0
- package/dist/tools/harness-directive.js.map +1 -0
- package/dist/tools/loop-skill-hint.d.ts +74 -174
- package/dist/tools/loop-skill-hint.d.ts.map +1 -1
- package/dist/tools/loop-skill-hint.js +110 -536
- package/dist/tools/loop-skill-hint.js.map +1 -1
- package/dist/tools/mcp-client-tool.d.ts +2 -0
- package/dist/tools/mcp-client-tool.d.ts.map +1 -1
- package/dist/tools/mcp-client-tool.js +3 -0
- package/dist/tools/mcp-client-tool.js.map +1 -1
- package/dist/tools/modality/shared.d.ts +1 -4
- package/dist/tools/modality/shared.d.ts.map +1 -1
- package/dist/tools/modality/shared.js +7 -22
- package/dist/tools/modality/shared.js.map +1 -1
- package/dist/tools/plan-store.d.ts +44 -4
- package/dist/tools/plan-store.d.ts.map +1 -1
- package/dist/tools/plan-store.js +93 -9
- package/dist/tools/plan-store.js.map +1 -1
- package/dist/tools/registry.d.ts +76 -3
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +623 -15
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/remediation-ladder.d.ts +91 -0
- package/dist/tools/remediation-ladder.d.ts.map +1 -0
- package/dist/tools/remediation-ladder.js +316 -0
- package/dist/tools/remediation-ladder.js.map +1 -0
- package/dist/tools/run-cli.d.ts.map +1 -1
- package/dist/tools/run-cli.js +29 -4
- package/dist/tools/run-cli.js.map +1 -1
- package/dist/tools/run-terminal.d.ts +11 -0
- package/dist/tools/run-terminal.d.ts.map +1 -1
- package/dist/tools/run-terminal.js +289 -10
- package/dist/tools/run-terminal.js.map +1 -1
- package/dist/tools/skills-hub.d.ts +7 -0
- package/dist/tools/skills-hub.d.ts.map +1 -1
- package/dist/tools/skills-hub.js +11 -2
- package/dist/tools/skills-hub.js.map +1 -1
- package/dist/tools/step-artifact.d.ts +35 -0
- package/dist/tools/step-artifact.d.ts.map +1 -0
- package/dist/tools/step-artifact.js +154 -0
- package/dist/tools/step-artifact.js.map +1 -0
- package/dist/tools/tool-loop.d.ts +327 -13
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +891 -66
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/tools/toolsets.js +4 -4
- package/dist/tools/toolsets.js.map +1 -1
- package/dist/tools/worktree.d.ts +11 -2
- package/dist/tools/worktree.d.ts.map +1 -1
- package/dist/tools/worktree.js +11 -2
- package/dist/tools/worktree.js.map +1 -1
- package/dist/utils/atomic-store.d.ts +53 -0
- package/dist/utils/atomic-store.d.ts.map +1 -0
- package/dist/utils/atomic-store.js +178 -0
- package/dist/utils/atomic-store.js.map +1 -0
- package/dist/utils/binary-probe.d.ts +25 -0
- package/dist/utils/binary-probe.d.ts.map +1 -0
- package/dist/utils/binary-probe.js +48 -0
- package/dist/utils/binary-probe.js.map +1 -0
- package/dist/utils/workspace-path.d.ts +50 -0
- package/dist/utils/workspace-path.d.ts.map +1 -0
- package/dist/utils/workspace-path.js +86 -0
- package/dist/utils/workspace-path.js.map +1 -0
- package/dist/web-dashboard/chat-console.d.ts +24 -0
- package/dist/web-dashboard/chat-console.d.ts.map +1 -1
- package/dist/web-dashboard/chat-console.js +38 -0
- package/dist/web-dashboard/chat-console.js.map +1 -1
- package/dist/web-dashboard/project-context.d.ts +27 -2
- package/dist/web-dashboard/project-context.d.ts.map +1 -1
- package/dist/web-dashboard/project-context.js +32 -2
- package/dist/web-dashboard/project-context.js.map +1 -1
- package/dist/web-dashboard/server.d.ts +40 -0
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +733 -15
- package/dist/web-dashboard/server.js.map +1 -1
- package/dist/web-dashboard/src/types.d.ts +236 -1
- package/dist/web-dashboard/src/types.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.d.ts +18 -2
- package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.js +94 -12
- package/dist/web-dashboard/workspace-guard.js.map +1 -1
- package/dist/web-dashboard/workspace-resolution.d.ts +87 -0
- package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -0
- package/dist/web-dashboard/workspace-resolution.js +105 -0
- package/dist/web-dashboard/workspace-resolution.js.map +1 -0
- package/package.json +1 -1
- package/src/web-dashboard/public/assets/index-tK8Y2qHB.css +1 -0
- package/src/web-dashboard/public/assets/index-zjbdJnO-.js +210 -0
- package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
- package/src/web-dashboard/public/index.html +2 -2
- package/src/web-dashboard/public/assets/index-BrZcYhy6.js +0 -207
- package/src/web-dashboard/public/assets/index-BrZcYhy6.js.map +0 -1
- package/src/web-dashboard/public/assets/index-DsWczTa6.css +0 -1
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KnowledgeBase — tag-scoped, vectorized retrieval over a user's own documents.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. The retrieval engine (`src/learning/retrieval.ts`) vectorizes
|
|
5
|
+
* a REPO for token reduction and keys everything by file path; the tagged memory
|
|
6
|
+
* store (`src/tools/memory-tools.ts`) holds FACTS, not whole documents. Neither
|
|
7
|
+
* answers "I brought my documents, gave them a tag, and I want precise answers
|
|
8
|
+
* scoped to that tag." So a user asking about their own data had exactly one
|
|
9
|
+
* path — re-read the file into context on EVERY turn, which costs the document's
|
|
10
|
+
* tokens repeatedly and is slow on large files.
|
|
11
|
+
*
|
|
12
|
+
* This module pays that cost ONCE. A document is extracted, chunked and embedded
|
|
13
|
+
* a single time under a tag; each later question embeds a short query and
|
|
14
|
+
* retrieves its top-k chunks. The tag is a real scope: each tag gets its own
|
|
15
|
+
* vector namespace (`knowledge-<tag>`), so tags never bleed into each other and
|
|
16
|
+
* a tag can be forgotten without touching anything else.
|
|
17
|
+
*
|
|
18
|
+
* Retrieval is relevance-FLOORED (`DEFAULT_KNOWLEDGE_MIN_SIMILARITY`), because
|
|
19
|
+
* "the nearest k neighbours" is not the same claim as "k relevant passages":
|
|
20
|
+
* an unrelated question must be able to come back empty rather than receive the
|
|
21
|
+
* closest six chunks of a document it has nothing to do with.
|
|
22
|
+
*
|
|
23
|
+
* Retrieval is also HYBRID (Phase 2.2–2.4): a dense (vector) half and a lexical
|
|
24
|
+
* (BM25, see `lexical-search.ts`) half are fused with Reciprocal Rank Fusion and
|
|
25
|
+
* then capped per source document. The two halves fail on different questions —
|
|
26
|
+
* embeddings miss the exact identifier, clause number or error code a user pastes
|
|
27
|
+
* in, and BM25 misses a paraphrase with no shared words — so ranking both and
|
|
28
|
+
* fusing their RANKS (not their incomparable scores) gets both. The cosine floor
|
|
29
|
+
* still governs DENSE evidence; lexical evidence is governed by its own term
|
|
30
|
+
* overlap, which is why an unrelated question (no shared vocabulary) still comes
|
|
31
|
+
* back empty from both halves. `fusion: 'dense'` reproduces vector-only
|
|
32
|
+
* retrieval for a caller that wants it or for the eval to compare against.
|
|
33
|
+
*
|
|
34
|
+
* The retrieved chunks are USER DATA; the model supplies the generic half of an
|
|
35
|
+
* answer (and `web-research` supplies the net-based half). `formatKnowledgeContext`
|
|
36
|
+
* labels each chunk with its source file so the model can attribute precisely
|
|
37
|
+
* which part of an answer came from the user's data.
|
|
38
|
+
*
|
|
39
|
+
* Storage: vectors in the existing pluggable VectorStore (one JSON/FAISS file
|
|
40
|
+
* per namespace), a small manifest at `<memory>/knowledge-index.json` for
|
|
41
|
+
* listing/removal. Everything lives in the Nuvira data dir (`~/.nuvira/memory`),
|
|
42
|
+
* never in a repository or package.
|
|
43
|
+
*/
|
|
44
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
import { join, extname, basename } from 'node:path';
|
|
47
|
+
import { envBuff, resolveNuviraHome } from '../config/paths.js';
|
|
48
|
+
import { embed, RETRIEVAL_MODEL } from '../memory/embedder.js';
|
|
49
|
+
import { getVectorStore, cosineSimilarity } from '../memory/vector-store.js';
|
|
50
|
+
import { DEFAULT_CHUNK_TOKENS, DEFAULT_OVERLAP_TOKENS } from './retrieval.js';
|
|
51
|
+
import { chunkMarkdown, parseMarkdownOutline } from './markdown-outline.js';
|
|
52
|
+
import { bm25Search } from './lexical-search.js';
|
|
53
|
+
import { logger } from '../utils/logger.js';
|
|
54
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
55
|
+
/** A tag is a sandboxed namespace segment: lowercase alphanumerics + hyphens, with at least one alphanumeric. */
|
|
56
|
+
export const KNOWLEDGE_TAG_RE = /^[a-z0-9-]*[a-z0-9][a-z0-9-]*$/;
|
|
57
|
+
/** Namespace prefix — keeps knowledge vectors apart from memory/history/repo. */
|
|
58
|
+
export const KNOWLEDGE_NAMESPACE_PREFIX = 'knowledge-';
|
|
59
|
+
/** Manifest of tags → documents (listing/removal only; vectors live elsewhere). */
|
|
60
|
+
export const KNOWLEDGE_MANIFEST_FILE = 'knowledge-index.json';
|
|
61
|
+
/** Default chunks returned per query. */
|
|
62
|
+
export const DEFAULT_KNOWLEDGE_TOP_K = 6;
|
|
63
|
+
/**
|
|
64
|
+
* Relevance floor for retrieval.
|
|
65
|
+
*
|
|
66
|
+
* Every backend scores in cosine similarity, where 1 means "the same direction"
|
|
67
|
+
* and 0 means "unrelated", so a hit below this is at best a weak topic touch.
|
|
68
|
+
* The floor is what makes an HONEST empty answer reachable: nearest-neighbour
|
|
69
|
+
* search always returns its k nearest neighbours, so without a floor the k-th
|
|
70
|
+
* can be a passage about something else entirely and an unrelated question still
|
|
71
|
+
* receives a confident-looking block of the wrong document. A hit below the
|
|
72
|
+
* floor is not evidence about the question, so it is dropped here rather than
|
|
73
|
+
* left to the model to notice and ignore.
|
|
74
|
+
*
|
|
75
|
+
* CALIBRATED, NOT GUESSED. Cosine similarity between a question and its own
|
|
76
|
+
* document is a property of the EMBEDDING MODEL, so this number only means
|
|
77
|
+
* anything next to the model that produced the vectors. On the eval corpus with
|
|
78
|
+
* the retrieval model (`RETRIEVAL_MODEL`, bge-small-en-v1.5), unrelated
|
|
79
|
+
* questions reach up to 0.425 and the lowest-scoring CORRECT answer sits at
|
|
80
|
+
* 0.635 (p50 0.772). 0.5 is the round value inside that
|
|
81
|
+
* measured gap with margin on both sides: above every leak probe and below every
|
|
82
|
+
* correct answer. `tests/live/knowledge-eval-live.test.ts` prints the band and
|
|
83
|
+
* FAILS if the shipped floor falls outside it, so this is re-measurable rather
|
|
84
|
+
* than folklore.
|
|
85
|
+
*
|
|
86
|
+
* IT IS TIER-SPECIFIC, and the embedder is TIERED. If the local ONNX model
|
|
87
|
+
* cannot load, `embed` falls back to a different model — Python
|
|
88
|
+
* `all-MiniLM-L6-v2`, on a different similarity scale — or to a zero vector,
|
|
89
|
+
* WITHOUT throwing. A floor calibrated for one model is simply wrong for
|
|
90
|
+
* another, and wrong in a way that looks measured. The live eval therefore proves
|
|
91
|
+
* which model actually ran (an identity probe) before it reports a band, and
|
|
92
|
+
* fails loudly if they disagree. The offline eval uses its OWN floor and says so
|
|
93
|
+
* (`KNOWLEDGE_EVAL_OFFLINE_MIN_SIMILARITY`), because a bag-of-words vectorizer is
|
|
94
|
+
* not on this model's scale at all.
|
|
95
|
+
*/
|
|
96
|
+
export const DEFAULT_KNOWLEDGE_MIN_SIMILARITY = 0.5;
|
|
97
|
+
/**
|
|
98
|
+
* How many chunks ONE source document may contribute to a result.
|
|
99
|
+
*
|
|
100
|
+
* Fusion alone can hand the whole top-k to whichever document the query happens
|
|
101
|
+
* to touch most, because a long document has more chances to contain a term —
|
|
102
|
+
* so a single highly-ranked file would crowd out the shorter document that
|
|
103
|
+
* actually answers the question. The cap promotes diversity of SOURCES.
|
|
104
|
+
*
|
|
105
|
+
* It is deliberately relaxed to "no cap" when the candidate pool has only one
|
|
106
|
+
* source: the common case is a tag holding a single document, and capping it
|
|
107
|
+
* there would throw away half the passages for no diversity to gain.
|
|
108
|
+
*/
|
|
109
|
+
export const DEFAULT_KNOWLEDGE_MAX_PER_SOURCE = 3;
|
|
110
|
+
/**
|
|
111
|
+
* Dense AND lexical candidates considered before fusion, as a multiple of topK
|
|
112
|
+
* (floored at MIN_CANDIDATE_POOL). Fusion has to see more than `topK` from each
|
|
113
|
+
* half: a passage the dense half ranked 12th and the lexical half ranked 2nd
|
|
114
|
+
* should be able to win, and it can only do that if both halves were given a
|
|
115
|
+
* pool deeper than the answer.
|
|
116
|
+
*/
|
|
117
|
+
const CANDIDATE_POOL_MULTIPLIER = 4;
|
|
118
|
+
const MIN_CANDIDATE_POOL = 24;
|
|
119
|
+
/**
|
|
120
|
+
* Reciprocal Rank Fusion's rank constant (Cormack et al., 2009). A larger value
|
|
121
|
+
* flattens the difference between adjacent ranks. 60 is the standard default and
|
|
122
|
+
* is what makes RRF safe here: it fuses RANKS, so cosine similarity — which is
|
|
123
|
+
* on the embedding model's scale — is never added to a BM25 score, which is on
|
|
124
|
+
* its own unbounded scale. The two numbers are never compared, only their orders.
|
|
125
|
+
*/
|
|
126
|
+
const RRF_K = 60;
|
|
127
|
+
/** Directories never walked when a tag is pointed at a folder. */
|
|
128
|
+
const IGNORE_DIRS = new Set([
|
|
129
|
+
'node_modules', '.git', 'dist', 'build', '.next', '.cache', '.venv', 'venv',
|
|
130
|
+
'__pycache__', '.turbo', '.nx', 'coverage', '.idea', '.vscode',
|
|
131
|
+
]);
|
|
132
|
+
/** Cap a directory walk so a stray `.` cannot index an entire disk. */
|
|
133
|
+
const MAX_FILES_PER_INGEST = 500;
|
|
134
|
+
/** Per-chunk text kept in metadata (matches retrieval.ts's budget). */
|
|
135
|
+
const MAX_EXTRACT_CHARS = 2_000_000;
|
|
136
|
+
// ─── Paths (resolved per call so tests that set NUVIRA_MEMORY_DIR are hermetic) ──
|
|
137
|
+
function memoryDir() {
|
|
138
|
+
return envBuff('MEMORY_DIR') || join(resolveNuviraHome(), 'memory');
|
|
139
|
+
}
|
|
140
|
+
function manifestPath() {
|
|
141
|
+
return join(memoryDir(), KNOWLEDGE_MANIFEST_FILE);
|
|
142
|
+
}
|
|
143
|
+
// ─── Tag handling ───────────────────────────────────────────────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Normalize a user-supplied tag into the sandboxed form. `Dheeraj_Health_report`
|
|
146
|
+
* → `dheeraj-health-report`. This is deliberately forgiving: a human naming a
|
|
147
|
+
* tag should not have to know the namespace rules, and a tag is not a path.
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeKnowledgeTag(tag) {
|
|
150
|
+
return tag
|
|
151
|
+
.trim()
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
154
|
+
.replace(/^-+|-+$/g, '')
|
|
155
|
+
.replace(/-{2,}/g, '-');
|
|
156
|
+
}
|
|
157
|
+
/** True when a (already-normalized) tag is valid and non-empty. */
|
|
158
|
+
export function isValidKnowledgeTag(tag) {
|
|
159
|
+
return KNOWLEDGE_TAG_RE.test(tag);
|
|
160
|
+
}
|
|
161
|
+
/** The VectorStore namespace for a tag. Assumes a normalized tag. */
|
|
162
|
+
export function namespaceForTag(tag) {
|
|
163
|
+
return `${KNOWLEDGE_NAMESPACE_PREFIX}${tag}`;
|
|
164
|
+
}
|
|
165
|
+
// ─── Manifest ───────────────────────────────────────────────────────────────
|
|
166
|
+
function emptyManifest() {
|
|
167
|
+
return { version: 1, tags: {} };
|
|
168
|
+
}
|
|
169
|
+
/** Read the tag manifest. Never throws — a missing/corrupt file yields empty. */
|
|
170
|
+
export function readKnowledgeManifest() {
|
|
171
|
+
try {
|
|
172
|
+
if (!existsSync(manifestPath()))
|
|
173
|
+
return emptyManifest();
|
|
174
|
+
const data = JSON.parse(readFileSync(manifestPath(), 'utf-8'));
|
|
175
|
+
if (!data || typeof data !== 'object' || typeof data.tags !== 'object')
|
|
176
|
+
return emptyManifest();
|
|
177
|
+
return { version: data.version ?? 1, tags: data.tags ?? {} };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return emptyManifest();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function writeKnowledgeManifest(manifest) {
|
|
184
|
+
try {
|
|
185
|
+
if (!existsSync(memoryDir()))
|
|
186
|
+
mkdirSync(memoryDir(), { recursive: true });
|
|
187
|
+
writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2), 'utf-8');
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Best-effort — the vectors are the source of truth; the manifest is an index.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Every tag with its recorded documents/chunk counts (sorted by recency). */
|
|
194
|
+
export function listKnowledgeTags() {
|
|
195
|
+
return Object.values(readKnowledgeManifest().tags).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
196
|
+
}
|
|
197
|
+
/** One tag's manifest entry, or null. */
|
|
198
|
+
export function getKnowledgeTag(tag) {
|
|
199
|
+
const normalized = normalizeKnowledgeTag(tag);
|
|
200
|
+
return readKnowledgeManifest().tags[normalized] ?? null;
|
|
201
|
+
}
|
|
202
|
+
// ─── Document store (the extracted text, kept so a section can be READ) ─────
|
|
203
|
+
//
|
|
204
|
+
// Retrieval returns passages, but "implement this spec" needs the section as the
|
|
205
|
+
// author wrote it — in order, complete, quotable. Re-extracting on every read
|
|
206
|
+
// would be slow and, for a PDF, would mean re-running the whole extractor just
|
|
207
|
+
// to show one heading's worth of text, so the extracted text is stored beside the
|
|
208
|
+
// vectors. It is user data in the same data dir, and it is removed with the tag.
|
|
209
|
+
/** SHA-1 of the extracted text — the change detector, not a security control. */
|
|
210
|
+
function contentHashOf(text) {
|
|
211
|
+
return createHash('sha1').update(text).digest('hex');
|
|
212
|
+
}
|
|
213
|
+
/** Stable id for a document, derived from its path. */
|
|
214
|
+
export function documentIdFor(sourcePath) {
|
|
215
|
+
return createHash('sha1').update(sourcePath).digest('hex').slice(0, 16);
|
|
216
|
+
}
|
|
217
|
+
/** A document's display title: its first heading, or its basename. */
|
|
218
|
+
function documentTitleOf(text, sourcePath) {
|
|
219
|
+
try {
|
|
220
|
+
const first = parseMarkdownOutline(text)[0];
|
|
221
|
+
if (first && first.level > 0)
|
|
222
|
+
return first.title;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Fall through to the basename — a title is never worth failing an ingest.
|
|
226
|
+
}
|
|
227
|
+
return basename(sourcePath);
|
|
228
|
+
}
|
|
229
|
+
function documentStoreDir(tag) {
|
|
230
|
+
return join(memoryDir(), 'knowledge-docs', normalizeKnowledgeTag(tag));
|
|
231
|
+
}
|
|
232
|
+
function documentStorePath(tag, docId) {
|
|
233
|
+
return join(documentStoreDir(tag), `${docId}.txt`);
|
|
234
|
+
}
|
|
235
|
+
/** Persist a document's extracted text. Best-effort — vectors remain the truth. */
|
|
236
|
+
function writeStoredDocument(tag, docId, text) {
|
|
237
|
+
try {
|
|
238
|
+
const dir = documentStoreDir(tag);
|
|
239
|
+
if (!existsSync(dir))
|
|
240
|
+
mkdirSync(dir, { recursive: true });
|
|
241
|
+
writeFileSync(documentStorePath(tag, docId), text, 'utf-8');
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// A failed write costs a later read, never an ingest.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function readStoredDocument(tag, docId) {
|
|
248
|
+
try {
|
|
249
|
+
const path = documentStorePath(tag, docId);
|
|
250
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function deleteStoredDocument(tag, docId) {
|
|
257
|
+
try {
|
|
258
|
+
const path = documentStorePath(tag, docId);
|
|
259
|
+
if (existsSync(path))
|
|
260
|
+
unlinkSync(path);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Best-effort — an orphaned text file is inert (nothing references it).
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// ─── File collection ────────────────────────────────────────────────────────
|
|
267
|
+
/** Recursively collect files under a path (bounded, ignores build/vendor dirs). */
|
|
268
|
+
export function collectKnowledgeFiles(root, out = [], depth = 0) {
|
|
269
|
+
if (out.length >= MAX_FILES_PER_INGEST || depth > 8)
|
|
270
|
+
return out;
|
|
271
|
+
let stat;
|
|
272
|
+
try {
|
|
273
|
+
stat = statSync(root);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
if (stat.isFile()) {
|
|
279
|
+
out.push(root);
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
if (!stat.isDirectory())
|
|
283
|
+
return out;
|
|
284
|
+
let entries = [];
|
|
285
|
+
try {
|
|
286
|
+
entries = readdirSync(root);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (out.length >= MAX_FILES_PER_INGEST)
|
|
293
|
+
break;
|
|
294
|
+
if (IGNORE_DIRS.has(entry))
|
|
295
|
+
continue;
|
|
296
|
+
collectKnowledgeFiles(join(root, entry), out, depth + 1);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
// ─── Text extraction ────────────────────────────────────────────────────────
|
|
301
|
+
/**
|
|
302
|
+
* Extract a file's text, reusing the `read_extract` manager so PDF/DOCX/XLSX/
|
|
303
|
+
* PPTX and text formats all work (and unsupported ones refuse with a reason
|
|
304
|
+
* instead of silently indexing a container's bytes).
|
|
305
|
+
*/
|
|
306
|
+
async function extractFileText(filePath) {
|
|
307
|
+
const ext = extname(filePath).toLowerCase();
|
|
308
|
+
// Plain text-ish formats need no heavy reader — read directly.
|
|
309
|
+
if (['.txt', '.md', '.markdown', '.csv', '.tsv', '.json', '.yaml', '.yml', '.log'].includes(ext)) {
|
|
310
|
+
try {
|
|
311
|
+
const text = readFileSync(filePath, 'utf-8');
|
|
312
|
+
return { ok: true, text: text.length > MAX_EXTRACT_CHARS ? text.slice(0, MAX_EXTRACT_CHARS) : text };
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const { getReadExtractManager } = await import('../tools/read-extract.js');
|
|
320
|
+
const result = await getReadExtractManager().extract(filePath);
|
|
321
|
+
if (!result.success || !result.text) {
|
|
322
|
+
return { ok: false, reason: result.error ?? result.code ?? 'extraction failed' };
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, text: result.text };
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// ─── Ingest ─────────────────────────────────────────────────────────────────
|
|
331
|
+
/**
|
|
332
|
+
* Ingest raw text under a tag. Idempotent per `<tag>:<sourcePath>#<i>` chunk id,
|
|
333
|
+
* so re-ingesting a changed document overwrites its chunks rather than
|
|
334
|
+
* duplicating them.
|
|
335
|
+
*/
|
|
336
|
+
export async function ingestKnowledgeText(tag, sourcePath, text, opts = {},
|
|
337
|
+
/**
|
|
338
|
+
* How many chunks this same `<tag>:<sourcePath>` contributed LAST time, so a
|
|
339
|
+
* document that got shorter has its retired tail removed. `ingestKnowledge`
|
|
340
|
+
* passes the count it reads from the manifest.
|
|
341
|
+
*/
|
|
342
|
+
previousChunkCount = 0) {
|
|
343
|
+
const normalized = normalizeKnowledgeTag(tag);
|
|
344
|
+
const chunkTokens = opts.chunkTokens ?? DEFAULT_CHUNK_TOKENS;
|
|
345
|
+
const overlapTokens = opts.overlapTokens ?? DEFAULT_OVERLAP_TOKENS;
|
|
346
|
+
const embedFn = opts.embedFn ?? ((t) => embed(t, undefined, false, RETRIEVAL_MODEL));
|
|
347
|
+
const label = `${normalized}:${sourcePath}`;
|
|
348
|
+
// Structure-aware: each chunk is tagged with the heading chain it sits under,
|
|
349
|
+
// and is EMBEDDED with that chain prepended (see markdown-outline.ts) while the
|
|
350
|
+
// stored/quotable text stays exactly as it appears in the document.
|
|
351
|
+
const chunks = chunkMarkdown(text, label, chunkTokens, overlapTokens);
|
|
352
|
+
const store = getVectorStore(namespaceForTag(normalized));
|
|
353
|
+
let stored = 0;
|
|
354
|
+
for (const chunk of chunks) {
|
|
355
|
+
let vector;
|
|
356
|
+
try {
|
|
357
|
+
vector = await embedFn(chunk.embedText);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// SKIP the chunk — never store a placeholder. A zero vector was inserted
|
|
361
|
+
// here before: it scores 0 (cosine guards the zero magnitude) so it could
|
|
362
|
+
// never win a search, but it cost a scan forever and it made a partially
|
|
363
|
+
// indexed document read as fully indexed. A hole the caller is TOLD about
|
|
364
|
+
// beats a dead entry nobody can see.
|
|
365
|
+
opts.onChunkSkipped?.(`chunk ${chunk.chunkIndex + 1} of ${basename(sourcePath)}`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
await store.insert(chunk.id, vector, {
|
|
369
|
+
kind: 'knowledge-chunk',
|
|
370
|
+
tag: normalized,
|
|
371
|
+
sourcePath,
|
|
372
|
+
chunkIndex: chunk.chunkIndex,
|
|
373
|
+
text: chunk.text,
|
|
374
|
+
headingPath: chunk.headingPath,
|
|
375
|
+
title: chunk.title,
|
|
376
|
+
tokenCount: chunk.tokenCount,
|
|
377
|
+
});
|
|
378
|
+
stored += 1;
|
|
379
|
+
}
|
|
380
|
+
// A document that got SHORTER must not leave its old tail behind. Chunk ids
|
|
381
|
+
// are `<tag>:<path>#<i>` and `insert` overwrites only the indices the new
|
|
382
|
+
// version produced, so the retired chunks would otherwise stay in the
|
|
383
|
+
// namespace and keep being retrieved for the life of the tag.
|
|
384
|
+
for (let i = chunks.length; i < previousChunkCount; i += 1) {
|
|
385
|
+
try {
|
|
386
|
+
await store.delete(`${label}#${i}`);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Best-effort: a failed delete leaves one stale chunk, which the relevance
|
|
390
|
+
// floor may still drop — but it must never break the ingest.
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return stored;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Ingest one or more files/directories under a tag. Extraction, chunking and
|
|
397
|
+
* embedding happen ONCE here; later questions only embed a short query.
|
|
398
|
+
*/
|
|
399
|
+
export async function ingestKnowledge(tag, paths, opts = {}) {
|
|
400
|
+
const normalized = normalizeKnowledgeTag(tag);
|
|
401
|
+
if (!isValidKnowledgeTag(normalized)) {
|
|
402
|
+
throw new Error(`Invalid knowledge tag '${tag}' — it must contain at least one letter or digit.`);
|
|
403
|
+
}
|
|
404
|
+
const result = { tag: normalized, files: 0, chunks: 0, unchanged: 0, skipped: [] };
|
|
405
|
+
const manifest = readKnowledgeManifest();
|
|
406
|
+
const entry = manifest.tags[normalized] ?? {
|
|
407
|
+
tag: normalized,
|
|
408
|
+
updatedAt: Date.now(),
|
|
409
|
+
chunkCount: 0,
|
|
410
|
+
documents: [],
|
|
411
|
+
};
|
|
412
|
+
// Flatten dirs → files, bounded.
|
|
413
|
+
const files = [];
|
|
414
|
+
for (const p of paths) {
|
|
415
|
+
if (existsSync(p)) {
|
|
416
|
+
collectKnowledgeFiles(p, files);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
result.skipped.push({ path: p, reason: 'not found' });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const file of files) {
|
|
423
|
+
const extracted = await extractFileText(file);
|
|
424
|
+
if (!extracted.ok) {
|
|
425
|
+
result.skipped.push({ path: file, reason: extracted.reason });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
// The document's previous contribution, read BEFORE the write — it drives
|
|
430
|
+
// the retired-tail delete, the recorded count and the unchanged check.
|
|
431
|
+
const previous = entry.documents.find((d) => d.path === file);
|
|
432
|
+
const docId = documentIdFor(file);
|
|
433
|
+
const contentHash = contentHashOf(extracted.text);
|
|
434
|
+
// UNCHANGED: same text as last time. Re-embedding it would cost the whole
|
|
435
|
+
// document again to arrive at byte-identical vectors, so the stored text is
|
|
436
|
+
// refreshed (cheap, and it is what a section read serves) and the vectors
|
|
437
|
+
// are left alone.
|
|
438
|
+
if (previous && previous.contentHash === contentHash) {
|
|
439
|
+
writeStoredDocument(normalized, docId, extracted.text);
|
|
440
|
+
result.unchanged += 1;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
let embedFailures = 0;
|
|
444
|
+
const chunks = await ingestKnowledgeText(normalized, file, extracted.text, { ...opts, onChunkSkipped: () => { embedFailures += 1; } }, previous?.chunks ?? 0);
|
|
445
|
+
if (chunks === 0) {
|
|
446
|
+
// Every chunk failed to embed. Recording the document would claim a file
|
|
447
|
+
// that contributes nothing and reads as indexed; say what happened.
|
|
448
|
+
result.skipped.push({ path: file, reason: 'no chunk could be embedded — nothing was indexed' });
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
result.files += 1;
|
|
452
|
+
result.chunks += chunks;
|
|
453
|
+
if (embedFailures > 0) {
|
|
454
|
+
result.skipped.push({
|
|
455
|
+
path: file,
|
|
456
|
+
reason: `${embedFailures} chunk(s) could not be embedded and were not indexed`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
// The extracted text is kept so a section can be read verbatim later;
|
|
460
|
+
// recorded BEFORE the manifest write so a crash cannot leave a document
|
|
461
|
+
// recorded with no readable text behind it.
|
|
462
|
+
writeStoredDocument(normalized, docId, extracted.text);
|
|
463
|
+
// Re-ingesting a changed document replaces its chunks; keep the recorded
|
|
464
|
+
// count in step by subtracting the previous contribution first.
|
|
465
|
+
if (previous) {
|
|
466
|
+
entry.chunkCount -= previous.chunks;
|
|
467
|
+
if (previous.docId !== docId)
|
|
468
|
+
deleteStoredDocument(normalized, previous.docId);
|
|
469
|
+
}
|
|
470
|
+
entry.documents = entry.documents.filter((d) => d.path !== file);
|
|
471
|
+
entry.documents.push({
|
|
472
|
+
docId,
|
|
473
|
+
path: file,
|
|
474
|
+
title: documentTitleOf(extracted.text, file),
|
|
475
|
+
chunks,
|
|
476
|
+
addedAt: Date.now(),
|
|
477
|
+
contentHash,
|
|
478
|
+
});
|
|
479
|
+
entry.chunkCount += chunks;
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
result.skipped.push({ path: file, reason: err instanceof Error ? err.message : String(err) });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
entry.updatedAt = Date.now();
|
|
486
|
+
// A tag that ingested nothing AND had nothing before is not a tag: recording
|
|
487
|
+
// it would put an empty entry in `list` (and in the model's view of what the
|
|
488
|
+
// user has), which reads as "this document set exists" when nothing was
|
|
489
|
+
// indexed at all.
|
|
490
|
+
if (entry.documents.length > 0 || manifest.tags[normalized]) {
|
|
491
|
+
manifest.tags[normalized] = entry;
|
|
492
|
+
writeKnowledgeManifest(manifest);
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
/** The source document a stored chunk belongs to (its path, else its id). */
|
|
497
|
+
function sourcePathOf(entry) {
|
|
498
|
+
return String(entry.metadata?.sourcePath ?? entry.id);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Embed the question and return the tag's top-k chunks. Never throws.
|
|
502
|
+
*
|
|
503
|
+
* Two retrievers run over the same namespace and their RANKS are fused:
|
|
504
|
+
*
|
|
505
|
+
* 1. **Dense** — cosine over the stored vectors, gated by `minSimilarity`.
|
|
506
|
+
* This is the Phase-0 relevance floor and it is unchanged: a passage the
|
|
507
|
+
* embeddings do not consider related is not dense evidence.
|
|
508
|
+
* 2. **Lexical** — BM25 over the stored chunk text, which can find the exact
|
|
509
|
+
* term a dense retriever missed. A lexical hit is evidence by itself, so it
|
|
510
|
+
* is admitted even when its cosine sits below the floor — that is the whole
|
|
511
|
+
* reason the second retriever exists, and it is why `minSimilarity` alone
|
|
512
|
+
* can no longer force an empty result for a question that shares vocabulary
|
|
513
|
+
* with the corpus. Pass `fusion: 'dense'` to get the old, purely-cosine
|
|
514
|
+
* behaviour.
|
|
515
|
+
*
|
|
516
|
+
* Reciprocal Rank Fusion scores each candidate `Σ 1/(RRF_K + rank)`. Scores are
|
|
517
|
+
* never summed across halves — cosine and BM25 are on different scales, and
|
|
518
|
+
* adding them would silently weight whichever happens to have the larger range.
|
|
519
|
+
* RRF only needs each half to ORDER its candidates, so a backend change cannot
|
|
520
|
+
* move the fusion.
|
|
521
|
+
*
|
|
522
|
+
* The fused ranking is then capped per source document, so one long file cannot
|
|
523
|
+
* take every slot from the shorter one that answers the question.
|
|
524
|
+
*/
|
|
525
|
+
export async function queryKnowledge(tag, question, opts = {}) {
|
|
526
|
+
const normalized = normalizeKnowledgeTag(tag);
|
|
527
|
+
const topK = opts.topK ?? DEFAULT_KNOWLEDGE_TOP_K;
|
|
528
|
+
const minSimilarity = opts.minSimilarity ?? DEFAULT_KNOWLEDGE_MIN_SIMILARITY;
|
|
529
|
+
const fusion = opts.fusion ?? 'hybrid';
|
|
530
|
+
const minLexicalScore = opts.minLexicalScore ?? 0;
|
|
531
|
+
const maxPerSource = opts.maxPerSource ?? DEFAULT_KNOWLEDGE_MAX_PER_SOURCE;
|
|
532
|
+
const embedFn = opts.embedFn ?? ((t) => embed(t, undefined, false, RETRIEVAL_MODEL));
|
|
533
|
+
try {
|
|
534
|
+
const queryVector = await embedFn(question);
|
|
535
|
+
const store = getVectorStore(namespaceForTag(normalized));
|
|
536
|
+
const scoped = (entry) => entry.metadata?.kind === 'knowledge-chunk' && entry.metadata?.tag === normalized;
|
|
537
|
+
const candidateK = Math.max(topK * CANDIDATE_POOL_MULTIPLIER, MIN_CANDIDATE_POOL);
|
|
538
|
+
const denseResults = await store.search(queryVector, candidateK, scoped);
|
|
539
|
+
// Dense evidence, floored. A non-finite score is treated as a miss: it must
|
|
540
|
+
// never compare as "relevant".
|
|
541
|
+
const denseKept = denseResults.filter(({ similarity }) => Number.isFinite(similarity) && similarity >= minSimilarity);
|
|
542
|
+
if (denseKept.length < denseResults.length) {
|
|
543
|
+
logger.debug(`Knowledge query for '${normalized}' dropped ${denseResults.length - denseKept.length} hit(s) below the ${minSimilarity} relevance floor.`);
|
|
544
|
+
}
|
|
545
|
+
const byId = new Map();
|
|
546
|
+
for (const { entry } of denseResults)
|
|
547
|
+
byId.set(entry.id, entry);
|
|
548
|
+
const candidates = new Map();
|
|
549
|
+
const fuse = (entry, similarity, fromDense, rank) => {
|
|
550
|
+
const contribution = 1 / (RRF_K + rank + 1);
|
|
551
|
+
const existing = candidates.get(entry.id);
|
|
552
|
+
if (existing) {
|
|
553
|
+
existing.rrf += contribution;
|
|
554
|
+
existing.fromDense = existing.fromDense || fromDense;
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
candidates.set(entry.id, { entry, similarity, fromDense, rrf: contribution });
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
// Dense first, so a passage found by BOTH keeps the backend's similarity
|
|
561
|
+
// rather than the locally recomputed one.
|
|
562
|
+
denseKept.forEach(({ entry, similarity }, rank) => fuse(entry, similarity, true, rank));
|
|
563
|
+
if (fusion === 'hybrid') {
|
|
564
|
+
// Lexical over EVERY scoped chunk, not just the dense pool: a passage the
|
|
565
|
+
// dense half never ranked is exactly the one BM25 is here to find.
|
|
566
|
+
// Rebuilt per query on purpose — see lexical-search.ts, no index to drift.
|
|
567
|
+
const documents = [];
|
|
568
|
+
for (const entry of await store.getAll()) {
|
|
569
|
+
if (!scoped(entry))
|
|
570
|
+
continue;
|
|
571
|
+
byId.set(entry.id, entry);
|
|
572
|
+
documents.push({ id: entry.id, text: String(entry.metadata?.text ?? '') });
|
|
573
|
+
}
|
|
574
|
+
const lexicalHits = bm25Search(documents, question, candidateK).filter((h) => h.score >= minLexicalScore);
|
|
575
|
+
lexicalHits.forEach((hit, rank) => {
|
|
576
|
+
const entry = byId.get(hit.id);
|
|
577
|
+
if (!entry)
|
|
578
|
+
return;
|
|
579
|
+
// The similarity is kept for the citation even when it is below the
|
|
580
|
+
// floor — reporting it is what lets a reader see that this passage came
|
|
581
|
+
// from a term match rather than a near neighbour.
|
|
582
|
+
fuse(entry, cosineSimilarity(queryVector, entry.vector), false, rank);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
if (candidates.size === 0)
|
|
586
|
+
return [];
|
|
587
|
+
// Deterministic order: RRF desc, then id — so equal scores never depend on
|
|
588
|
+
// Map insertion order, which is what keeps the eval reproducible.
|
|
589
|
+
const ranked = [...candidates.values()].sort((a, b) => b.rrf - a.rrf || a.entry.id.localeCompare(b.entry.id));
|
|
590
|
+
// Diversity cap. Only meaningful when there IS more than one source to
|
|
591
|
+
// promote, so a single-document tag (the common case) is never truncated.
|
|
592
|
+
const sources = new Set(ranked.map((c) => sourcePathOf(c.entry)));
|
|
593
|
+
const cap = sources.size > 1 ? maxPerSource : topK;
|
|
594
|
+
const perSource = new Map();
|
|
595
|
+
const selected = [];
|
|
596
|
+
for (const candidate of ranked) {
|
|
597
|
+
const source = sourcePathOf(candidate.entry);
|
|
598
|
+
const used = perSource.get(source) ?? 0;
|
|
599
|
+
if (used >= cap)
|
|
600
|
+
continue;
|
|
601
|
+
perSource.set(source, used + 1);
|
|
602
|
+
selected.push(candidate);
|
|
603
|
+
if (selected.length >= topK)
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
return selected.map(({ entry, similarity, fromDense }) => ({
|
|
607
|
+
tag: normalized,
|
|
608
|
+
text: String(entry.metadata?.text ?? ''),
|
|
609
|
+
sourcePath: sourcePathOf(entry),
|
|
610
|
+
chunkIndex: Number(entry.metadata?.chunkIndex ?? 0),
|
|
611
|
+
similarity,
|
|
612
|
+
headingPath: entry.metadata?.headingPath ? String(entry.metadata.headingPath) : undefined,
|
|
613
|
+
evidence: fromDense ? 'dense' : 'term',
|
|
614
|
+
}));
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
logger.debug(`Knowledge query failed for tag '${normalized}': ${err instanceof Error ? err.message : err}`);
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// ─── Forget ─────────────────────────────────────────────────────────────────
|
|
622
|
+
// ─── Reading a document, whole or by section ────────────────────────────────
|
|
623
|
+
//
|
|
624
|
+
// Retrieval answers "where is it mentioned"; this answers "what does it say",
|
|
625
|
+
// in the document's own order and wording. The two are different jobs: a
|
|
626
|
+
// spec-driven build needs the section verbatim, not the six most similar
|
|
627
|
+
// paragraphs of it, and no amount of chunk ranking substitutes for that.
|
|
628
|
+
/** Cap on a read, so "the whole document" cannot become an unbounded prompt. */
|
|
629
|
+
export const MAX_READ_CHARS = 40_000;
|
|
630
|
+
/**
|
|
631
|
+
* Find the document a caller means. Accepts a docId, an exact path, or a
|
|
632
|
+
* basename; a basename that matches more than one document is reported as
|
|
633
|
+
* ambiguous rather than resolved by guessing, because guessing picks the wrong
|
|
634
|
+
* document silently, which is the failure this whole module is against.
|
|
635
|
+
*/
|
|
636
|
+
function resolveDocument(tag, docRef) {
|
|
637
|
+
const entry = getKnowledgeTag(tag);
|
|
638
|
+
if (!entry)
|
|
639
|
+
return { ok: false, reason: `No knowledge tag '${normalizeKnowledgeTag(tag)}' found.` };
|
|
640
|
+
const ref = docRef.trim();
|
|
641
|
+
const exact = entry.documents.find((d) => d.docId === ref || d.path === ref);
|
|
642
|
+
if (exact)
|
|
643
|
+
return { ok: true, doc: exact };
|
|
644
|
+
const byName = entry.documents.filter((d) => basename(d.path).toLowerCase() === ref.toLowerCase());
|
|
645
|
+
if (byName.length === 1)
|
|
646
|
+
return { ok: true, doc: byName[0] };
|
|
647
|
+
if (byName.length > 1) {
|
|
648
|
+
return {
|
|
649
|
+
ok: false,
|
|
650
|
+
reason: `'${ref}' matches ${byName.length} documents under '${entry.tag}' — use the full path or the doc id: ${byName
|
|
651
|
+
.map((d) => d.docId)
|
|
652
|
+
.join(', ')}`,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const available = entry.documents.map((d) => basename(d.path)).join(', ');
|
|
656
|
+
return { ok: false, reason: `No document '${ref}' under '${entry.tag}'. Available: ${available || '(none)'}` };
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Read a document's text, or one of its sections.
|
|
660
|
+
*
|
|
661
|
+
* `section` is matched against the section's heading path, case-insensitively,
|
|
662
|
+
* with or without the ancestors (`"Token refresh"` finds
|
|
663
|
+
* `Auth Design > Token refresh`, and so does the full path). An unknown section
|
|
664
|
+
* returns the available ones instead of the whole document, because silently
|
|
665
|
+
* answering a narrower question with everything is how a reader concludes the
|
|
666
|
+
* document says something it never said.
|
|
667
|
+
*/
|
|
668
|
+
export function readKnowledgeDocument(tag, docRef, section) {
|
|
669
|
+
const resolved = resolveDocument(tag, docRef);
|
|
670
|
+
if (!resolved.ok)
|
|
671
|
+
return resolved;
|
|
672
|
+
const doc = resolved.doc;
|
|
673
|
+
const full = readStoredDocument(normalizeKnowledgeTag(tag), doc.docId);
|
|
674
|
+
if (full === null) {
|
|
675
|
+
return {
|
|
676
|
+
ok: false,
|
|
677
|
+
reason: `The text of '${basename(doc.path)}' is not stored (it was ingested by an older version). Re-ingest it: action: add.`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
let text = full;
|
|
681
|
+
let headingPath;
|
|
682
|
+
if (section && section.trim()) {
|
|
683
|
+
const want = section.trim().toLowerCase();
|
|
684
|
+
const sections = parseMarkdownOutline(full);
|
|
685
|
+
const match = sections.find((s) => {
|
|
686
|
+
const own = s.title.toLowerCase();
|
|
687
|
+
const chain = s.headingPath ? `${s.headingPath} > ${s.title}`.toLowerCase() : own;
|
|
688
|
+
return own === want || chain === want || chain.endsWith(` > ${want}`);
|
|
689
|
+
});
|
|
690
|
+
if (!match) {
|
|
691
|
+
const list = sections
|
|
692
|
+
.filter((s) => s.level > 0)
|
|
693
|
+
.slice(0, 40)
|
|
694
|
+
.map((s) => (s.headingPath ? `${s.headingPath} > ${s.title}` : s.title))
|
|
695
|
+
.join('\n ');
|
|
696
|
+
return { ok: false, reason: `No section '${section}' in '${basename(doc.path)}'. Sections:\n ${list}` };
|
|
697
|
+
}
|
|
698
|
+
text = match.text;
|
|
699
|
+
headingPath = match.headingPath ? `${match.headingPath} > ${match.title}` : match.title;
|
|
700
|
+
}
|
|
701
|
+
const truncated = text.length > MAX_READ_CHARS;
|
|
702
|
+
return {
|
|
703
|
+
ok: true,
|
|
704
|
+
text: truncated ? text.slice(0, MAX_READ_CHARS) : text,
|
|
705
|
+
truncated,
|
|
706
|
+
path: doc.path,
|
|
707
|
+
headingPath,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
/** Every document under a tag, with its heading tree. */
|
|
711
|
+
export function listKnowledgeSections(tag, docRef) {
|
|
712
|
+
const entry = getKnowledgeTag(tag);
|
|
713
|
+
if (!entry)
|
|
714
|
+
return [];
|
|
715
|
+
const docs = docRef ? (() => {
|
|
716
|
+
const resolved = resolveDocument(tag, docRef);
|
|
717
|
+
return resolved.ok ? [resolved.doc] : [];
|
|
718
|
+
})() : entry.documents;
|
|
719
|
+
return docs.map((doc) => {
|
|
720
|
+
const text = readStoredDocument(entry.tag, doc.docId);
|
|
721
|
+
const sections = text
|
|
722
|
+
? parseMarkdownOutline(text)
|
|
723
|
+
.filter((s) => s.level > 0)
|
|
724
|
+
.map((s) => ({
|
|
725
|
+
title: s.title,
|
|
726
|
+
headingPath: s.headingPath,
|
|
727
|
+
level: s.level,
|
|
728
|
+
chars: s.text.length,
|
|
729
|
+
}))
|
|
730
|
+
: [];
|
|
731
|
+
return { docId: doc.docId, path: doc.path, title: doc.title, chunks: doc.chunks, addedAt: doc.addedAt, sections };
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Remove one document from a tag: its chunks, its stored text, its manifest row.
|
|
736
|
+
* Used by `syncKnowledgeTag` when a file is no longer present under a synced
|
|
737
|
+
* root, and by the CLI to drop a single document without forgetting the tag.
|
|
738
|
+
*/
|
|
739
|
+
export async function removeKnowledgeDocument(tag, docRef) {
|
|
740
|
+
const normalized = normalizeKnowledgeTag(tag);
|
|
741
|
+
const resolved = resolveDocument(normalized, docRef);
|
|
742
|
+
if (!resolved.ok)
|
|
743
|
+
return false;
|
|
744
|
+
const doc = resolved.doc;
|
|
745
|
+
const manifest = readKnowledgeManifest();
|
|
746
|
+
const entry = manifest.tags[normalized];
|
|
747
|
+
if (entry) {
|
|
748
|
+
entry.documents = entry.documents.filter((d) => d.path !== doc.path);
|
|
749
|
+
entry.chunkCount = Math.max(0, entry.chunkCount - doc.chunks);
|
|
750
|
+
entry.updatedAt = Date.now();
|
|
751
|
+
if (entry.documents.length === 0)
|
|
752
|
+
delete manifest.tags[normalized];
|
|
753
|
+
writeKnowledgeManifest(manifest);
|
|
754
|
+
}
|
|
755
|
+
const store = getVectorStore(namespaceForTag(normalized));
|
|
756
|
+
const label = `${normalized}:${doc.path}`;
|
|
757
|
+
for (let i = 0; i < doc.chunks; i += 1) {
|
|
758
|
+
try {
|
|
759
|
+
await store.delete(`${label}#${i}`);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
// Best-effort — a stale chunk is floored at query time rather than fatal.
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
deleteStoredDocument(normalized, doc.docId);
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
/** Remove a tag's vectors and manifest entry. Returns true when something was removed. */
|
|
769
|
+
export async function forgetKnowledgeTag(tag) {
|
|
770
|
+
const normalized = normalizeKnowledgeTag(tag);
|
|
771
|
+
const manifest = readKnowledgeManifest();
|
|
772
|
+
const existed = Boolean(manifest.tags[normalized]);
|
|
773
|
+
delete manifest.tags[normalized];
|
|
774
|
+
writeKnowledgeManifest(manifest);
|
|
775
|
+
try {
|
|
776
|
+
await getVectorStore(namespaceForTag(normalized)).clear();
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
// Best-effort — the manifest entry is already gone.
|
|
780
|
+
}
|
|
781
|
+
// The stored extracted text is user data too, and `forget` has to mean forget:
|
|
782
|
+
// leaving it behind would keep the documents on disk with nothing pointing at
|
|
783
|
+
// them, which is worse than either keeping or removing them.
|
|
784
|
+
try {
|
|
785
|
+
rmSync(documentStoreDir(normalized), { recursive: true, force: true });
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
// Best-effort.
|
|
789
|
+
}
|
|
790
|
+
return existed;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Bring a tag up to date with the folders it is kept in step with.
|
|
794
|
+
*
|
|
795
|
+
* This is `add` plus the two things a folder being kept in step needs: content
|
|
796
|
+
* hashes mean an unchanged file is not re-extracted, re-chunked or re-embedded,
|
|
797
|
+
* and a file that has DISAPPEARED stops being served.
|
|
798
|
+
*
|
|
799
|
+
* Removal is scoped to the roots the caller actually synced. Syncing `./specs`
|
|
800
|
+
* must not delete documents ingested from somewhere else, so a document is only
|
|
801
|
+
* pruned when it lives under one of the synced directories AND was absent from
|
|
802
|
+
* that directory's enumeration — a precise rule, rather than "anything not in
|
|
803
|
+
* this list", which would quietly empty a tag the first time it was called with
|
|
804
|
+
* the wrong path.
|
|
805
|
+
*/
|
|
806
|
+
export async function syncKnowledgeTag(tag, paths, opts = {}) {
|
|
807
|
+
const normalized = normalizeKnowledgeTag(tag);
|
|
808
|
+
const result = {
|
|
809
|
+
tag: normalized,
|
|
810
|
+
added: 0,
|
|
811
|
+
changed: 0,
|
|
812
|
+
unchanged: 0,
|
|
813
|
+
removed: [],
|
|
814
|
+
chunks: 0,
|
|
815
|
+
skipped: [],
|
|
816
|
+
};
|
|
817
|
+
const files = [];
|
|
818
|
+
const directoryRoots = [];
|
|
819
|
+
for (const path of paths) {
|
|
820
|
+
let stat;
|
|
821
|
+
try {
|
|
822
|
+
stat = statSync(path);
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
result.skipped.push({ path, reason: 'not found' });
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (stat.isDirectory()) {
|
|
829
|
+
directoryRoots.push(path);
|
|
830
|
+
collectKnowledgeFiles(path, files);
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
files.push(path);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
// Snapshot BEFORE the write: the difference between the two manifests is what
|
|
837
|
+
// "added/changed/unchanged" means, and it must not be inferred from a counter.
|
|
838
|
+
const before = new Map((getKnowledgeTag(normalized)?.documents ?? []).map((d) => [d.path, d]));
|
|
839
|
+
const enumerated = new Set(files);
|
|
840
|
+
const ingest = await ingestKnowledge(normalized, files, opts);
|
|
841
|
+
result.skipped = ingest.skipped;
|
|
842
|
+
result.chunks = ingest.chunks;
|
|
843
|
+
// Classify the files the CALLER synced — the enumerated set — by diffing the
|
|
844
|
+
// two manifests. A document that is about to be pruned must not be counted as
|
|
845
|
+
// "unchanged": it is removed, and counting it here would report a sync as
|
|
846
|
+
// having kept something it just deleted.
|
|
847
|
+
const after = new Map((getKnowledgeTag(normalized)?.documents ?? []).map((d) => [d.path, d]));
|
|
848
|
+
for (const path of enumerated) {
|
|
849
|
+
const current = after.get(path);
|
|
850
|
+
if (!current)
|
|
851
|
+
continue; // could not be ingested — it is already in `skipped`
|
|
852
|
+
const previous = before.get(path);
|
|
853
|
+
if (!previous)
|
|
854
|
+
result.added += 1;
|
|
855
|
+
else if (previous.contentHash !== current.contentHash)
|
|
856
|
+
result.changed += 1;
|
|
857
|
+
else
|
|
858
|
+
result.unchanged += 1;
|
|
859
|
+
}
|
|
860
|
+
for (const path of before.keys()) {
|
|
861
|
+
if (enumerated.has(path))
|
|
862
|
+
continue;
|
|
863
|
+
const underSyncedRoot = directoryRoots.some((root) => path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`));
|
|
864
|
+
if (underSyncedRoot && (await removeKnowledgeDocument(normalized, path))) {
|
|
865
|
+
result.removed.push(path);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
// ─── Formatting for the model ───────────────────────────────────────────────
|
|
871
|
+
/**
|
|
872
|
+
* Render retrieved chunks as context that ATTRIBUTES each part to the user's
|
|
873
|
+
* data. The model can then answer the data half from here and the generic half
|
|
874
|
+
* from its own knowledge / `web-research`, and say which is which.
|
|
875
|
+
*/
|
|
876
|
+
export function formatKnowledgeContext(tag, hits) {
|
|
877
|
+
const normalized = normalizeKnowledgeTag(tag);
|
|
878
|
+
if (hits.length === 0) {
|
|
879
|
+
return `No entries found for knowledge tag '${normalized}'. Ingest documents first with the knowledge tool (action: add).`;
|
|
880
|
+
}
|
|
881
|
+
const parts = hits.map((h) => {
|
|
882
|
+
const name = basename(h.sourcePath);
|
|
883
|
+
// The section is part of the citation: "clause 4" is not attributable
|
|
884
|
+
// without it, and a reader checking the quote needs to know where to look.
|
|
885
|
+
const section = h.headingPath ? ` · §${h.headingPath}` : '';
|
|
886
|
+
// A term-only passage is marked as such: its `sim` is below the floor by
|
|
887
|
+
// construction, and a bare low number next to USER DATA reads as a retrieval
|
|
888
|
+
// mistake rather than as "this is the passage that contains your term".
|
|
889
|
+
const evidence = h.evidence === 'term' ? ' · term match' : '';
|
|
890
|
+
return `--- [from your data: ${name} · chunk ${h.chunkIndex + 1}${section} · sim ${h.similarity.toFixed(3)}${evidence}] ---\n${h.text}`;
|
|
891
|
+
});
|
|
892
|
+
return [
|
|
893
|
+
`Knowledge tag: ${normalized} (${hits.length} relevant chunk${hits.length === 1 ? '' : 's'} from the user's own data)`,
|
|
894
|
+
'Treat the following as USER DATA — quote it for facts about the user. For general advice, combine it with your own knowledge and, when current guidance is needed, the web-research tool, and say which part is which.',
|
|
895
|
+
// Relevance is measured (see DEFAULT_KNOWLEDGE_MIN_SIMILARITY), but a floor
|
|
896
|
+
// is a heuristic — so the fallback is stated as a policy, not left to chance:
|
|
897
|
+
// passages that do not answer the question must be reported as insufficient
|
|
898
|
+
// rather than stretched to fit.
|
|
899
|
+
'If these passages do not answer the question, say so plainly and answer from your own knowledge — do not stretch them to fit, and never assert a fact about the user\'s data that is not in them.',
|
|
900
|
+
'',
|
|
901
|
+
...parts,
|
|
902
|
+
].join('\n');
|
|
903
|
+
}
|
|
904
|
+
// Re-export for tests/callers that want the raw embedding defaults.
|
|
905
|
+
export { DEFAULT_CHUNK_TOKENS, DEFAULT_OVERLAP_TOKENS };
|
|
906
|
+
//# sourceMappingURL=knowledge-base.js.map
|