agent-nuvira 3.3.13 → 3.3.15

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.
Files changed (398) hide show
  1. package/README.md +67 -1
  2. package/dist/agents/agents/planner.d.ts.map +1 -1
  3. package/dist/agents/agents/planner.js +9 -0
  4. package/dist/agents/agents/planner.js.map +1 -1
  5. package/dist/agents/orchestrator.d.ts +31 -0
  6. package/dist/agents/orchestrator.d.ts.map +1 -1
  7. package/dist/agents/orchestrator.js +74 -126
  8. package/dist/agents/orchestrator.js.map +1 -1
  9. package/dist/cli/chat.d.ts +89 -0
  10. package/dist/cli/chat.d.ts.map +1 -1
  11. package/dist/cli/chat.js +765 -33
  12. package/dist/cli/chat.js.map +1 -1
  13. package/dist/cli/cli-program.d.ts.map +1 -1
  14. package/dist/cli/cli-program.js +12 -0
  15. package/dist/cli/cli-program.js.map +1 -1
  16. package/dist/cli/config.d.ts +12 -0
  17. package/dist/cli/config.d.ts.map +1 -1
  18. package/dist/cli/config.js +86 -0
  19. package/dist/cli/config.js.map +1 -1
  20. package/dist/cli/decisions.d.ts +28 -0
  21. package/dist/cli/decisions.d.ts.map +1 -0
  22. package/dist/cli/decisions.js +140 -0
  23. package/dist/cli/decisions.js.map +1 -0
  24. package/dist/cli/doctor.d.ts +6 -0
  25. package/dist/cli/doctor.d.ts.map +1 -1
  26. package/dist/cli/doctor.js +46 -0
  27. package/dist/cli/doctor.js.map +1 -1
  28. package/dist/cli/failover-runner.d.ts.map +1 -1
  29. package/dist/cli/failover-runner.js +19 -1
  30. package/dist/cli/failover-runner.js.map +1 -1
  31. package/dist/cli/feedback.d.ts.map +1 -1
  32. package/dist/cli/feedback.js +5 -1
  33. package/dist/cli/feedback.js.map +1 -1
  34. package/dist/cli/hooks.d.ts +30 -0
  35. package/dist/cli/hooks.d.ts.map +1 -0
  36. package/dist/cli/hooks.js +224 -0
  37. package/dist/cli/hooks.js.map +1 -0
  38. package/dist/cli/knowledge.d.ts +9 -1
  39. package/dist/cli/knowledge.d.ts.map +1 -1
  40. package/dist/cli/knowledge.js +93 -2
  41. package/dist/cli/knowledge.js.map +1 -1
  42. package/dist/cli/loop-executor.d.ts.map +1 -1
  43. package/dist/cli/loop-executor.js +47 -12
  44. package/dist/cli/loop-executor.js.map +1 -1
  45. package/dist/cli/model.d.ts +20 -0
  46. package/dist/cli/model.d.ts.map +1 -1
  47. package/dist/cli/model.js +294 -14
  48. package/dist/cli/model.js.map +1 -1
  49. package/dist/cli/rate.d.ts +78 -0
  50. package/dist/cli/rate.d.ts.map +1 -0
  51. package/dist/cli/rate.js +294 -0
  52. package/dist/cli/rate.js.map +1 -0
  53. package/dist/config/process-env.d.ts.map +1 -1
  54. package/dist/config/process-env.js +10 -0
  55. package/dist/config/process-env.js.map +1 -1
  56. package/dist/enterprise/vault.d.ts.map +1 -1
  57. package/dist/enterprise/vault.js +5 -21
  58. package/dist/enterprise/vault.js.map +1 -1
  59. package/dist/gateway/hook-contract.d.ts +33 -0
  60. package/dist/gateway/hook-contract.d.ts.map +1 -1
  61. package/dist/gateway/hook-contract.js +97 -0
  62. package/dist/gateway/hook-contract.js.map +1 -1
  63. package/dist/inference/gemini-adapter.d.ts.map +1 -1
  64. package/dist/inference/gemini-adapter.js +4 -0
  65. package/dist/inference/gemini-adapter.js.map +1 -1
  66. package/dist/inference/interface.d.ts +37 -0
  67. package/dist/inference/interface.d.ts.map +1 -1
  68. package/dist/inference/native-tools.d.ts +16 -1
  69. package/dist/inference/native-tools.d.ts.map +1 -1
  70. package/dist/inference/native-tools.js +34 -10
  71. package/dist/inference/native-tools.js.map +1 -1
  72. package/dist/inference/route-resolver.d.ts +30 -0
  73. package/dist/inference/route-resolver.d.ts.map +1 -1
  74. package/dist/inference/route-resolver.js +158 -1
  75. package/dist/inference/route-resolver.js.map +1 -1
  76. package/dist/inference/tools.d.ts +16 -1
  77. package/dist/inference/tools.d.ts.map +1 -1
  78. package/dist/inference/tools.js +51 -18
  79. package/dist/inference/tools.js.map +1 -1
  80. package/dist/learning/acceptance-model.d.ts +247 -0
  81. package/dist/learning/acceptance-model.d.ts.map +1 -0
  82. package/dist/learning/acceptance-model.js +708 -0
  83. package/dist/learning/acceptance-model.js.map +1 -0
  84. package/dist/learning/auto-router.d.ts +68 -24
  85. package/dist/learning/auto-router.d.ts.map +1 -1
  86. package/dist/learning/auto-router.js +301 -96
  87. package/dist/learning/auto-router.js.map +1 -1
  88. package/dist/learning/autonomy-policy.d.ts +12 -16
  89. package/dist/learning/autonomy-policy.d.ts.map +1 -1
  90. package/dist/learning/autonomy-policy.js +50 -48
  91. package/dist/learning/autonomy-policy.js.map +1 -1
  92. package/dist/learning/behavioural-labels.d.ts +86 -0
  93. package/dist/learning/behavioural-labels.d.ts.map +1 -0
  94. package/dist/learning/behavioural-labels.js +166 -0
  95. package/dist/learning/behavioural-labels.js.map +1 -0
  96. package/dist/learning/capability-evidence.d.ts +167 -0
  97. package/dist/learning/capability-evidence.d.ts.map +1 -0
  98. package/dist/learning/capability-evidence.js +210 -0
  99. package/dist/learning/capability-evidence.js.map +1 -0
  100. package/dist/learning/capability-readiness.d.ts +64 -0
  101. package/dist/learning/capability-readiness.d.ts.map +1 -0
  102. package/dist/learning/capability-readiness.js +74 -0
  103. package/dist/learning/capability-readiness.js.map +1 -0
  104. package/dist/learning/capability-types.d.ts +169 -0
  105. package/dist/learning/capability-types.d.ts.map +1 -0
  106. package/dist/learning/capability-types.js +20 -0
  107. package/dist/learning/capability-types.js.map +1 -0
  108. package/dist/learning/catalog-feed.d.ts +89 -0
  109. package/dist/learning/catalog-feed.d.ts.map +1 -0
  110. package/dist/learning/catalog-feed.js +211 -0
  111. package/dist/learning/catalog-feed.js.map +1 -0
  112. package/dist/learning/cli-intent-effects.d.ts +60 -0
  113. package/dist/learning/cli-intent-effects.d.ts.map +1 -0
  114. package/dist/learning/cli-intent-effects.js +153 -0
  115. package/dist/learning/cli-intent-effects.js.map +1 -0
  116. package/dist/learning/command-adaptation.d.ts +41 -0
  117. package/dist/learning/command-adaptation.d.ts.map +1 -0
  118. package/dist/learning/command-adaptation.js +122 -0
  119. package/dist/learning/command-adaptation.js.map +1 -0
  120. package/dist/learning/consent-picture.d.ts +42 -0
  121. package/dist/learning/consent-picture.d.ts.map +1 -0
  122. package/dist/learning/consent-picture.js +53 -0
  123. package/dist/learning/consent-picture.js.map +1 -0
  124. package/dist/learning/context-budget.d.ts +37 -6
  125. package/dist/learning/context-budget.d.ts.map +1 -1
  126. package/dist/learning/context-budget.js +38 -9
  127. package/dist/learning/context-budget.js.map +1 -1
  128. package/dist/learning/decision-log.d.ts +114 -0
  129. package/dist/learning/decision-log.d.ts.map +1 -0
  130. package/dist/learning/decision-log.js +296 -0
  131. package/dist/learning/decision-log.js.map +1 -0
  132. package/dist/learning/deliverable-class.d.ts.map +1 -1
  133. package/dist/learning/deliverable-class.js +19 -0
  134. package/dist/learning/deliverable-class.js.map +1 -1
  135. package/dist/learning/deliverable-corpus.d.ts +117 -0
  136. package/dist/learning/deliverable-corpus.d.ts.map +1 -0
  137. package/dist/learning/deliverable-corpus.js +176 -0
  138. package/dist/learning/deliverable-corpus.js.map +1 -0
  139. package/dist/learning/error-repair.d.ts +22 -1
  140. package/dist/learning/error-repair.d.ts.map +1 -1
  141. package/dist/learning/error-repair.js +37 -0
  142. package/dist/learning/error-repair.js.map +1 -1
  143. package/dist/learning/exhaustion-report.d.ts +136 -0
  144. package/dist/learning/exhaustion-report.d.ts.map +1 -0
  145. package/dist/learning/exhaustion-report.js +239 -0
  146. package/dist/learning/exhaustion-report.js.map +1 -0
  147. package/dist/learning/failure-bookkeeping.d.ts +16 -0
  148. package/dist/learning/failure-bookkeeping.d.ts.map +1 -1
  149. package/dist/learning/failure-bookkeeping.js +51 -5
  150. package/dist/learning/failure-bookkeeping.js.map +1 -1
  151. package/dist/learning/gate-friction.d.ts +39 -0
  152. package/dist/learning/gate-friction.d.ts.map +1 -0
  153. package/dist/learning/gate-friction.js +91 -0
  154. package/dist/learning/gate-friction.js.map +1 -0
  155. package/dist/learning/harness-narration.d.ts +34 -0
  156. package/dist/learning/harness-narration.d.ts.map +1 -0
  157. package/dist/learning/harness-narration.js +58 -0
  158. package/dist/learning/harness-narration.js.map +1 -0
  159. package/dist/learning/hub-skill-catalog.d.ts.map +1 -1
  160. package/dist/learning/hub-skill-catalog.js +1 -2
  161. package/dist/learning/hub-skill-catalog.js.map +1 -1
  162. package/dist/learning/hybrid-router.d.ts +65 -0
  163. package/dist/learning/hybrid-router.d.ts.map +1 -1
  164. package/dist/learning/hybrid-router.js +186 -0
  165. package/dist/learning/hybrid-router.js.map +1 -1
  166. package/dist/learning/knowledge-base.d.ts +229 -3
  167. package/dist/learning/knowledge-base.d.ts.map +1 -1
  168. package/dist/learning/knowledge-base.js +603 -25
  169. package/dist/learning/knowledge-base.js.map +1 -1
  170. package/dist/learning/knowledge-eval.d.ts +171 -0
  171. package/dist/learning/knowledge-eval.d.ts.map +1 -0
  172. package/dist/learning/knowledge-eval.js +196 -0
  173. package/dist/learning/knowledge-eval.js.map +1 -0
  174. package/dist/learning/knowledge-turn.d.ts +92 -0
  175. package/dist/learning/knowledge-turn.d.ts.map +1 -0
  176. package/dist/learning/knowledge-turn.js +190 -0
  177. package/dist/learning/knowledge-turn.js.map +1 -0
  178. package/dist/learning/learned-commands.d.ts +64 -0
  179. package/dist/learning/learned-commands.d.ts.map +1 -0
  180. package/dist/learning/learned-commands.js +108 -0
  181. package/dist/learning/learned-commands.js.map +1 -0
  182. package/dist/learning/lexical-search.d.ts +50 -0
  183. package/dist/learning/lexical-search.d.ts.map +1 -0
  184. package/dist/learning/lexical-search.js +111 -0
  185. package/dist/learning/lexical-search.js.map +1 -0
  186. package/dist/learning/machine-facts.d.ts +71 -0
  187. package/dist/learning/machine-facts.d.ts.map +1 -0
  188. package/dist/learning/machine-facts.js +164 -0
  189. package/dist/learning/machine-facts.js.map +1 -0
  190. package/dist/learning/markdown-outline.d.ts +73 -0
  191. package/dist/learning/markdown-outline.d.ts.map +1 -0
  192. package/dist/learning/markdown-outline.js +136 -0
  193. package/dist/learning/markdown-outline.js.map +1 -0
  194. package/dist/learning/model-first-router.d.ts +33 -1
  195. package/dist/learning/model-first-router.d.ts.map +1 -1
  196. package/dist/learning/model-first-router.js +123 -9
  197. package/dist/learning/model-first-router.js.map +1 -1
  198. package/dist/learning/model-identity.d.ts +80 -0
  199. package/dist/learning/model-identity.d.ts.map +1 -0
  200. package/dist/learning/model-identity.js +115 -0
  201. package/dist/learning/model-identity.js.map +1 -0
  202. package/dist/learning/model-registry.d.ts +141 -2
  203. package/dist/learning/model-registry.d.ts.map +1 -1
  204. package/dist/learning/model-registry.js +324 -38
  205. package/dist/learning/model-registry.js.map +1 -1
  206. package/dist/learning/model-scoring.d.ts.map +1 -1
  207. package/dist/learning/model-scoring.js +8 -1
  208. package/dist/learning/model-scoring.js.map +1 -1
  209. package/dist/learning/model-selection.d.ts.map +1 -1
  210. package/dist/learning/model-selection.js +41 -0
  211. package/dist/learning/model-selection.js.map +1 -1
  212. package/dist/learning/outcome-observation.d.ts +49 -0
  213. package/dist/learning/outcome-observation.d.ts.map +1 -0
  214. package/dist/learning/outcome-observation.js +95 -0
  215. package/dist/learning/outcome-observation.js.map +1 -0
  216. package/dist/learning/pair-entitlement.d.ts +184 -0
  217. package/dist/learning/pair-entitlement.d.ts.map +1 -0
  218. package/dist/learning/pair-entitlement.js +214 -0
  219. package/dist/learning/pair-entitlement.js.map +1 -0
  220. package/dist/learning/provider-fallback.d.ts +23 -1
  221. package/dist/learning/provider-fallback.d.ts.map +1 -1
  222. package/dist/learning/provider-fallback.js +24 -0
  223. package/dist/learning/provider-fallback.js.map +1 -1
  224. package/dist/learning/reasoning-trace.d.ts +65 -1
  225. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  226. package/dist/learning/reasoning-trace.js +123 -1
  227. package/dist/learning/reasoning-trace.js.map +1 -1
  228. package/dist/learning/requirement-probe.d.ts +55 -0
  229. package/dist/learning/requirement-probe.d.ts.map +1 -0
  230. package/dist/learning/requirement-probe.js +77 -0
  231. package/dist/learning/requirement-probe.js.map +1 -0
  232. package/dist/learning/resilient-call.d.ts +20 -1
  233. package/dist/learning/resilient-call.d.ts.map +1 -1
  234. package/dist/learning/resilient-call.js +42 -4
  235. package/dist/learning/resilient-call.js.map +1 -1
  236. package/dist/learning/response-usability.d.ts +85 -0
  237. package/dist/learning/response-usability.d.ts.map +1 -0
  238. package/dist/learning/response-usability.js +135 -0
  239. package/dist/learning/response-usability.js.map +1 -0
  240. package/dist/learning/router-bandit.d.ts +132 -0
  241. package/dist/learning/router-bandit.d.ts.map +1 -1
  242. package/dist/learning/router-bandit.js +253 -0
  243. package/dist/learning/router-bandit.js.map +1 -1
  244. package/dist/learning/routing-history.d.ts +41 -2
  245. package/dist/learning/routing-history.d.ts.map +1 -1
  246. package/dist/learning/routing-history.js.map +1 -1
  247. package/dist/learning/run-trace.d.ts +21 -0
  248. package/dist/learning/run-trace.d.ts.map +1 -1
  249. package/dist/learning/run-trace.js +38 -0
  250. package/dist/learning/run-trace.js.map +1 -1
  251. package/dist/learning/session-grant.d.ts +75 -0
  252. package/dist/learning/session-grant.d.ts.map +1 -0
  253. package/dist/learning/session-grant.js +112 -0
  254. package/dist/learning/session-grant.js.map +1 -0
  255. package/dist/learning/task-model-continuity.d.ts +73 -0
  256. package/dist/learning/task-model-continuity.d.ts.map +1 -0
  257. package/dist/learning/task-model-continuity.js +87 -0
  258. package/dist/learning/task-model-continuity.js.map +1 -0
  259. package/dist/learning/turn-feedback.d.ts +93 -0
  260. package/dist/learning/turn-feedback.d.ts.map +1 -0
  261. package/dist/learning/turn-feedback.js +127 -0
  262. package/dist/learning/turn-feedback.js.map +1 -0
  263. package/dist/learning/turn-report.d.ts +104 -2
  264. package/dist/learning/turn-report.d.ts.map +1 -1
  265. package/dist/learning/turn-report.js +161 -6
  266. package/dist/learning/turn-report.js.map +1 -1
  267. package/dist/learning/verdict-routing.d.ts +51 -0
  268. package/dist/learning/verdict-routing.d.ts.map +1 -0
  269. package/dist/learning/verdict-routing.js +122 -0
  270. package/dist/learning/verdict-routing.js.map +1 -0
  271. package/dist/mcp/client.d.ts.map +1 -1
  272. package/dist/mcp/client.js +5 -0
  273. package/dist/mcp/client.js.map +1 -1
  274. package/dist/mcp/manager.d.ts +10 -0
  275. package/dist/mcp/manager.d.ts.map +1 -1
  276. package/dist/mcp/manager.js +20 -0
  277. package/dist/mcp/manager.js.map +1 -1
  278. package/dist/mcp/mcp-discovery.d.ts +34 -0
  279. package/dist/mcp/mcp-discovery.d.ts.map +1 -0
  280. package/dist/mcp/mcp-discovery.js +71 -0
  281. package/dist/mcp/mcp-discovery.js.map +1 -0
  282. package/dist/mcp/mcp-schema-cache.d.ts +17 -0
  283. package/dist/mcp/mcp-schema-cache.d.ts.map +1 -1
  284. package/dist/mcp/mcp-schema-cache.js +47 -3
  285. package/dist/mcp/mcp-schema-cache.js.map +1 -1
  286. package/dist/mcp/types.d.ts +28 -0
  287. package/dist/mcp/types.d.ts.map +1 -1
  288. package/dist/mcp/types.js.map +1 -1
  289. package/dist/parity/drivers.d.ts.map +1 -1
  290. package/dist/parity/drivers.js +3 -0
  291. package/dist/parity/drivers.js.map +1 -1
  292. package/dist/parity/observation.d.ts +12 -0
  293. package/dist/parity/observation.d.ts.map +1 -1
  294. package/dist/parity/observation.js +14 -0
  295. package/dist/parity/observation.js.map +1 -1
  296. package/dist/tools/ask-user.d.ts.map +1 -1
  297. package/dist/tools/ask-user.js +4 -0
  298. package/dist/tools/ask-user.js.map +1 -1
  299. package/dist/tools/capability-registry.d.ts +150 -0
  300. package/dist/tools/capability-registry.d.ts.map +1 -0
  301. package/dist/tools/capability-registry.js +400 -0
  302. package/dist/tools/capability-registry.js.map +1 -0
  303. package/dist/tools/child-agent-runtime.d.ts.map +1 -1
  304. package/dist/tools/child-agent-runtime.js +19 -5
  305. package/dist/tools/child-agent-runtime.js.map +1 -1
  306. package/dist/tools/coding-tools.d.ts +17 -5
  307. package/dist/tools/coding-tools.d.ts.map +1 -1
  308. package/dist/tools/coding-tools.js +80 -11
  309. package/dist/tools/coding-tools.js.map +1 -1
  310. package/dist/tools/command-memo.d.ts +116 -0
  311. package/dist/tools/command-memo.d.ts.map +1 -0
  312. package/dist/tools/command-memo.js +202 -0
  313. package/dist/tools/command-memo.js.map +1 -0
  314. package/dist/tools/git-tool.d.ts.map +1 -1
  315. package/dist/tools/git-tool.js +13 -3
  316. package/dist/tools/git-tool.js.map +1 -1
  317. package/dist/tools/harness-directive.d.ts +42 -0
  318. package/dist/tools/harness-directive.d.ts.map +1 -0
  319. package/dist/tools/harness-directive.js +45 -0
  320. package/dist/tools/harness-directive.js.map +1 -0
  321. package/dist/tools/loop-skill-hint.d.ts +74 -174
  322. package/dist/tools/loop-skill-hint.d.ts.map +1 -1
  323. package/dist/tools/loop-skill-hint.js +110 -536
  324. package/dist/tools/loop-skill-hint.js.map +1 -1
  325. package/dist/tools/mcp-client-tool.d.ts +2 -0
  326. package/dist/tools/mcp-client-tool.d.ts.map +1 -1
  327. package/dist/tools/mcp-client-tool.js +3 -0
  328. package/dist/tools/mcp-client-tool.js.map +1 -1
  329. package/dist/tools/modality/shared.d.ts +1 -4
  330. package/dist/tools/modality/shared.d.ts.map +1 -1
  331. package/dist/tools/modality/shared.js +7 -22
  332. package/dist/tools/modality/shared.js.map +1 -1
  333. package/dist/tools/plan-store.d.ts +20 -2
  334. package/dist/tools/plan-store.d.ts.map +1 -1
  335. package/dist/tools/plan-store.js +51 -7
  336. package/dist/tools/plan-store.js.map +1 -1
  337. package/dist/tools/registry.d.ts +76 -3
  338. package/dist/tools/registry.d.ts.map +1 -1
  339. package/dist/tools/registry.js +486 -26
  340. package/dist/tools/registry.js.map +1 -1
  341. package/dist/tools/remediation-ladder.d.ts.map +1 -1
  342. package/dist/tools/remediation-ladder.js +8 -0
  343. package/dist/tools/remediation-ladder.js.map +1 -1
  344. package/dist/tools/run-cli.d.ts.map +1 -1
  345. package/dist/tools/run-cli.js +29 -4
  346. package/dist/tools/run-cli.js.map +1 -1
  347. package/dist/tools/run-terminal.d.ts +11 -0
  348. package/dist/tools/run-terminal.d.ts.map +1 -1
  349. package/dist/tools/run-terminal.js +265 -10
  350. package/dist/tools/run-terminal.js.map +1 -1
  351. package/dist/tools/tool-loop.d.ts +321 -13
  352. package/dist/tools/tool-loop.d.ts.map +1 -1
  353. package/dist/tools/tool-loop.js +867 -66
  354. package/dist/tools/tool-loop.js.map +1 -1
  355. package/dist/utils/atomic-store.d.ts +53 -0
  356. package/dist/utils/atomic-store.d.ts.map +1 -0
  357. package/dist/utils/atomic-store.js +178 -0
  358. package/dist/utils/atomic-store.js.map +1 -0
  359. package/dist/utils/binary-probe.d.ts +25 -0
  360. package/dist/utils/binary-probe.d.ts.map +1 -0
  361. package/dist/utils/binary-probe.js +48 -0
  362. package/dist/utils/binary-probe.js.map +1 -0
  363. package/dist/utils/workspace-path.d.ts +50 -0
  364. package/dist/utils/workspace-path.d.ts.map +1 -0
  365. package/dist/utils/workspace-path.js +86 -0
  366. package/dist/utils/workspace-path.js.map +1 -0
  367. package/dist/web-dashboard/chat-console.d.ts +24 -0
  368. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  369. package/dist/web-dashboard/chat-console.js +38 -0
  370. package/dist/web-dashboard/chat-console.js.map +1 -1
  371. package/dist/web-dashboard/project-context.d.ts +27 -2
  372. package/dist/web-dashboard/project-context.d.ts.map +1 -1
  373. package/dist/web-dashboard/project-context.js +32 -2
  374. package/dist/web-dashboard/project-context.js.map +1 -1
  375. package/dist/web-dashboard/server.d.ts +11 -0
  376. package/dist/web-dashboard/server.d.ts.map +1 -1
  377. package/dist/web-dashboard/server.js +362 -11
  378. package/dist/web-dashboard/server.js.map +1 -1
  379. package/dist/web-dashboard/src/types.d.ts +170 -1
  380. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  381. package/dist/web-dashboard/workspace-guard.d.ts +11 -2
  382. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  383. package/dist/web-dashboard/workspace-guard.js +24 -12
  384. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  385. package/dist/web-dashboard/workspace-resolution.d.ts +87 -0
  386. package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -0
  387. package/dist/web-dashboard/workspace-resolution.js +105 -0
  388. package/dist/web-dashboard/workspace-resolution.js.map +1 -0
  389. package/package.json +1 -1
  390. package/src/web-dashboard/public/assets/index-zjbdJnO-.js +210 -0
  391. package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
  392. package/src/web-dashboard/public/index.html +1 -1
  393. package/dist/agents/nvda-addon.d.ts +0 -14
  394. package/dist/agents/nvda-addon.js +0 -52
  395. package/dist/forwarded.d.ts +0 -2
  396. package/dist/forwarded.js +0 -2
  397. package/src/web-dashboard/public/assets/index-DhiuR4S2.js +0 -210
  398. 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';
