agent-nuvira 3.3.13 → 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/orchestrator.d.ts +31 -0
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +74 -126
- 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 +765 -33
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/cli-program.d.ts.map +1 -1
- package/dist/cli/cli-program.js +12 -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 +86 -0
- 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/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 +9 -1
- package/dist/cli/knowledge.d.ts.map +1 -1
- package/dist/cli/knowledge.js +93 -2
- package/dist/cli/knowledge.js.map +1 -1
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +47 -12
- 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/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/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 +33 -0
- package/dist/gateway/hook-contract.d.ts.map +1 -1
- package/dist/gateway/hook-contract.js +97 -0
- package/dist/gateway/hook-contract.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 +37 -0
- package/dist/inference/interface.d.ts.map +1 -1
- 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/route-resolver.d.ts +30 -0
- package/dist/inference/route-resolver.d.ts.map +1 -1
- package/dist/inference/route-resolver.js +158 -1
- package/dist/inference/route-resolver.js.map +1 -1
- package/dist/inference/tools.d.ts +16 -1
- package/dist/inference/tools.d.ts.map +1 -1
- package/dist/inference/tools.js +51 -18
- 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 +68 -24
- package/dist/learning/auto-router.d.ts.map +1 -1
- package/dist/learning/auto-router.js +301 -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 +22 -1
- package/dist/learning/error-repair.d.ts.map +1 -1
- package/dist/learning/error-repair.js +37 -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 +229 -3
- package/dist/learning/knowledge-base.d.ts.map +1 -1
- package/dist/learning/knowledge-base.js +593 -24
- package/dist/learning/knowledge-base.js.map +1 -1
- 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.map +1 -1
- package/dist/learning/model-selection.js +41 -0
- 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/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/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/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/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 +20 -2
- package/dist/tools/plan-store.d.ts.map +1 -1
- package/dist/tools/plan-store.js +51 -7
- 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 +486 -26
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/remediation-ladder.d.ts.map +1 -1
- package/dist/tools/remediation-ladder.js +8 -0
- package/dist/tools/remediation-ladder.js.map +1 -1
- 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 +265 -10
- package/dist/tools/run-terminal.js.map +1 -1
- package/dist/tools/tool-loop.d.ts +321 -13
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +867 -66
- package/dist/tools/tool-loop.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 +11 -0
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +362 -11
- package/dist/web-dashboard/server.js.map +1 -1
- package/dist/web-dashboard/src/types.d.ts +170 -1
- package/dist/web-dashboard/src/types.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.d.ts +11 -2
- package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.js +24 -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-zjbdJnO-.js +210 -0
- package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
- package/src/web-dashboard/public/index.html +1 -1
- package/src/web-dashboard/public/assets/index-DhiuR4S2.js +0 -210
- package/src/web-dashboard/public/assets/index-DhiuR4S2.js.map +0 -1
|
@@ -15,6 +15,22 @@
|
|
|
15
15
|
* vector namespace (`knowledge-<tag>`), so tags never bleed into each other and
|
|
16
16
|
* a tag can be forgotten without touching anything else.
|
|
17
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
|
+
*
|
|
18
34
|
* The retrieved chunks are USER DATA; the model supplies the generic half of an
|
|
19
35
|
* answer (and `web-research` supplies the net-based half). `formatKnowledgeContext`
|
|
20
36
|
* labels each chunk with its source file so the model can attribute precisely
|
|
@@ -25,12 +41,15 @@
|
|
|
25
41
|
* listing/removal. Everything lives in the Nuvira data dir (`~/.nuvira/memory`),
|
|
26
42
|
* never in a repository or package.
|
|
27
43
|
*/
|
|
28
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
44
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
29
46
|
import { join, extname, basename } from 'node:path';
|
|
30
47
|
import { envBuff, resolveNuviraHome } from '../config/paths.js';
|
|
31
48
|
import { embed, RETRIEVAL_MODEL } from '../memory/embedder.js';
|
|
32
|
-
import { getVectorStore } from '../memory/vector-store.js';
|
|
33
|
-
import {
|
|
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';
|
|
34
53
|
import { logger } from '../utils/logger.js';
|
|
35
54
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
36
55
|
/** A tag is a sandboxed namespace segment: lowercase alphanumerics + hyphens, with at least one alphanumeric. */
|
|
@@ -41,6 +60,70 @@ export const KNOWLEDGE_NAMESPACE_PREFIX = 'knowledge-';
|
|
|
41
60
|
export const KNOWLEDGE_MANIFEST_FILE = 'knowledge-index.json';
|
|
42
61
|
/** Default chunks returned per query. */
|
|
43
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;
|
|
44
127
|
/** Directories never walked when a tag is pointed at a folder. */
|
|
45
128
|
const IGNORE_DIRS = new Set([
|
|
46
129
|
'node_modules', '.git', 'dist', 'build', '.next', '.cache', '.venv', 'venv',
|
|
@@ -116,6 +199,70 @@ export function getKnowledgeTag(tag) {
|
|
|
116
199
|
const normalized = normalizeKnowledgeTag(tag);
|
|
117
200
|
return readKnowledgeManifest().tags[normalized] ?? null;
|
|
118
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
|
+
}
|
|
119
266
|
// ─── File collection ────────────────────────────────────────────────────────
|
|
120
267
|
/** Recursively collect files under a path (bounded, ignores build/vendor dirs). */
|
|
121
268
|
export function collectKnowledgeFiles(root, out = [], depth = 0) {
|
|
@@ -186,22 +333,37 @@ async function extractFileText(filePath) {
|
|
|
186
333
|
* so re-ingesting a changed document overwrites its chunks rather than
|
|
187
334
|
* duplicating them.
|
|
188
335
|
*/
|
|
189
|
-
export async function ingestKnowledgeText(tag, sourcePath, text, opts = {}
|
|
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) {
|
|
190
343
|
const normalized = normalizeKnowledgeTag(tag);
|
|
191
344
|
const chunkTokens = opts.chunkTokens ?? DEFAULT_CHUNK_TOKENS;
|
|
192
345
|
const overlapTokens = opts.overlapTokens ?? DEFAULT_OVERLAP_TOKENS;
|
|
193
346
|
const embedFn = opts.embedFn ?? ((t) => embed(t, undefined, false, RETRIEVAL_MODEL));
|
|
194
347
|
const label = `${normalized}:${sourcePath}`;
|
|
195
|
-
|
|
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);
|
|
196
352
|
const store = getVectorStore(namespaceForTag(normalized));
|
|
353
|
+
let stored = 0;
|
|
197
354
|
for (const chunk of chunks) {
|
|
198
355
|
let vector;
|
|
199
356
|
try {
|
|
200
|
-
vector = await embedFn(chunk.
|
|
357
|
+
vector = await embedFn(chunk.embedText);
|
|
201
358
|
}
|
|
202
359
|
catch {
|
|
203
|
-
//
|
|
204
|
-
|
|
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;
|
|
205
367
|
}
|
|
206
368
|
await store.insert(chunk.id, vector, {
|
|
207
369
|
kind: 'knowledge-chunk',
|
|
@@ -209,10 +371,26 @@ export async function ingestKnowledgeText(tag, sourcePath, text, opts = {}) {
|
|
|
209
371
|
sourcePath,
|
|
210
372
|
chunkIndex: chunk.chunkIndex,
|
|
211
373
|
text: chunk.text,
|
|
374
|
+
headingPath: chunk.headingPath,
|
|
375
|
+
title: chunk.title,
|
|
212
376
|
tokenCount: chunk.tokenCount,
|
|
213
377
|
});
|
|
378
|
+
stored += 1;
|
|
214
379
|
}
|
|
215
|
-
|
|
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;
|
|
216
394
|
}
|
|
217
395
|
/**
|
|
218
396
|
* Ingest one or more files/directories under a tag. Extraction, chunking and
|
|
@@ -223,7 +401,7 @@ export async function ingestKnowledge(tag, paths, opts = {}) {
|
|
|
223
401
|
if (!isValidKnowledgeTag(normalized)) {
|
|
224
402
|
throw new Error(`Invalid knowledge tag '${tag}' — it must contain at least one letter or digit.`);
|
|
225
403
|
}
|
|
226
|
-
const result = { tag: normalized, files: 0, chunks: 0, skipped: [] };
|
|
404
|
+
const result = { tag: normalized, files: 0, chunks: 0, unchanged: 0, skipped: [] };
|
|
227
405
|
const manifest = readKnowledgeManifest();
|
|
228
406
|
const entry = manifest.tags[normalized] ?? {
|
|
229
407
|
tag: normalized,
|
|
@@ -248,16 +426,56 @@ export async function ingestKnowledge(tag, paths, opts = {}) {
|
|
|
248
426
|
continue;
|
|
249
427
|
}
|
|
250
428
|
try {
|
|
251
|
-
|
|
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
|
+
}
|
|
252
451
|
result.files += 1;
|
|
253
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);
|
|
254
463
|
// Re-ingesting a changed document replaces its chunks; keep the recorded
|
|
255
464
|
// count in step by subtracting the previous contribution first.
|
|
256
|
-
|
|
257
|
-
if (previous)
|
|
465
|
+
if (previous) {
|
|
258
466
|
entry.chunkCount -= previous.chunks;
|
|
467
|
+
if (previous.docId !== docId)
|
|
468
|
+
deleteStoredDocument(normalized, previous.docId);
|
|
469
|
+
}
|
|
259
470
|
entry.documents = entry.documents.filter((d) => d.path !== file);
|
|
260
|
-
entry.documents.push({
|
|
471
|
+
entry.documents.push({
|
|
472
|
+
docId,
|
|
473
|
+
path: file,
|
|
474
|
+
title: documentTitleOf(extracted.text, file),
|
|
475
|
+
chunks,
|
|
476
|
+
addedAt: Date.now(),
|
|
477
|
+
contentHash,
|
|
478
|
+
});
|
|
261
479
|
entry.chunkCount += chunks;
|
|
262
480
|
}
|
|
263
481
|
catch (err) {
|
|
@@ -265,28 +483,134 @@ export async function ingestKnowledge(tag, paths, opts = {}) {
|
|
|
265
483
|
}
|
|
266
484
|
}
|
|
267
485
|
entry.updatedAt = Date.now();
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
}
|
|
270
494
|
return result;
|
|
271
495
|
}
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
*/
|
|
274
525
|
export async function queryKnowledge(tag, question, opts = {}) {
|
|
275
526
|
const normalized = normalizeKnowledgeTag(tag);
|
|
276
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;
|
|
277
532
|
const embedFn = opts.embedFn ?? ((t) => embed(t, undefined, false, RETRIEVAL_MODEL));
|
|
278
533
|
try {
|
|
279
534
|
const queryVector = await embedFn(question);
|
|
280
535
|
const store = getVectorStore(namespaceForTag(normalized));
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 }) => ({
|
|
285
607
|
tag: normalized,
|
|
286
608
|
text: String(entry.metadata?.text ?? ''),
|
|
287
|
-
sourcePath:
|
|
609
|
+
sourcePath: sourcePathOf(entry),
|
|
288
610
|
chunkIndex: Number(entry.metadata?.chunkIndex ?? 0),
|
|
289
611
|
similarity,
|
|
612
|
+
headingPath: entry.metadata?.headingPath ? String(entry.metadata.headingPath) : undefined,
|
|
613
|
+
evidence: fromDense ? 'dense' : 'term',
|
|
290
614
|
}));
|
|
291
615
|
}
|
|
292
616
|
catch (err) {
|
|
@@ -295,6 +619,152 @@ export async function queryKnowledge(tag, question, opts = {}) {
|
|
|
295
619
|
}
|
|
296
620
|
}
|
|
297
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
|
+
}
|
|
298
768
|
/** Remove a tag's vectors and manifest entry. Returns true when something was removed. */
|
|
299
769
|
export async function forgetKnowledgeTag(tag) {
|
|
300
770
|
const normalized = normalizeKnowledgeTag(tag);
|
|
@@ -308,8 +778,95 @@ export async function forgetKnowledgeTag(tag) {
|
|
|
308
778
|
catch {
|
|
309
779
|
// Best-effort — the manifest entry is already gone.
|
|
310
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
|
+
}
|
|
311
790
|
return existed;
|
|
312
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
|
+
}
|
|
313
870
|
// ─── Formatting for the model ───────────────────────────────────────────────
|
|
314
871
|
/**
|
|
315
872
|
* Render retrieved chunks as context that ATTRIBUTES each part to the user's
|
|
@@ -323,11 +880,23 @@ export function formatKnowledgeContext(tag, hits) {
|
|
|
323
880
|
}
|
|
324
881
|
const parts = hits.map((h) => {
|
|
325
882
|
const name = basename(h.sourcePath);
|
|
326
|
-
|
|
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}`;
|
|
327
891
|
});
|
|
328
892
|
return [
|
|
329
893
|
`Knowledge tag: ${normalized} (${hits.length} relevant chunk${hits.length === 1 ? '' : 's'} from the user's own data)`,
|
|
330
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.',
|
|
331
900
|
'',
|
|
332
901
|
...parts,
|
|
333
902
|
].join('\n');
|