@seanhogg/builderforce-memory 2026.7.14 → 2026.9.28

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 (225) hide show
  1. package/README.md +10 -0
  2. package/dist/bridges/AnthropicBridge.d.ts +9 -1
  3. package/dist/bridges/AnthropicBridge.d.ts.map +1 -1
  4. package/dist/bridges/AnthropicBridge.js +58 -2
  5. package/dist/bridges/AnthropicBridge.js.map +1 -1
  6. package/dist/bridges/CachingBridge.d.ts +9 -1
  7. package/dist/bridges/CachingBridge.d.ts.map +1 -1
  8. package/dist/bridges/CachingBridge.js +24 -1
  9. package/dist/bridges/CachingBridge.js.map +1 -1
  10. package/dist/bridges/OpenAIBridge.d.ts +4 -1
  11. package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
  12. package/dist/bridges/OpenAIBridge.js +40 -2
  13. package/dist/bridges/OpenAIBridge.js.map +1 -1
  14. package/dist/bridges/SemanticCachingBridge.d.ts +8 -1
  15. package/dist/bridges/SemanticCachingBridge.d.ts.map +1 -1
  16. package/dist/bridges/SemanticCachingBridge.js +26 -1
  17. package/dist/bridges/SemanticCachingBridge.js.map +1 -1
  18. package/dist/bridges/TransformerBridge.d.ts +13 -0
  19. package/dist/bridges/TransformerBridge.d.ts.map +1 -1
  20. package/dist/bridges/VertexAIBridge.d.ts +102 -0
  21. package/dist/bridges/VertexAIBridge.d.ts.map +1 -0
  22. package/dist/bridges/VertexAIBridge.js +224 -0
  23. package/dist/bridges/VertexAIBridge.js.map +1 -0
  24. package/dist/bridges/index.d.ts +3 -1
  25. package/dist/bridges/index.d.ts.map +1 -1
  26. package/dist/bridges/index.js +1 -0
  27. package/dist/bridges/index.js.map +1 -1
  28. package/dist/eval/EvalHarness.d.ts +37 -0
  29. package/dist/eval/EvalHarness.d.ts.map +1 -0
  30. package/dist/eval/EvalHarness.js +178 -0
  31. package/dist/eval/EvalHarness.js.map +1 -0
  32. package/dist/eval/graders.d.ts +76 -0
  33. package/dist/eval/graders.d.ts.map +1 -0
  34. package/dist/eval/graders.js +168 -0
  35. package/dist/eval/graders.js.map +1 -0
  36. package/dist/eval/index.d.ts +26 -0
  37. package/dist/eval/index.d.ts.map +1 -0
  38. package/dist/eval/index.js +26 -0
  39. package/dist/eval/index.js.map +1 -0
  40. package/dist/eval/types.d.ts +97 -0
  41. package/dist/eval/types.d.ts.map +1 -0
  42. package/dist/eval/types.js +5 -0
  43. package/dist/eval/types.js.map +1 -0
  44. package/dist/evermind/contract.d.ts +40 -0
  45. package/dist/evermind/contract.d.ts.map +1 -0
  46. package/dist/evermind/contract.js +13 -0
  47. package/dist/evermind/contract.js.map +1 -0
  48. package/dist/evermind/index.d.ts +15 -0
  49. package/dist/evermind/index.d.ts.map +1 -0
  50. package/dist/evermind/index.js +4 -0
  51. package/dist/evermind/index.js.map +1 -0
  52. package/dist/evermind/memoryBlock.d.ts +7 -0
  53. package/dist/evermind/memoryBlock.d.ts.map +1 -0
  54. package/dist/evermind/memoryBlock.js +37 -0
  55. package/dist/evermind/memoryBlock.js.map +1 -0
  56. package/dist/evermind/recall.d.ts +48 -0
  57. package/dist/evermind/recall.d.ts.map +1 -0
  58. package/dist/evermind/recall.js +105 -0
  59. package/dist/evermind/recall.js.map +1 -0
  60. package/dist/index.d.ts +18 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +14 -1
  63. package/dist/index.js.map +1 -1
  64. package/dist/ingest/IngestionPipeline.d.ts +95 -0
  65. package/dist/ingest/IngestionPipeline.d.ts.map +1 -0
  66. package/dist/ingest/IngestionPipeline.js +295 -0
  67. package/dist/ingest/IngestionPipeline.js.map +1 -0
  68. package/dist/ingest/index.d.ts +10 -0
  69. package/dist/ingest/index.d.ts.map +1 -0
  70. package/dist/ingest/index.js +8 -0
  71. package/dist/ingest/index.js.map +1 -0
  72. package/dist/ingest/parsers.d.ts +53 -0
  73. package/dist/ingest/parsers.d.ts.map +1 -0
  74. package/dist/ingest/parsers.js +306 -0
  75. package/dist/ingest/parsers.js.map +1 -0
  76. package/dist/ingest/types.d.ts +114 -0
  77. package/dist/ingest/types.d.ts.map +1 -0
  78. package/dist/ingest/types.js +19 -0
  79. package/dist/ingest/types.js.map +1 -0
  80. package/dist/memory/MemoryStore.d.ts +33 -0
  81. package/dist/memory/MemoryStore.d.ts.map +1 -1
  82. package/dist/memory/MemoryStore.js +29 -3
  83. package/dist/memory/MemoryStore.js.map +1 -1
  84. package/dist/memory/index.d.ts +1 -1
  85. package/dist/memory/index.d.ts.map +1 -1
  86. package/dist/orchestration/AgentGraph.d.ts +104 -0
  87. package/dist/orchestration/AgentGraph.d.ts.map +1 -0
  88. package/dist/orchestration/AgentGraph.js +364 -0
  89. package/dist/orchestration/AgentGraph.js.map +1 -0
  90. package/dist/orchestration/checkpoint.d.ts +58 -0
  91. package/dist/orchestration/checkpoint.d.ts.map +1 -0
  92. package/dist/orchestration/checkpoint.js +92 -0
  93. package/dist/orchestration/checkpoint.js.map +1 -0
  94. package/dist/orchestration/index.d.ts +14 -0
  95. package/dist/orchestration/index.d.ts.map +1 -0
  96. package/dist/orchestration/index.js +10 -0
  97. package/dist/orchestration/index.js.map +1 -0
  98. package/dist/orchestration/patterns.d.ts +159 -0
  99. package/dist/orchestration/patterns.d.ts.map +1 -0
  100. package/dist/orchestration/patterns.js +371 -0
  101. package/dist/orchestration/patterns.js.map +1 -0
  102. package/dist/orchestration/types.d.ts +146 -0
  103. package/dist/orchestration/types.d.ts.map +1 -0
  104. package/dist/orchestration/types.js +20 -0
  105. package/dist/orchestration/types.js.map +1 -0
  106. package/dist/rag/EnterpriseRetriever.d.ts +114 -0
  107. package/dist/rag/EnterpriseRetriever.d.ts.map +1 -0
  108. package/dist/rag/EnterpriseRetriever.js +240 -0
  109. package/dist/rag/EnterpriseRetriever.js.map +1 -0
  110. package/dist/rag/index.d.ts +6 -0
  111. package/dist/rag/index.d.ts.map +1 -0
  112. package/dist/rag/index.js +5 -0
  113. package/dist/rag/index.js.map +1 -0
  114. package/dist/retrieval/bm25.d.ts +48 -1
  115. package/dist/retrieval/bm25.d.ts.map +1 -1
  116. package/dist/retrieval/bm25.js +59 -13
  117. package/dist/retrieval/bm25.js.map +1 -1
  118. package/dist/retrieval/index.d.ts +2 -2
  119. package/dist/retrieval/index.d.ts.map +1 -1
  120. package/dist/retrieval/index.js +1 -1
  121. package/dist/retrieval/index.js.map +1 -1
  122. package/dist/runtime/SSMRuntime.d.ts +1 -1
  123. package/dist/runtime/SSMRuntime.d.ts.map +1 -1
  124. package/dist/telemetry/InstrumentedBridge.d.ts +62 -0
  125. package/dist/telemetry/InstrumentedBridge.d.ts.map +1 -0
  126. package/dist/telemetry/InstrumentedBridge.js +120 -0
  127. package/dist/telemetry/InstrumentedBridge.js.map +1 -0
  128. package/dist/telemetry/MetricsRegistry.d.ts +88 -0
  129. package/dist/telemetry/MetricsRegistry.d.ts.map +1 -0
  130. package/dist/telemetry/MetricsRegistry.js +167 -0
  131. package/dist/telemetry/MetricsRegistry.js.map +1 -0
  132. package/dist/telemetry/Tracer.d.ts +137 -0
  133. package/dist/telemetry/Tracer.d.ts.map +1 -0
  134. package/dist/telemetry/Tracer.js +244 -0
  135. package/dist/telemetry/Tracer.js.map +1 -0
  136. package/dist/telemetry/exporters.d.ts +68 -0
  137. package/dist/telemetry/exporters.d.ts.map +1 -0
  138. package/dist/telemetry/exporters.js +145 -0
  139. package/dist/telemetry/exporters.js.map +1 -0
  140. package/dist/telemetry/index.d.ts +19 -0
  141. package/dist/telemetry/index.d.ts.map +1 -0
  142. package/dist/telemetry/index.js +12 -0
  143. package/dist/telemetry/index.js.map +1 -0
  144. package/dist/telemetry/pricing.d.ts +74 -0
  145. package/dist/telemetry/pricing.d.ts.map +1 -0
  146. package/dist/telemetry/pricing.js +98 -0
  147. package/dist/telemetry/pricing.js.map +1 -0
  148. package/dist/telemetry/traceparent.d.ts +50 -0
  149. package/dist/telemetry/traceparent.d.ts.map +1 -0
  150. package/dist/telemetry/traceparent.js +77 -0
  151. package/dist/telemetry/traceparent.js.map +1 -0
  152. package/dist/telemetry/types.d.ts +90 -0
  153. package/dist/telemetry/types.d.ts.map +1 -0
  154. package/dist/telemetry/types.js +17 -0
  155. package/dist/telemetry/types.js.map +1 -0
  156. package/dist/vectorstore/MemoryVectorStore.d.ts +72 -0
  157. package/dist/vectorstore/MemoryVectorStore.d.ts.map +1 -0
  158. package/dist/vectorstore/MemoryVectorStore.js +205 -0
  159. package/dist/vectorstore/MemoryVectorStore.js.map +1 -0
  160. package/dist/vectorstore/RestVectorStore.d.ts +79 -0
  161. package/dist/vectorstore/RestVectorStore.d.ts.map +1 -0
  162. package/dist/vectorstore/RestVectorStore.js +170 -0
  163. package/dist/vectorstore/RestVectorStore.js.map +1 -0
  164. package/dist/vectorstore/dialects.d.ts +101 -0
  165. package/dist/vectorstore/dialects.d.ts.map +1 -0
  166. package/dist/vectorstore/dialects.js +618 -0
  167. package/dist/vectorstore/dialects.js.map +1 -0
  168. package/dist/vectorstore/filter.d.ts +42 -0
  169. package/dist/vectorstore/filter.d.ts.map +1 -0
  170. package/dist/vectorstore/filter.js +132 -0
  171. package/dist/vectorstore/filter.js.map +1 -0
  172. package/dist/vectorstore/index.d.ts +15 -0
  173. package/dist/vectorstore/index.d.ts.map +1 -0
  174. package/dist/vectorstore/index.js +11 -0
  175. package/dist/vectorstore/index.js.map +1 -0
  176. package/dist/vectorstore/types.d.ts +151 -0
  177. package/dist/vectorstore/types.d.ts.map +1 -0
  178. package/dist/vectorstore/types.js +30 -0
  179. package/dist/vectorstore/types.js.map +1 -0
  180. package/package.json +39 -5
  181. package/src/bridges/AnthropicBridge.ts +68 -3
  182. package/src/bridges/CachingBridge.ts +28 -2
  183. package/src/bridges/OpenAIBridge.ts +48 -3
  184. package/src/bridges/SemanticCachingBridge.ts +29 -2
  185. package/src/bridges/TransformerBridge.ts +16 -0
  186. package/src/bridges/VertexAIBridge.ts +297 -0
  187. package/src/bridges/index.ts +3 -1
  188. package/src/eval/EvalHarness.ts +214 -0
  189. package/src/eval/graders.ts +218 -0
  190. package/src/eval/index.ts +26 -0
  191. package/src/eval/types.ts +107 -0
  192. package/src/evermind/contract.ts +39 -0
  193. package/src/evermind/index.ts +18 -0
  194. package/src/evermind/memoryBlock.ts +38 -0
  195. package/src/evermind/recall.ts +122 -0
  196. package/src/index.ts +232 -0
  197. package/src/ingest/IngestionPipeline.ts +378 -0
  198. package/src/ingest/index.ts +46 -0
  199. package/src/ingest/parsers.ts +318 -0
  200. package/src/ingest/types.ts +116 -0
  201. package/src/memory/MemoryStore.ts +55 -3
  202. package/src/memory/index.ts +1 -1
  203. package/src/orchestration/AgentGraph.ts +462 -0
  204. package/src/orchestration/checkpoint.ts +129 -0
  205. package/src/orchestration/index.ts +63 -0
  206. package/src/orchestration/patterns.ts +532 -0
  207. package/src/orchestration/types.ts +128 -0
  208. package/src/rag/EnterpriseRetriever.ts +348 -0
  209. package/src/rag/index.ts +18 -0
  210. package/src/retrieval/bm25.ts +71 -13
  211. package/src/retrieval/index.ts +2 -2
  212. package/src/telemetry/InstrumentedBridge.ts +152 -0
  213. package/src/telemetry/MetricsRegistry.ts +263 -0
  214. package/src/telemetry/Tracer.ts +310 -0
  215. package/src/telemetry/exporters.ts +187 -0
  216. package/src/telemetry/index.ts +56 -0
  217. package/src/telemetry/pricing.ts +129 -0
  218. package/src/telemetry/traceparent.ts +89 -0
  219. package/src/telemetry/types.ts +105 -0
  220. package/src/vectorstore/MemoryVectorStore.ts +250 -0
  221. package/src/vectorstore/RestVectorStore.ts +236 -0
  222. package/src/vectorstore/dialects.ts +702 -0
  223. package/src/vectorstore/filter.ts +148 -0
  224. package/src/vectorstore/index.ts +53 -0
  225. package/src/vectorstore/types.ts +149 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * rag/EnterpriseRetriever.ts — hybrid, tenant-scoped, cited retrieval.