29
- import { join, extname, basename } from 'node:path';
44
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
45
+ import { createHash } from 'node:crypto';
46
+ import { join, extname, basename, relative, isAbsolute } 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 { chunkText, DEFAULT_CHUNK_TOKENS, DEFAULT_OVERLAP_TOKENS } from './retrieval.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';
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
- const chunks = chunkText(text, label, chunkTokens, overlapTokens);
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.text);
357
+ vector = await embedFn(chunk.embedText);
201
358
  }
202
359
  catch {
203
- // A failed embed leaves a zero vector (never outranks a real match).
204
- vector = new Array(384).fill(0);
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
- return chunks.length;
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
- const chunks = await ingestKnowledgeText(normalized, file, extracted.text, opts);
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
- const previous = entry.documents.find((d) => d.path === file);
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({ path: file, chunks, addedAt: Date.now() });
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
- manifest.tags[normalized] = entry;
269
- writeKnowledgeManifest(manifest);
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
- // ─── Query ──────────────────────────────────────────────────────────────────
273
- /** Embed the question and return the tag's top-k chunks. Never throws. */
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 results = await store.search(queryVector, topK, (entry) => {
282
- return entry.metadata?.kind === 'knowledge-chunk' && entry.metadata?.tag === normalized;
283
- });
284
- return results.map(({ entry, similarity }) => ({
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: String(entry.metadata?.sourcePath ?? entry.id),
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,104 @@ 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
+ // True when `child` is `root` itself or lives beneath it, on ANY platform.
793
+ // Deliberately NOT a `startsWith(root + '/')` test: manifest paths carry the
794
+ // native separator, so on Windows a hardcoded `/` prefix never matches — the
795
+ // document looks like it belongs to no synced root and a deleted file is
796
+ // silently never pruned, so it keeps being served. `path.relative` is native.
797
+ function isUnderRoot(root, child) {
798
+ const rel = relative(root, child);
799
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
800
+ }
801
+ /**
802
+ * Bring a tag up to date with the folders it is kept in step with.
803
+ *
804
+ * This is `add` plus the two things a folder being kept in step needs: content
805
+ * hashes mean an unchanged file is not re-extracted, re-chunked or re-embedded,
806
+ * and a file that has DISAPPEARED stops being served.
807
+ *
808
+ * Removal is scoped to the roots the caller actually synced. Syncing `./specs`
809
+ * must not delete documents ingested from somewhere else, so a document is only
810
+ * pruned when it lives under one of the synced directories AND was absent from
811
+ * that directory's enumeration — a precise rule, rather than "anything not in
812
+ * this list", which would quietly empty a tag the first time it was called with
813
+ * the wrong path.
814
+ */
815
+ export async function syncKnowledgeTag(tag, paths, opts = {}) {
816
+ const normalized = normalizeKnowledgeTag(tag);
817
+ const result = {
818
+ tag: normalized,
819
+ added: 0,
820
+ changed: 0,
821
+ unchanged: 0,
822
+ removed: [],
823
+ chunks: 0,
824
+ skipped: [],
825
+ };
826
+ const files = [];
827
+ const directoryRoots = [];
828
+ for (const path of paths) {
829
+ let stat;
830
+ try {
831
+ stat = statSync(path);
832
+ }
833
+ catch {
834
+ result.skipped.push({ path, reason: 'not found' });
835
+ continue;
836
+ }
837
+ if (stat.isDirectory()) {
838
+ directoryRoots.push(path);
839
+ collectKnowledgeFiles(path, files);
840
+ }
841
+ else {
842
+ files.push(path);
843
+ }
844
+ }
845
+ // Snapshot BEFORE the write: the difference between the two manifests is what
846
+ // "added/changed/unchanged" means, and it must not be inferred from a counter.
847
+ const before = new Map((getKnowledgeTag(normalized)?.documents ?? []).map((d) => [d.path, d]));
848
+ const enumerated = new Set(files);
849
+ const ingest = await ingestKnowledge(normalized, files, opts);
850
+ result.skipped = ingest.skipped;
851
+ result.chunks = ingest.chunks;
852
+ // Classify the files the CALLER synced — the enumerated set — by diffing the
853
+ // two manifests. A document that is about to be pruned must not be counted as
854
+ // "unchanged": it is removed, and counting it here would report a sync as
855
+ // having kept something it just deleted.
856
+ const after = new Map((getKnowledgeTag(normalized)?.documents ?? []).map((d) => [d.path, d]));
857
+ for (const path of enumerated) {
858
+ const current = after.get(path);
859
+ if (!current)
860
+ continue; // could not be ingested — it is already in `skipped`
861
+ const previous = before.get(path);
862
+ if (!previous)
863
+ result.added += 1;
864
+ else if (previous.contentHash !== current.contentHash)
865
+ result.changed += 1;
866
+ else
867
+ result.unchanged += 1;
868
+ }
869
+ for (const path of before.keys()) {
870
+ if (enumerated.has(path))
871
+ continue;
872
+ const underSyncedRoot = directoryRoots.some((root) => isUnderRoot(root, path));
873
+ if (underSyncedRoot && (await removeKnowledgeDocument(normalized, path))) {
874
+ result.removed.push(path);
875
+ }
876
+ }
877
+ return result;
878
+ }
313
879
  // ─── Formatting for the model ───────────────────────────────────────────────
314
880
  /**
315
881
  * Render retrieved chunks as context that ATTRIBUTES each part to the user's
@@ -323,11 +889,23 @@ export function formatKnowledgeContext(tag, hits) {
323
889
  }
324
890
  const parts = hits.map((h) => {
325
891
  const name = basename(h.sourcePath);
326
- return `--- [from your data: ${name} · chunk ${h.chunkIndex + 1} · sim ${h.similarity.toFixed(3)}] ---\n${h.text}`;
892
+ // The section is part of the citation: "clause 4" is not attributable
893
+ // without it, and a reader checking the quote needs to know where to look.
894
+ const section = h.headingPath ? ` · §${h.headingPath}` : '';
895
+ // A term-only passage is marked as such: its `sim` is below the floor by
896
+ // construction, and a bare low number next to USER DATA reads as a retrieval
897
+ // mistake rather than as "this is the passage that contains your term".
898
+ const evidence = h.evidence === 'term' ? ' · term match' : '';
899
+ return `--- [from your data: ${name} · chunk ${h.chunkIndex + 1}${section} · sim ${h.similarity.toFixed(3)}${evidence}] ---\n${h.text}`;
327
900
  });
328
901
  return [
329
902
  `Knowledge tag: ${normalized} (${hits.length} relevant chunk${hits.length === 1 ? '' : 's'} from the user's own data)`,
330
903
  '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.',
904
+ // Relevance is measured (see DEFAULT_KNOWLEDGE_MIN_SIMILARITY), but a floor
905
+ // is a heuristic — so the fallback is stated as a policy, not left to chance:
906
+ // passages that do not answer the question must be reported as insufficient
907
+ // rather than stretched to fit.
908
+ '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
909
  '',
332
910
  ...parts,
333
911
  ].join('\n');