3
+ *
4
+ * The gap between a RAG demo and an enterprise RAG system is not the embedding
5
+ * model. It is these five properties, all of which live here:
6
+ *
7
+ * 1. **Scoped.** Every read carries an {@link AccessScope} that is compiled into
8
+ * the database query. There is no code path that reads unscoped, so a
9
+ * cross-tenant answer is not something a caller can cause by forgetting.
10
+ * 2. **Hybrid.** Dense retrieval alone misses exact tokens — error codes, SKUs,
11
+ * contract clause numbers, function names. Lexical search alone misses
12
+ * paraphrase. RRF over both, then MMR for diversity, then an optional
13
+ * reranker.
14
+ * 3. **Honest about degradation.** When the store has no lexical index the
15
+ * retrieval is dense-only and SAYS so on the span and in the result, instead
16
+ * of quietly returning worse answers.
17
+ * 4. **Cited.** Every passage carries its source id, title and URI, and the
18
+ * prompt builder numbers them, so an answer can be checked. An uncitable
19
+ * answer is an unauditable one, and enterprises audit.
20
+ * 5. **Traced and costed.** Retrieval and generation are spans under one trace,
21
+ * with token counts and cost, so "why was this answer bad" and "why did this
22
+ * cost that" are answerable from the same record.
23
+ */
24
+
25
+ import type { TransformerBridge } from '../bridges/TransformerBridge.js';
26
+ import { maximalMarginalRelevance, reciprocalRankFusion } from '../retrieval/fusion.js';
27
+ import type { Span, Tracer } from '../telemetry/Tracer.js';
28
+ import {
29
+ SOURCE_ID_FIELD,
30
+ TITLE_FIELD,
31
+ URI_FIELD,
32
+ } from '../ingest/types.js';
33
+ import type {
34
+ AccessScope,
35
+ MetadataFilter,
36
+ VectorMatch,
37
+ VectorStore,
38
+ } from '../vectorstore/types.js';
39
+
40
+ /** Reranker port — a cross-encoder, an LLM judge, or a hosted rerank endpoint. */
41
+ export type Reranker = (
42
+ query: string,
43
+ passages: readonly RetrievedPassage[],
44
+ ) => Promise<readonly RetrievedPassage[]>;
45
+
46
+ export interface RetrievedPassage {
47
+ id: string;
48
+ text: string;
49
+ /** Fused relevance score. */
50
+ score: number;
51
+ sourceId: string;
52
+ title?: string;
53
+ uri?: string;
54
+ metadata: Record<string, unknown>;
55
+ }
56
+
57
+ export type RetrievalMode = 'hybrid' | 'dense-only' | 'keyword-only' | 'empty';
58
+
59
+ export interface RetrievalResult {
60
+ passages: RetrievedPassage[];
61
+ /** What actually ran — `dense-only` means the store had no lexical index. */
62
+ mode: RetrievalMode;
63
+ /** Candidates considered before fusion and truncation. */
64
+ candidatesConsidered: number;
65
+ traceId?: string;
66
+ }
67
+
68
+ export interface RetrieveOptions {
69
+ /** Tenant/ACL scope. Required unless the retriever was built with a default. */
70
+ scope?: AccessScope;
71
+ /** Extra metadata predicate, AND-ed with the scope. */
72
+ filter?: MetadataFilter;
73
+ /** Passages returned. Default 5. */
74
+ topK?: number;
75
+ /** Candidates pulled from each retrieval arm before fusion. Default `topK * 4`. */
76
+ candidateK?: number;
77
+ /** Drop passages below this fused score after normalisation. Default 0. */
78
+ minScore?: number;
79
+ /** Parent span, so retrieval nests under the agent turn that asked for it. */
80
+ parent?: Span;
81
+ }
82
+
83
+ export interface EnterpriseRetrieverOptions {
84
+ store: VectorStore;
85
+ /** Embeds the query. Omit for keyword-only retrieval. */
86
+ embed?: (text: string) => Promise<Float32Array>;
87
+ /** Applied to the fused list before truncation. */
88
+ reranker?: Reranker;
89
+ /** Default scope, when a deployment is single-tenant or scope comes from context. */
90
+ defaultScope?: AccessScope;
91
+ /** RRF weight for the dense arm. Default 1. */
92
+ denseWeight?: number;
93
+ /** RRF weight for the lexical arm. Default 1. */
94
+ keywordWeight?: number;
95
+ /** MMR relevance/diversity trade-off; 1 is pure relevance. Default 0.7. */
96
+ mmrLambda?: number;
97
+ /** Disable the MMR diversity pass. Default false. */
98
+ disableDiversity?: boolean;
99
+ tracer?: Tracer;
100
+ }
101
+
102
+ export class EnterpriseRetriever {
103
+ private readonly _store: VectorStore;
104
+ private readonly _embed: ((text: string) => Promise<Float32Array>) | undefined;
105
+ private readonly _reranker: Reranker | undefined;
106
+ private readonly _defaultScope: AccessScope | undefined;
107
+ private readonly _denseWeight: number;
108
+ private readonly _keywordWeight: number;
109
+ private readonly _mmrLambda: number;
110
+ private readonly _diversity: boolean;
111
+ private readonly _tracer: Tracer | undefined;
112
+
113
+ constructor(opts: EnterpriseRetrieverOptions) {
114
+ this._store = opts.store;
115
+ this._embed = opts.embed;
116
+ this._reranker = opts.reranker;
117
+ this._defaultScope = opts.defaultScope;
118
+ this._denseWeight = opts.denseWeight ?? 1;
119
+ this._keywordWeight = opts.keywordWeight ?? 1;
120
+ this._mmrLambda = opts.mmrLambda ?? 0.7;
121
+ this._diversity = !opts.disableDiversity;
122
+ this._tracer = opts.tracer;
123
+ }
124
+
125
+ async retrieve(query: string, opts: RetrieveOptions = {}): Promise<RetrievalResult> {
126
+ const topK = Math.max(1, opts.topK ?? 5);
127
+ const candidateK = Math.max(topK, opts.candidateK ?? topK * 4);
128
+ const scope = opts.scope ?? this._defaultScope;
129
+
130
+ const span = this._tracer?.startSpan('retrieval.hybrid', {
131
+ kind: 'retrieval',
132
+ ...(opts.parent ? { parent: opts.parent } : {}),
133
+ attributes: {
134
+ 'retrieval.store': this._store.name,
135
+ 'retrieval.top_k': topK,
136
+ 'retrieval.tenant': scope?.tenantId ?? 'unscoped',
137
+ },
138
+ });
139
+
140
+ try {
141
+ const queryVector = this._embed ? await this._embed(query) : undefined;
142
+
143
+ const base = {
144
+ text: query,
145
+ topK: candidateK,
146
+ ...(opts.filter ? { filter: opts.filter } : {}),
147
+ ...(scope ? { scope } : {}),
148
+ includeVectors: this._diversity,
149
+ };
150
+
151
+ // Both arms run concurrently: they hit different indexes and the
152
+ // sequential version doubles p95 latency for nothing.
153
+ const [dense, keyword] = await Promise.all([
154
+ queryVector
155
+ ? this._store.query({ ...base, vector: queryVector })
156
+ : Promise.resolve<VectorMatch[]>([]),
157
+ this._store.keywordSearch
158
+ ? this._store.keywordSearch(base)
159
+ : Promise.resolve<VectorMatch[]>([]),
160
+ ]);
161
+
162
+ const mode = pickMode(dense.length > 0, keyword.length > 0, Boolean(this._store.keywordSearch));
163
+ const byId = new Map<string, VectorMatch>();
164
+ for (const match of [...dense, ...keyword]) {
165
+ if (!byId.has(match.id)) byId.set(match.id, match);
166
+ }
167
+
168
+ let ordered = this._fuse(dense, keyword, byId);
169
+
170
+ if (this._diversity && queryVector && ordered.length > topK) {
171
+ ordered = this._diversify(queryVector, ordered, byId, topK);
172
+ }
173
+
174
+ let passages = ordered.slice(0, Math.max(topK, this._reranker ? candidateK : topK))
175
+ .map((hit) => toPassage(byId.get(hit.id) as VectorMatch, hit.score));
176
+
177
+ if (this._reranker && passages.length > 0) {
178
+ passages = [...await this._reranker(query, passages)];
179
+ }
180
+ if (opts.minScore !== undefined) {
181
+ passages = passages.filter((p) => p.score >= (opts.minScore as number));
182
+ }
183
+ passages = passages.slice(0, topK);
184
+
185
+ span?.setAttributes({
186
+ 'retrieval.mode': mode,
187
+ 'retrieval.candidates': byId.size,
188
+ 'retrieval.returned': passages.length,
189
+ 'retrieval.dense_hits': dense.length,
190
+ 'retrieval.keyword_hits': keyword.length,
191
+ 'retrieval.reranked': Boolean(this._reranker),
192
+ });
193
+ span?.end('ok');
194
+
195
+ const result: RetrievalResult = {
196
+ passages,
197
+ mode,
198
+ candidatesConsidered: byId.size,
199
+ };
200
+ if (span) result.traceId = span.traceId;
201
+ return result;
202
+ } catch (err) {
203
+ span?.fail(err);
204
+ throw err;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Retrieve, then answer strictly from what was retrieved.
210
+ *
211
+ * The system prompt forbids answering beyond the passages and requires
212
+ * `[n]` citations. That is not decoration: an enterprise deployment is judged
213
+ * on whether a wrong answer is *detectable*, and an uncited claim is not.
214
+ */
215
+ async answer(
216
+ query: string,
217
+ bridge: TransformerBridge,
218
+ opts: RetrieveOptions & { systemPrompt?: string; maxTokens?: number; model?: string } = {},
219
+ ): Promise<{ answer: string; result: RetrievalResult }> {
220
+ const span = this._tracer?.startSpan('rag.answer', {
221
+ kind: 'agent',
222
+ ...(opts.parent ? { parent: opts.parent } : {}),
223
+ });
224
+
225
+ try {
226
+ const result = await this.retrieve(query, { ...opts, ...(span ? { parent: span } : {}) });
227
+
228
+ if (result.passages.length === 0) {
229
+ // Refusing beats hallucinating: with no evidence there is nothing to
230
+ // ground an answer in, and a confident answer here is the exact
231
+ // failure enterprises cite when a pilot is killed.
232
+ span?.setAttribute('rag.refused', true);
233
+ span?.end('ok');
234
+ return { answer: NO_EVIDENCE_ANSWER, result };
235
+ }
236
+
237
+ const prompt = buildGroundedPrompt(query, result.passages);
238
+ const answer = await bridge.generate(prompt, {
239
+ systemPrompt: opts.systemPrompt ?? GROUNDED_SYSTEM_PROMPT,
240
+ ...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
241
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
242
+ });
243
+
244
+ span?.setAttributes({ 'rag.passages': result.passages.length, 'rag.mode': result.mode });
245
+ span?.end('ok');
246
+ return { answer, result };
247
+ } catch (err) {
248
+ span?.fail(err);
249
+ throw err;
250
+ }
251
+ }
252
+
253
+ private _fuse(
254
+ dense: readonly VectorMatch[],
255
+ keyword: readonly VectorMatch[],
256
+ byId: Map<string, VectorMatch>,
257
+ ): Array<{ id: string; score: number }> {
258
+ const lists = [];
259
+ if (dense.length) lists.push({ ids: dense.map((m) => m.id), weight: this._denseWeight });
260
+ if (keyword.length) lists.push({ ids: keyword.map((m) => m.id), weight: this._keywordWeight });
261
+
262
+ if (lists.length === 0) return [];
263
+ if (lists.length === 1) {
264
+ // One arm: keep the store's own similarity rather than replacing it with
265
+ // an RRF score, which carries no meaning for a single list.
266
+ const only = dense.length ? dense : keyword;
267
+ return only.map((m) => ({ id: m.id, score: m.score }));
268
+ }
269
+
270
+ const fused = reciprocalRankFusion(lists);
271
+ const best = fused[0]?.score ?? 1;
272
+ // RRF scores live on a tiny arbitrary scale (~1/60); normalising to [0,1]
273
+ // keeps `minScore` meaning the same thing on one arm or two.
274
+ return fused
275
+ .filter((hit) => byId.has(hit.id))
276
+ .map((hit) => ({ id: hit.id, score: best > 0 ? hit.score / best : 0 }));
277
+ }
278
+
279
+ private _diversify(
280
+ queryVector: Float32Array,
281
+ ordered: Array<{ id: string; score: number }>,
282
+ byId: Map<string, VectorMatch>,
283
+ topK: number,
284
+ ): Array<{ id: string; score: number }> {
285
+ const candidates = ordered
286
+ .map((hit) => ({ id: hit.id, vector: byId.get(hit.id)?.vector }))
287
+ .filter((c): c is { id: string; vector: Float32Array } => Boolean(c.vector));
288
+
289
+ // Without stored vectors MMR cannot run; keeping the fused order is the
290
+ // correct degradation, not an error.
291
+ if (candidates.length < 2) return ordered;
292
+
293
+ const scores = new Map(ordered.map((hit) => [hit.id, hit.score]));
294
+ const picked = maximalMarginalRelevance(queryVector, candidates, topK, this._mmrLambda);
295
+ const pickedSet = new Set(picked);
296
+
297
+ return [
298
+ ...picked.map((id) => ({ id, score: scores.get(id) ?? 0 })),
299
+ ...ordered.filter((hit) => !pickedSet.has(hit.id)),
300
+ ];
301
+ }
302
+ }
303
+
304
+ function pickMode(hasDense: boolean, hasKeyword: boolean, storeHasKeyword: boolean): RetrievalMode {
305
+ if (hasDense && hasKeyword) return 'hybrid';
306
+ if (hasDense) return 'dense-only';
307
+ if (hasKeyword) return 'keyword-only';
308
+ return storeHasKeyword ? 'empty' : 'dense-only';
309
+ }
310
+
311
+ function toPassage(match: VectorMatch, score: number): RetrievedPassage {
312
+ const metadata = match.metadata as Record<string, unknown>;
313
+ const passage: RetrievedPassage = {
314
+ id: match.id,
315
+ text: match.text,
316
+ score,
317
+ sourceId: String(metadata[SOURCE_ID_FIELD] ?? match.id),
318
+ metadata,
319
+ };
320
+ const title = metadata[TITLE_FIELD];
321
+ if (typeof title === 'string') passage.title = title;
322
+ const uri = metadata[URI_FIELD];
323
+ if (typeof uri === 'string') passage.uri = uri;
324
+ return passage;
325
+ }
326
+
327
+ export const GROUNDED_SYSTEM_PROMPT =
328
+ 'You answer strictly from the numbered passages provided. ' +
329
+ 'Cite every claim with the passage number in square brackets, e.g. [2]. ' +
330
+ 'If the passages do not contain the answer, say so plainly and name what is missing. ' +
331
+ 'Never rely on knowledge that is not in the passages.';
332
+
333
+ export const NO_EVIDENCE_ANSWER =
334
+ 'No indexed passage matched this question within the caller\'s access scope, ' +
335
+ 'so there is nothing to ground an answer in.';
336
+
337
+ /** Renders passages as a numbered, citable evidence block. */
338
+ export function buildGroundedPrompt(query: string, passages: readonly RetrievedPassage[]): string {
339
+ const evidence = passages
340
+ .map((p, i) => {
341
+ const label = p.title ?? p.sourceId;
342
+ const uri = p.uri ? ` (${p.uri})` : '';
343
+ return `[${i + 1}] ${label}${uri}\n${p.text}`;
344
+ })
345
+ .join('\n\n');
346
+
347
+ return `Passages:\n\n${evidence}\n\nQuestion: ${query}\n\nAnswer, citing passages by number:`;
348
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * rag — enterprise retrieval: hybrid, tenant/ACL scoped, cited, traced.
3
+ */
4
+
5
+ export {
6
+ EnterpriseRetriever,
7
+ buildGroundedPrompt,
8
+ GROUNDED_SYSTEM_PROMPT,
9
+ NO_EVIDENCE_ANSWER,
10
+ } from './EnterpriseRetriever.js';
11
+ export type {
12
+ EnterpriseRetrieverOptions,
13
+ RetrieveOptions,
14
+ RetrievalResult,
15
+ RetrievalMode,
16
+ RetrievedPassage,
17
+ Reranker,
18
+ } from './EnterpriseRetriever.js';
@@ -6,16 +6,39 @@
6
6
  * Fusing the two (see {@link ./fusion}) is what lifts the memory layer from
7
7
  * "cosine only" to a hybrid retriever on par with Weaviate-style search.
8
8
  *
9
- * Pure and zero-dependency — reuses the shared `tokenize` from ../similarity.
9
+ * Two things are separable here and are kept separate on purpose:
10
+ *
11
+ * • the SCORING CORE ({@link bm25Idf}, {@link bm25LengthNorm}, {@link bm25TermScore})
12
+ * — the Okapi formula itself, which is the same wherever lexical relevance is
13
+ * scored, whether the index is built inline from raw text or read out of a
14
+ * precomputed per-field term table (BM25F). Exported so a consumer with its own
15
+ * index shape reuses the maths instead of restating it and drifting from it;
16
+ * • the TOKENIZER — which is genuinely corpus-specific. Prose wants stopword
17
+ * removal and stemming; identifiers and error codes want neither. So the
18
+ * tokenizer is an injectable {@link Bm25Options.tokenize} hook defaulting to the
19
+ * package's plain `tokenize`, rather than a second copy of BM25 growing around
20
+ * each specialisation.
21
+ *
22
+ * Pure and zero-dependency.
10
23
  */
11
24
 
12
25
  import { tokenize } from '../similarity/index.js';
13
26
 
27
+ /** Splits text into the terms BM25 scores over. */
28
+ export type Bm25Tokenizer = (text: string) => string[];
29
+
14
30
  export interface Bm25Options {
15
31
  /** Term-frequency saturation. Higher = TF matters more. Default 1.5. */
16
32
  k1?: number;
17
33
  /** Length normalisation, 0..1. Higher = penalise long docs more. Default 0.75. */
18
34
  b?: number;
35
+ /**
36
+ * Tokenizer for both the query and the documents. Default is the package's plain
37
+ * `tokenize` (lowercase word split), which preserves identifiers verbatim. Supply
38
+ * a stemming/stopword tokenizer when the corpus is prose — the SAME function must
39
+ * be used for query and documents or nothing will match.
40
+ */
41
+ tokenize?: Bm25Tokenizer;
19
42
  }
20
43
 
21
44
  export interface Bm25Doc {
@@ -28,6 +51,45 @@ export interface Bm25Hit {
28
51
  score: number;
29
52
  }
30
53
 
54
+ /** Term-frequency saturation used when {@link Bm25Options.k1} is omitted. */
55
+ export const BM25_DEFAULT_K1 = 1.5;
56
+ /** Length normalisation used when {@link Bm25Options.b} is omitted. */
57
+ export const BM25_DEFAULT_B = 0.75;
58
+
59
+ /**
60
+ * Inverse document frequency with the +1 smoothing variant, which is always
61
+ * non-negative — the unsmoothed form goes negative for a term present in more than
62
+ * half the corpus, which subtracts score for a MATCH.
63
+ */
64
+ export function bm25Idf(documentCount: number, documentFrequency: number): number {
65
+ const n = Math.max(documentCount, 1);
66
+ const df = Math.max(0, Math.min(documentFrequency, n));
67
+ return Math.log(1 + (n - df + 0.5) / (df + 0.5));
68
+ }
69
+
70
+ /**
71
+ * The document-length penalty `1 - b + b·(len/avgdl)`, factored out because it is
72
+ * per-DOCUMENT: computing it once and reusing it across the query's terms is what
73
+ * keeps scoring O(terms) rather than O(terms) divisions of the same quotient.
74
+ */
75
+ export function bm25LengthNorm(length: number, averageLength: number, b: number = BM25_DEFAULT_B): number {
76
+ const avg = averageLength > 0 ? averageLength : 1;
77
+ return 1 - b + b * (Math.max(0, length) / avg);
78
+ }
79
+
80
+ /**
81
+ * One term's contribution: `idf · f(k1+1) / (f + k1·lengthNorm)`.
82
+ *
83
+ * `frequency` is whatever the caller counts as the term's occurrence weight — a raw
84
+ * count for plain BM25, or a field-weighted sum for BM25F. Combining fields BEFORE
85
+ * saturation (rather than saturating each field and summing) is what makes BM25F
86
+ * behave like BM25 over a weighted document.
87
+ */
88
+ export function bm25TermScore(frequency: number, idf: number, lengthNorm: number, k1: number = BM25_DEFAULT_K1): number {
89
+ if (frequency <= 0) return 0;
90
+ return (idf * (frequency * (k1 + 1))) / (frequency + k1 * lengthNorm);
91
+ }
92
+
31
93
  /**
32
94
  * Scores every document against `query` with Okapi BM25, returning hits sorted by
33
95
  * descending score (documents with no query-term overlap score 0 and are dropped).
@@ -35,12 +97,13 @@ export interface Bm25Hit {
35
97
  * store / a vector pre-filter) this is O(N·terms) and needs no persistence.
36
98
  */
37
99
  export function bm25Search(query: string, docs: Bm25Doc[], opts: Bm25Options = {}): Bm25Hit[] {
38
- const k1 = opts.k1 ?? 1.5;
39
- const b = opts.b ?? 0.75;
100
+ const k1 = opts.k1 ?? BM25_DEFAULT_K1;
101
+ const b = opts.b ?? BM25_DEFAULT_B;
102
+ const split = opts.tokenize ?? tokenize;
40
103
  const N = docs.length;
41
104
  if (N === 0) return [];
42
105
 
43
- const queryTerms = new Set(tokenize(query));
106
+ const queryTerms = new Set(split(query));
44
107
  if (queryTerms.size === 0) return [];
45
108
 
46
109
  // Per-doc term frequencies + document lengths.
@@ -49,7 +112,7 @@ export function bm25Search(query: string, docs: Bm25Doc[], opts: Bm25Options = {
49
112
  let totalLen = 0;
50
113
 
51
114
  for (const doc of docs) {
52
- const tokens = tokenize(doc.text);
115
+ const tokens = split(doc.text);
53
116
  const tf = new Map<string, number>();
54
117
  for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1);
55
118
  for (const t of tf.keys()) if (queryTerms.has(t)) df.set(t, (df.get(t) ?? 0) + 1);
@@ -58,22 +121,17 @@ export function bm25Search(query: string, docs: Bm25Doc[], opts: Bm25Options = {
58
121
  }
59
122
  const avgdl = totalLen / N || 1;
60
123
 
61
- // idf with the +1 smoothing variant (always non-negative).
62
124
  const idf = new Map<string, number>();
63
- for (const term of queryTerms) {
64
- const n = df.get(term) ?? 0;
65
- idf.set(term, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
66
- }
125
+ for (const term of queryTerms) idf.set(term, bm25Idf(N, df.get(term) ?? 0));
67
126
 
68
127
  const hits: Bm25Hit[] = [];
69
128
  for (const d of docTerms) {
129
+ const lengthNorm = bm25LengthNorm(d.len, avgdl, b);
70
130
  let score = 0;
71
131
  for (const term of queryTerms) {
72
132
  const f = d.tf.get(term);
73
133
  if (!f) continue;
74
- const numer = f * (k1 + 1);
75
- const denom = f + k1 * (1 - b + b * (d.len / avgdl));
76
- score += idf.get(term)! * (numer / denom);
134
+ score += bm25TermScore(f, idf.get(term)!, lengthNorm, k1);
77
135
  }
78
136
  if (score > 0) hits.push({ id: d.id, score });
79
137
  }
@@ -9,8 +9,8 @@
9
9
  export { chunkText } from './chunk.js';
10
10
  export type { Chunk, ChunkOptions } from './chunk.js';
11
11
 
12
- export { bm25Search } from './bm25.js';
13
- export type { Bm25Doc, Bm25Hit, Bm25Options } from './bm25.js';
12
+ export { bm25Search, bm25Idf, bm25LengthNorm, bm25TermScore, BM25_DEFAULT_K1, BM25_DEFAULT_B } from './bm25.js';
13
+ export type { Bm25Doc, Bm25Hit, Bm25Options, Bm25Tokenizer } from './bm25.js';
14
14
 
15
15
  export { reciprocalRankFusion, maximalMarginalRelevance } from './fusion.js';
16
16
  export type { RankedList, FusedHit, MmrCandidate } from './fusion.js';
@@ -0,0 +1,152 @@
1
+ /**
2
+ * telemetry/InstrumentedBridge.ts — one decorator that makes ANY bridge observable.
3
+ *
4
+ * The alternative — instrumenting Anthropic, OpenAI, Vertex, Fetch, Caching and
5
+ * SemanticCaching bridges each in their own way — guarantees six subtly different
6
+ * definitions of "a call". This is a single decorator over the
7
+ * {@link TransformerBridge} port, so a bridge added tomorrow is observable the
8
+ * moment it satisfies the interface, and the caching bridges are measured at the
9
+ * same seam as the provider bridges (which is how the cache-hit rate becomes a
10
+ * real number rather than an anecdote).
11
+ *
12
+ * Token counts come from the provider when the inner bridge reports them via
13
+ * `lastCall`, and are estimated from characters otherwise — with `estimated: true`
14
+ * carried through to the metric snapshot so a dashboard never mixes measured and
15
+ * inferred spend without saying so.
16
+ */
17
+
18
+ import type { TransformerBridge, BridgeGenerateOptions } from '../bridges/TransformerBridge.js';
19
+ import { estimateTokens } from './pricing.js';
20
+ import { Tracer, type Span } from './Tracer.js';
21
+ import type { LlmUsage } from './types.js';
22
+
23
+ export interface InstrumentedBridgeOptions {
24
+ /** Tracer that receives the spans. */
25
+ tracer: Tracer;
26
+ /** Model label used when the inner bridge reports none. Default `'unknown'`. */
27
+ model?: string;
28
+ /** Span name. Default `'llm.generate'`. */
29
+ spanName?: string;
30
+ /**
31
+ * Parent span, so a model call nests under the agent/graph node that made it.
32
+ * A function is accepted for the common case where the parent changes per call.
33
+ */
34
+ parent?: Span | (() => Span | undefined);
35
+ /** Static attributes stamped on every span (tenant, environment, route). */
36
+ attributes?: Record<string, string | number | boolean>;
37
+ /**
38
+ * Token counter, when an exact one is available (e.g. the provider's
39
+ * count_tokens endpoint). Default: ~4 chars per token.
40
+ */
41
+ countTokens?: (text: string) => number;
42
+ }
43
+
44
+ export class InstrumentedBridge implements TransformerBridge {
45
+ readonly supportsStreaming: boolean;
46
+
47
+ private readonly _inner: TransformerBridge;
48
+ private readonly _tracer: Tracer;
49
+ private readonly _model: string;
50
+ private readonly _spanName: string;
51
+ private readonly _parent?: Span | (() => Span | undefined);
52
+ private readonly _attributes: Record<string, string | number | boolean>;
53
+ private readonly _count: (text: string) => number;
54
+
55
+ private _lastCall: { usage?: LlmUsage; cacheHit?: boolean; cacheTier?: string } | undefined;
56
+
57
+ constructor(inner: TransformerBridge, opts: InstrumentedBridgeOptions) {
58
+ this._inner = inner;
59
+ this._tracer = opts.tracer;
60
+ this._model = opts.model ?? 'unknown';
61
+ this._spanName = opts.spanName ?? 'llm.generate';
62
+ if (opts.parent) this._parent = opts.parent;
63
+ this._attributes = opts.attributes ?? {};
64
+ this._count = opts.countTokens ?? estimateTokens;
65
+ this.supportsStreaming = inner.supportsStreaming;
66
+ }
67
+
68
+ /** Usage/cache info for the most recent call — makes the decorator chainable. */
69
+ get lastCall(): { usage?: LlmUsage; cacheHit?: boolean; cacheTier?: string } | undefined {
70
+ return this._lastCall;
71
+ }
72
+
73
+ async generate(prompt: string, opts: BridgeGenerateOptions = {}): Promise<string> {
74
+ const span = this._startSpan(prompt, opts, false);
75
+ try {
76
+ const reply = await this._inner.generate(prompt, opts);
77
+ this._finish(span, prompt, reply, opts);
78
+ span.end('ok');
79
+ return reply;
80
+ } catch (err) {
81
+ span.fail(err);
82
+ throw err;
83
+ }
84
+ }
85
+
86
+ async *stream(prompt: string, opts: BridgeGenerateOptions = {}): AsyncIterable<string> {
87
+ if (!this._inner.stream) {
88
+ throw new Error('InstrumentedBridge: the wrapped bridge does not support streaming.');
89
+ }
90
+ const span = this._startSpan(prompt, opts, true);
91
+ let reply = '';
92
+ let first = true;
93
+ try {
94
+ for await (const chunk of this._inner.stream(prompt, opts)) {
95
+ if (first) {
96
+ span.recordFirstToken();
97
+ first = false;
98
+ }
99
+ reply += chunk;
100
+ yield chunk;
101
+ }
102
+ this._finish(span, prompt, reply, opts);
103
+ span.end('ok');
104
+ } catch (err) {
105
+ span.fail(err);
106
+ throw err;
107
+ }
108
+ }
109
+
110
+ private _startSpan(prompt: string, opts: BridgeGenerateOptions, streaming: boolean): Span {
111
+ const parent = typeof this._parent === 'function' ? this._parent() : this._parent;
112
+ const span = this._tracer.startSpan(this._spanName, {
113
+ kind: 'llm',
114
+ ...(parent ? { parent } : {}),
115
+ });
116
+ span.setAttributes({
117
+ ...this._attributes,
118
+ 'llm.model': opts.model ?? this._model,
119
+ 'llm.streaming': streaming,
120
+ 'llm.prompt_chars': prompt.length,
121
+ 'llm.max_tokens': opts.maxTokens,
122
+ 'llm.temperature': opts.temperature,
123
+ });
124
+ return span;
125
+ }
126
+
127
+ private _finish(span: Span, prompt: string, reply: string, opts: BridgeGenerateOptions): void {
128
+ const reported = (this._inner as { lastCall?: { usage?: LlmUsage; cacheHit?: boolean; cacheTier?: string } }).lastCall;
129
+ const model = reported?.usage?.model ?? opts.model ?? this._model;
130
+
131
+ const usage: LlmUsage = reported?.usage ?? {
132
+ model,
133
+ inputTokens: this._count(prompt),
134
+ outputTokens: this._count(reply),
135
+ estimated: true,
136
+ };
137
+ if (reported?.cacheHit) usage.localCacheHit = true;
138
+
139
+ span.recordUsage(usage);
140
+ span.setAttributes({
141
+ 'llm.response_chars': reply.length,
142
+ 'llm.cache_hit': reported?.cacheHit ?? false,
143
+ 'llm.cache_tier': reported?.cacheTier,
144
+ });
145
+
146
+ this._lastCall = {
147
+ usage,
148
+ ...(reported?.cacheHit !== undefined ? { cacheHit: reported.cacheHit } : {}),
149
+ ...(reported?.cacheTier !== undefined ? { cacheTier: reported.cacheTier } : {}),
150
+ };
151
+ }
152
+ }