better-mastra 0.0.0-stage → 0.0.0

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 (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/dist/embeddings/bounded-promise-cache.d.ts +54 -0
  4. package/dist/embeddings/bounded-promise-cache.d.ts.map +1 -0
  5. package/dist/embeddings/bounded-promise-cache.js +113 -0
  6. package/dist/embeddings/bounded-promise-cache.js.map +1 -0
  7. package/dist/embeddings/embedder.d.ts +50 -0
  8. package/dist/embeddings/embedder.d.ts.map +1 -0
  9. package/dist/embeddings/embedder.js +127 -0
  10. package/dist/embeddings/embedder.js.map +1 -0
  11. package/dist/embeddings/index.d.ts +3 -0
  12. package/dist/embeddings/index.d.ts.map +1 -0
  13. package/dist/embeddings/index.js +3 -0
  14. package/dist/embeddings/index.js.map +1 -0
  15. package/dist/evals/case.d.ts +85 -0
  16. package/dist/evals/case.d.ts.map +1 -0
  17. package/dist/evals/case.js +11 -0
  18. package/dist/evals/case.js.map +1 -0
  19. package/dist/evals/checks.d.ts +63 -0
  20. package/dist/evals/checks.d.ts.map +1 -0
  21. package/dist/evals/checks.js +179 -0
  22. package/dist/evals/checks.js.map +1 -0
  23. package/dist/evals/cli.d.ts +3 -0
  24. package/dist/evals/cli.d.ts.map +1 -0
  25. package/dist/evals/cli.js +162 -0
  26. package/dist/evals/cli.js.map +1 -0
  27. package/dist/evals/config.d.ts +51 -0
  28. package/dist/evals/config.d.ts.map +1 -0
  29. package/dist/evals/config.js +64 -0
  30. package/dist/evals/config.js.map +1 -0
  31. package/dist/evals/index.d.ts +9 -0
  32. package/dist/evals/index.d.ts.map +1 -0
  33. package/dist/evals/index.js +9 -0
  34. package/dist/evals/index.js.map +1 -0
  35. package/dist/evals/judge.d.ts +100 -0
  36. package/dist/evals/judge.d.ts.map +1 -0
  37. package/dist/evals/judge.js +156 -0
  38. package/dist/evals/judge.js.map +1 -0
  39. package/dist/evals/live.d.ts +24 -0
  40. package/dist/evals/live.d.ts.map +1 -0
  41. package/dist/evals/live.js +43 -0
  42. package/dist/evals/live.js.map +1 -0
  43. package/dist/evals/mastra.d.ts +162 -0
  44. package/dist/evals/mastra.d.ts.map +1 -0
  45. package/dist/evals/mastra.js +344 -0
  46. package/dist/evals/mastra.js.map +1 -0
  47. package/dist/evals/report.d.ts +88 -0
  48. package/dist/evals/report.d.ts.map +1 -0
  49. package/dist/evals/report.js +213 -0
  50. package/dist/evals/report.js.map +1 -0
  51. package/dist/evals/run.d.ts +79 -0
  52. package/dist/evals/run.d.ts.map +1 -0
  53. package/dist/evals/run.js +213 -0
  54. package/dist/evals/run.js.map +1 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +2 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/models/anthropic-safe-schema.d.ts +5 -0
  60. package/dist/models/anthropic-safe-schema.d.ts.map +1 -0
  61. package/dist/models/anthropic-safe-schema.js +44 -0
  62. package/dist/models/anthropic-safe-schema.js.map +1 -0
  63. package/dist/models/billing-error.d.ts +13 -0
  64. package/dist/models/billing-error.d.ts.map +1 -0
  65. package/dist/models/billing-error.js +25 -0
  66. package/dist/models/billing-error.js.map +1 -0
  67. package/dist/models/effort.d.ts +17 -0
  68. package/dist/models/effort.d.ts.map +1 -0
  69. package/dist/models/effort.js +12 -0
  70. package/dist/models/effort.js.map +1 -0
  71. package/dist/models/index.d.ts +12 -0
  72. package/dist/models/index.d.ts.map +1 -0
  73. package/dist/models/index.js +11 -0
  74. package/dist/models/index.js.map +1 -0
  75. package/dist/models/openrouter-batch.d.ts +28 -0
  76. package/dist/models/openrouter-batch.d.ts.map +1 -0
  77. package/dist/models/openrouter-batch.js +335 -0
  78. package/dist/models/openrouter-batch.js.map +1 -0
  79. package/dist/models/openrouter-compat.d.ts +37 -0
  80. package/dist/models/openrouter-compat.d.ts.map +1 -0
  81. package/dist/models/openrouter-compat.js +184 -0
  82. package/dist/models/openrouter-compat.js.map +1 -0
  83. package/dist/models/openrouter.d.ts +33 -0
  84. package/dist/models/openrouter.d.ts.map +1 -0
  85. package/dist/models/openrouter.js +54 -0
  86. package/dist/models/openrouter.js.map +1 -0
  87. package/dist/models/pricing.d.ts +38 -0
  88. package/dist/models/pricing.d.ts.map +1 -0
  89. package/dist/models/pricing.js +52 -0
  90. package/dist/models/pricing.js.map +1 -0
  91. package/dist/models/prompt-cache.d.ts +64 -0
  92. package/dist/models/prompt-cache.d.ts.map +1 -0
  93. package/dist/models/prompt-cache.js +143 -0
  94. package/dist/models/prompt-cache.js.map +1 -0
  95. package/dist/models/providers/anthropic.d.ts +19 -0
  96. package/dist/models/providers/anthropic.d.ts.map +1 -0
  97. package/dist/models/providers/anthropic.js +64 -0
  98. package/dist/models/providers/anthropic.js.map +1 -0
  99. package/dist/models/providers/inception.d.ts +13 -0
  100. package/dist/models/providers/inception.d.ts.map +1 -0
  101. package/dist/models/providers/inception.js +48 -0
  102. package/dist/models/providers/inception.js.map +1 -0
  103. package/dist/models/providers/openai.d.ts +11 -0
  104. package/dist/models/providers/openai.d.ts.map +1 -0
  105. package/dist/models/providers/openai.js +46 -0
  106. package/dist/models/providers/openai.js.map +1 -0
  107. package/dist/models/providers/openrouter.d.ts +14 -0
  108. package/dist/models/providers/openrouter.d.ts.map +1 -0
  109. package/dist/models/providers/openrouter.js +65 -0
  110. package/dist/models/providers/openrouter.js.map +1 -0
  111. package/dist/models/providers/requesty.d.ts +11 -0
  112. package/dist/models/providers/requesty.d.ts.map +1 -0
  113. package/dist/models/providers/requesty.js +75 -0
  114. package/dist/models/providers/requesty.js.map +1 -0
  115. package/dist/models/providers/vertex.d.ts +44 -0
  116. package/dist/models/providers/vertex.d.ts.map +1 -0
  117. package/dist/models/providers/vertex.js +56 -0
  118. package/dist/models/providers/vertex.js.map +1 -0
  119. package/dist/models/registry.d.ts +79 -0
  120. package/dist/models/registry.d.ts.map +1 -0
  121. package/dist/models/registry.js +170 -0
  122. package/dist/models/registry.js.map +1 -0
  123. package/dist/models/route.d.ts +51 -0
  124. package/dist/models/route.d.ts.map +1 -0
  125. package/dist/models/route.js +40 -0
  126. package/dist/models/route.js.map +1 -0
  127. package/dist/runtime/health.d.ts +37 -0
  128. package/dist/runtime/health.d.ts.map +1 -0
  129. package/dist/runtime/health.js +37 -0
  130. package/dist/runtime/health.js.map +1 -0
  131. package/dist/runtime/index.d.ts +5 -0
  132. package/dist/runtime/index.d.ts.map +1 -0
  133. package/dist/runtime/index.js +5 -0
  134. package/dist/runtime/index.js.map +1 -0
  135. package/dist/runtime/memory.d.ts +50 -0
  136. package/dist/runtime/memory.d.ts.map +1 -0
  137. package/dist/runtime/memory.js +72 -0
  138. package/dist/runtime/memory.js.map +1 -0
  139. package/dist/runtime/shutdown.d.ts +23 -0
  140. package/dist/runtime/shutdown.d.ts.map +1 -0
  141. package/dist/runtime/shutdown.js +25 -0
  142. package/dist/runtime/shutdown.js.map +1 -0
  143. package/dist/runtime/storage.d.ts +65 -0
  144. package/dist/runtime/storage.d.ts.map +1 -0
  145. package/dist/runtime/storage.js +140 -0
  146. package/dist/runtime/storage.js.map +1 -0
  147. package/dist/runtime/type-bridge.d.ts +9 -0
  148. package/dist/runtime/type-bridge.d.ts.map +1 -0
  149. package/dist/runtime/type-bridge.js +8 -0
  150. package/dist/runtime/type-bridge.js.map +1 -0
  151. package/dist/usage/breakdown.d.ts +38 -0
  152. package/dist/usage/breakdown.d.ts.map +1 -0
  153. package/dist/usage/breakdown.js +88 -0
  154. package/dist/usage/breakdown.js.map +1 -0
  155. package/dist/usage/budget.d.ts +47 -0
  156. package/dist/usage/budget.d.ts.map +1 -0
  157. package/dist/usage/budget.js +188 -0
  158. package/dist/usage/budget.js.map +1 -0
  159. package/dist/usage/generate.d.ts +92 -0
  160. package/dist/usage/generate.d.ts.map +1 -0
  161. package/dist/usage/generate.js +211 -0
  162. package/dist/usage/generate.js.map +1 -0
  163. package/dist/usage/index.d.ts +5 -0
  164. package/dist/usage/index.d.ts.map +1 -0
  165. package/dist/usage/index.js +5 -0
  166. package/dist/usage/index.js.map +1 -0
  167. package/dist/usage/stream.d.ts +25 -0
  168. package/dist/usage/stream.d.ts.map +1 -0
  169. package/dist/usage/stream.js +24 -0
  170. package/dist/usage/stream.js.map +1 -0
  171. package/package.json +217 -5
  172. package/src/client/actions.ts +313 -0
  173. package/src/client/cards.ts +154 -0
  174. package/src/client/chat.ts +187 -0
  175. package/src/client/errors.ts +83 -0
  176. package/src/client/fetch.ts +299 -0
  177. package/src/client/index.ts +12 -0
  178. package/src/client/messages.ts +375 -0
  179. package/src/client/pending.ts +252 -0
  180. package/src/client/ratings.ts +86 -0
  181. package/src/client/reveal.ts +188 -0
  182. package/src/client/screen-context.ts +114 -0
  183. package/src/client/session.ts +950 -0
  184. package/src/client/turn.ts +767 -0
  185. package/src/dictation/adapters.ts +345 -0
  186. package/src/dictation/index.ts +36 -0
  187. package/src/dictation/protocol.ts +157 -0
  188. package/src/dictation/react/index.ts +140 -0
  189. package/src/dictation/server/index.ts +136 -0
  190. package/src/dictation/session.ts +359 -0
  191. package/src/dictation/vue/index.ts +121 -0
  192. package/src/embeddings/bounded-promise-cache.ts +148 -0
  193. package/src/embeddings/embedder.ts +173 -0
  194. package/src/embeddings/index.ts +8 -0
  195. package/src/evals/case.ts +97 -0
  196. package/src/evals/checks.ts +277 -0
  197. package/src/evals/cli.ts +189 -0
  198. package/src/evals/config.ts +120 -0
  199. package/src/evals/index.ts +8 -0
  200. package/src/evals/judge.ts +241 -0
  201. package/src/evals/live.ts +49 -0
  202. package/src/evals/mastra.ts +497 -0
  203. package/src/evals/report.ts +318 -0
  204. package/src/evals/run.ts +312 -0
  205. package/src/hono/chatRoute.ts +139 -0
  206. package/src/hono/index.ts +3 -0
  207. package/src/hono/studio.ts +192 -0
  208. package/src/hono/types.ts +20 -0
  209. package/src/index.ts +1 -0
  210. package/src/models/anthropic-safe-schema.ts +53 -0
  211. package/src/models/billing-error.ts +26 -0
  212. package/src/models/effort.ts +25 -0
  213. package/src/models/index.ts +68 -0
  214. package/src/models/openrouter-batch.ts +431 -0
  215. package/src/models/openrouter-compat.ts +208 -0
  216. package/src/models/openrouter.ts +77 -0
  217. package/src/models/pricing.ts +82 -0
  218. package/src/models/prompt-cache.ts +194 -0
  219. package/src/models/providers/anthropic.ts +77 -0
  220. package/src/models/providers/inception.ts +54 -0
  221. package/src/models/providers/openai.ts +53 -0
  222. package/src/models/providers/openrouter.ts +86 -0
  223. package/src/models/providers/requesty.ts +89 -0
  224. package/src/models/providers/vertex.ts +65 -0
  225. package/src/models/registry.ts +280 -0
  226. package/src/models/route.ts +78 -0
  227. package/src/react/index.ts +244 -0
  228. package/src/react/markdown/index.ts +322 -0
  229. package/src/resumable/index.ts +435 -0
  230. package/src/resumable/redis.ts +138 -0
  231. package/src/runtime/health.ts +79 -0
  232. package/src/runtime/index.ts +20 -0
  233. package/src/runtime/memory.ts +101 -0
  234. package/src/runtime/shutdown.ts +32 -0
  235. package/src/runtime/storage.ts +215 -0
  236. package/src/runtime/type-bridge.ts +10 -0
  237. package/src/tools/cli.ts +36 -0
  238. package/src/tools/clientActions.ts +46 -0
  239. package/src/tools/deferredTools.ts +150 -0
  240. package/src/tools/defineTool.ts +187 -0
  241. package/src/tools/dryRun.ts +40 -0
  242. package/src/tools/index.ts +42 -0
  243. package/src/tools/manifest.ts +53 -0
  244. package/src/tools/projection.ts +33 -0
  245. package/src/tools/toolInputSchema.ts +318 -0
  246. package/src/turn/agent.ts +198 -0
  247. package/src/turn/changes.ts +215 -0
  248. package/src/turn/errors.ts +34 -0
  249. package/src/turn/index.ts +104 -0
  250. package/src/turn/modelArms.ts +68 -0
  251. package/src/turn/observer.ts +160 -0
  252. package/src/turn/processors.ts +158 -0
  253. package/src/turn/promptBudget.ts +61 -0
  254. package/src/turn/promptCache.ts +107 -0
  255. package/src/turn/promptVersion.ts +34 -0
  256. package/src/turn/replySuggestions.ts +135 -0
  257. package/src/turn/requestContext.ts +101 -0
  258. package/src/turn/response.ts +22 -0
  259. package/src/turn/runChatTurn.ts +637 -0
  260. package/src/turn/stream.ts +139 -0
  261. package/src/turn/threads.ts +530 -0
  262. package/src/turn/title.ts +64 -0
  263. package/src/turn/types.ts +96 -0
  264. package/src/usage/breakdown.ts +137 -0
  265. package/src/usage/budget.ts +243 -0
  266. package/src/usage/generate.ts +407 -0
  267. package/src/usage/index.ts +21 -0
  268. package/src/usage/stream.ts +45 -0
  269. package/src/vue/index.ts +258 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hans Kristoffer Hansen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,46 @@
1
- # Temporary Holding Version
1
+ # better-mastra
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small layer on Mastra and the AI SDK: model registry, chat turns, resumable streams and chat clients for React and Vue.
4
+
5
+ Work in progress.
6
+
7
+ ## Resumable streams (`better-mastra/resumable`)
8
+
9
+ Opt-in. A dropped connection no longer aborts the turn: the client reconnects with AI SDK `reconnectToStream` (`useChat({ resume: true })`) and gets a full replay of the current assistant message plus the live tail (`resumeStreamIsReplay: true`, the `DefaultChatTransport` default).
10
+
11
+ ```ts
12
+ import { RedisClient } from 'bun'
13
+ import { bunRedis, createResumableStreams } from 'better-mastra/resumable'
14
+
15
+ const resumable = createResumableStreams({ redis: bunRedis(new RedisClient(process.env.REDIS_URL)) })
16
+
17
+ // POST /chat/:threadId — do not tie the run to request.signal
18
+ const abort = new AbortController()
19
+ const stream = await resumable.start({ threadKey: threadId, stream: uiMessageStream, abort })
20
+
21
+ // GET /chat/:threadId/stream
22
+ const resumed = await resumable.reconnect(threadId, request.signal)
23
+ if (!resumed) return new Response(null, { status: 204 })
24
+
25
+ // POST /chat/:threadId/stop — works from any replica
26
+ await resumable.stop(threadId)
27
+
28
+ // shutdown: waits for active turns (drainTimeoutMs)
29
+ await resumable.close()
30
+ ```
31
+
32
+ Use `ioRedis(client)` for ioredis. Both adapters open extra connections with `duplicate()`: one subscriber per replica, and one per cross-replica reader (for `XREAD BLOCK`).
33
+
34
+ How it works: chunks are buffered in memory (consecutive `text-delta`s merged) and fanned out to subscribers. A per-thread locator `{ streamId, owner }` in Redis says which replica owns the turn. Reconnecting to the owner is served from memory. Reconnecting to another replica publishes a spill request on the owner's control channel; the owner writes its buffer in one `XADD`, then mirrors new chunks in 50 ms batches (first one immediate) until a terminal marker, and the reader follows with `XREAD BLOCK`.
35
+
36
+ Redis ops for a ~1,500-chunk turn:
37
+
38
+ | Design | Redis ops |
39
+ |---|---|
40
+ | Mastra durable agents (`CachingPubSub`: cache write + publish per chunk) | ~3,000–4,500 |
41
+ | Coalesced SETEX + PUBLISH (hellu support-turn) | ~600 |
42
+ | This, no reconnect | 2 (locator SET, final PEXPIRE) + 1 PEXPIRE per 10 s of turn |
43
+ | This, same-replica reconnect | +0 for a running turn, +1 GET for a finished one |
44
+ | This, cross-replica reconnect | +1 PUBLISH, +1 XADD and EXPIRE per 50 ms batch, reader XREADs |
45
+
46
+ Failure behaviour: a buffer over `maxBufferBytes` (2 MB) keeps streaming but new reconnects get 204. If the owner crashes, its locator expires and the cross-replica reader ends with an `error` chunk after `idleTimeoutMs`; the client reloads the thread from Mastra memory. If Redis is down, turns still run and same-replica reconnects work; cross-replica reconnects return 204. Finished turns stay resumable for `graceMs` (30 s).
@@ -0,0 +1,54 @@
1
+ type BoundedPromiseCacheOptions<Key> = {
2
+ /** How long a value is served without triggering any reload. */
3
+ freshMs: number;
4
+ /**
5
+ * Maximum age (from load) a value may be served at all. Between `freshMs`
6
+ * and `staleMs` the stale value is returned while a rebuild runs in the
7
+ * background; past `staleMs` the caller pays the reload inline. Omitted (or
8
+ * `<= freshMs`) makes this a plain TTL cache.
9
+ */
10
+ staleMs?: number;
11
+ maxEntries: number;
12
+ now?: () => number;
13
+ /**
14
+ * Fired when a caller has to wait for a reload because the entry is past
15
+ * `staleMs` - the case a voice first-audio path must never hit.
16
+ */
17
+ onBlockingReload?: (key: Key) => void;
18
+ };
19
+ /**
20
+ * Small in-process promise cache: bounded, shares work already in flight,
21
+ * evicts a rejected first load so the next caller retries, and - when
22
+ * `staleMs > 0` - serves the stale value while it revalidates in the
23
+ * background. A failed background rebuild keeps serving the stale value
24
+ * until the stale window closes.
25
+ */
26
+ export declare class BoundedPromiseCache<Key, Value> {
27
+ private readonly options;
28
+ private readonly entries;
29
+ private now;
30
+ constructor(options: BoundedPromiseCacheOptions<Key>);
31
+ /** Test seam: pin the clock. `null` restores `Date.now`. */
32
+ setNowForTests(now: (() => number) | null): void;
33
+ /** Reports a fresh OR stale entry - anything `getOrLoad` would return without a blocking reload. */
34
+ has(key: Key): boolean;
35
+ /** Drop every entry. Test seam for module-level caches. */
36
+ clear(): void;
37
+ delete(key: Key): void;
38
+ deleteWhere(predicate: (key: Key) => boolean): void;
39
+ getOrLoad(key: Key, load: () => Promise<Value>,
40
+ /**
41
+ * `serveStale: false` takes the reload inline once the fresh window has
42
+ * passed. Serve-stale exists for voice first-audio; on a written channel
43
+ * it only meant an owner's settings change took up to `staleMs` to show
44
+ * up, because invalidation never leaves the process that served it.
45
+ */
46
+ options?: {
47
+ serveStale?: boolean;
48
+ }): Promise<Value>;
49
+ private scheduleRefresh;
50
+ private entryFor;
51
+ private evictIfNeeded;
52
+ }
53
+ export {};
54
+ //# sourceMappingURL=bounded-promise-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bounded-promise-cache.d.ts","sourceRoot":"","sources":["../../src/embeddings/bounded-promise-cache.ts"],"names":[],"mappings":"AAQA,KAAK,0BAA0B,CAAC,GAAG,IAAI;IACtC,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;CACrC,CAAA;AAED;;;;;;GAMG;AACH,qBAAa,mBAAmB,CAAC,GAAG,EAAE,KAAK;IAI9B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAHpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2C;IACnE,OAAO,CAAC,GAAG,CAAc;gBAEI,OAAO,EAAE,0BAA0B,CAAC,GAAG,CAAC;IAIrE,4DAA4D;IAC5D,cAAc,CAAC,GAAG,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,IAAI;IAIzC,oGAAoG;IACpG,GAAG,CAAC,GAAG,EAAE,GAAG;IAKZ,2DAA2D;IAC3D,KAAK;IAIL,MAAM,CAAC,GAAG,EAAE,GAAG;IAIf,WAAW,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO;IAM5C,SAAS,CACR,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC;IAC1B;;;;;OAKG;IACH,OAAO,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,OAAO,CAAA;KAAE,GAChC,OAAO,CAAC,KAAK,CAAC;IA4BjB,OAAO,CAAC,eAAe;IAmBvB,OAAO,CAAC,QAAQ;IAahB,OAAO,CAAC,aAAa;CASrB"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Small in-process promise cache: bounded, shares work already in flight,
3
+ * evicts a rejected first load so the next caller retries, and - when
4
+ * `staleMs > 0` - serves the stale value while it revalidates in the
5
+ * background. A failed background rebuild keeps serving the stale value
6
+ * until the stale window closes.
7
+ */
8
+ export class BoundedPromiseCache {
9
+ options;
10
+ entries = new Map();
11
+ now;
12
+ constructor(options) {
13
+ this.options = options;
14
+ this.now = options.now ?? Date.now;
15
+ }
16
+ /** Test seam: pin the clock. `null` restores `Date.now`. */
17
+ setNowForTests(now) {
18
+ this.now = now ?? this.options.now ?? Date.now;
19
+ }
20
+ /** Reports a fresh OR stale entry - anything `getOrLoad` would return without a blocking reload. */
21
+ has(key) {
22
+ const cached = this.entries.get(key);
23
+ return Boolean(cached && cached.staleUntil > this.now());
24
+ }
25
+ /** Drop every entry. Test seam for module-level caches. */
26
+ clear() {
27
+ this.entries.clear();
28
+ }
29
+ delete(key) {
30
+ this.entries.delete(key);
31
+ }
32
+ deleteWhere(predicate) {
33
+ for (const key of this.entries.keys()) {
34
+ if (predicate(key))
35
+ this.entries.delete(key);
36
+ }
37
+ }
38
+ getOrLoad(key, load,
39
+ /**
40
+ * `serveStale: false` takes the reload inline once the fresh window has
41
+ * passed. Serve-stale exists for voice first-audio; on a written channel
42
+ * it only meant an owner's settings change took up to `staleMs` to show
43
+ * up, because invalidation never leaves the process that served it.
44
+ */
45
+ options) {
46
+ const now = this.now();
47
+ const cached = this.entries.get(key);
48
+ if (cached) {
49
+ if (cached.freshUntil > now)
50
+ return cached.value;
51
+ if (cached.staleUntil > now) {
52
+ if (options?.serveStale !== false) {
53
+ this.scheduleRefresh(key, cached, load);
54
+ return cached.value;
55
+ }
56
+ }
57
+ else {
58
+ // Only a caller that WANTED the stale value is surprised by this.
59
+ this.options.onBlockingReload?.(key);
60
+ }
61
+ this.entries.delete(key);
62
+ }
63
+ this.evictIfNeeded(now);
64
+ const value = load();
65
+ const entry = this.entryFor(now, value);
66
+ this.entries.set(key, entry);
67
+ value.catch(() => {
68
+ if (this.entries.get(key) === entry)
69
+ this.entries.delete(key);
70
+ });
71
+ return value;
72
+ }
73
+ scheduleRefresh(key, cached, load) {
74
+ if (cached.refresh)
75
+ return;
76
+ const refresh = load();
77
+ cached.refresh = refresh;
78
+ refresh
79
+ .then((value) => {
80
+ if (this.entries.get(key) !== cached)
81
+ return;
82
+ this.entries.set(key, this.entryFor(this.now(), Promise.resolve(value)));
83
+ })
84
+ .catch(() => {
85
+ // Keep serving the stale value; the next read past `freshMs` retries.
86
+ if (this.entries.get(key) === cached)
87
+ cached.refresh = null;
88
+ });
89
+ }
90
+ entryFor(now, value) {
91
+ const { freshMs, staleMs = 0 } = this.options;
92
+ return {
93
+ freshUntil: now + freshMs,
94
+ staleUntil: now + Math.max(freshMs, staleMs),
95
+ value,
96
+ refresh: null
97
+ };
98
+ }
99
+ evictIfNeeded(now) {
100
+ if (this.entries.size < this.options.maxEntries)
101
+ return;
102
+ for (const [key, entry] of this.entries) {
103
+ if (entry.staleUntil <= now)
104
+ this.entries.delete(key);
105
+ }
106
+ if (this.entries.size < this.options.maxEntries)
107
+ return;
108
+ const oldest = this.entries.keys().next();
109
+ if (!oldest.done)
110
+ this.entries.delete(oldest.value);
111
+ }
112
+ }
113
+ //# sourceMappingURL=bounded-promise-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bounded-promise-cache.js","sourceRoot":"","sources":["../../src/embeddings/bounded-promise-cache.ts"],"names":[],"mappings":"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","sourcesContent":["type PromiseCacheEntry<Value> = {\n\tfreshUntil: number\n\tstaleUntil: number\n\tvalue: Promise<Value>\n\t/** The background rebuild running for a stale entry, if any. */\n\trefresh: Promise<Value> | null\n}\n\ntype BoundedPromiseCacheOptions<Key> = {\n\t/** How long a value is served without triggering any reload. */\n\tfreshMs: number\n\t/**\n\t * Maximum age (from load) a value may be served at all. Between `freshMs`\n\t * and `staleMs` the stale value is returned while a rebuild runs in the\n\t * background; past `staleMs` the caller pays the reload inline. Omitted (or\n\t * `<= freshMs`) makes this a plain TTL cache.\n\t */\n\tstaleMs?: number\n\tmaxEntries: number\n\tnow?: () => number\n\t/**\n\t * Fired when a caller has to wait for a reload because the entry is past\n\t * `staleMs` - the case a voice first-audio path must never hit.\n\t */\n\tonBlockingReload?: (key: Key) => void\n}\n\n/**\n * Small in-process promise cache: bounded, shares work already in flight,\n * evicts a rejected first load so the next caller retries, and - when\n * `staleMs > 0` - serves the stale value while it revalidates in the\n * background. A failed background rebuild keeps serving the stale value\n * until the stale window closes.\n */\nexport class BoundedPromiseCache<Key, Value> {\n\tprivate readonly entries = new Map<Key, PromiseCacheEntry<Value>>()\n\tprivate now: () => number\n\n\tconstructor(private readonly options: BoundedPromiseCacheOptions<Key>) {\n\t\tthis.now = options.now ?? Date.now\n\t}\n\n\t/** Test seam: pin the clock. `null` restores `Date.now`. */\n\tsetNowForTests(now: (() => number) | null) {\n\t\tthis.now = now ?? this.options.now ?? Date.now\n\t}\n\n\t/** Reports a fresh OR stale entry - anything `getOrLoad` would return without a blocking reload. */\n\thas(key: Key) {\n\t\tconst cached = this.entries.get(key)\n\t\treturn Boolean(cached && cached.staleUntil > this.now())\n\t}\n\n\t/** Drop every entry. Test seam for module-level caches. */\n\tclear() {\n\t\tthis.entries.clear()\n\t}\n\n\tdelete(key: Key) {\n\t\tthis.entries.delete(key)\n\t}\n\n\tdeleteWhere(predicate: (key: Key) => boolean) {\n\t\tfor (const key of this.entries.keys()) {\n\t\t\tif (predicate(key)) this.entries.delete(key)\n\t\t}\n\t}\n\n\tgetOrLoad(\n\t\tkey: Key,\n\t\tload: () => Promise<Value>,\n\t\t/**\n\t\t * `serveStale: false` takes the reload inline once the fresh window has\n\t\t * passed. Serve-stale exists for voice first-audio; on a written channel\n\t\t * it only meant an owner's settings change took up to `staleMs` to show\n\t\t * up, because invalidation never leaves the process that served it.\n\t\t */\n\t\toptions?: { serveStale?: boolean }\n\t): Promise<Value> {\n\t\tconst now = this.now()\n\t\tconst cached = this.entries.get(key)\n\t\tif (cached) {\n\t\t\tif (cached.freshUntil > now) return cached.value\n\t\t\tif (cached.staleUntil > now) {\n\t\t\t\tif (options?.serveStale !== false) {\n\t\t\t\t\tthis.scheduleRefresh(key, cached, load)\n\t\t\t\t\treturn cached.value\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Only a caller that WANTED the stale value is surprised by this.\n\t\t\t\tthis.options.onBlockingReload?.(key)\n\t\t\t}\n\t\t\tthis.entries.delete(key)\n\t\t}\n\n\t\tthis.evictIfNeeded(now)\n\n\t\tconst value = load()\n\t\tconst entry = this.entryFor(now, value)\n\t\tthis.entries.set(key, entry)\n\t\tvalue.catch(() => {\n\t\t\tif (this.entries.get(key) === entry) this.entries.delete(key)\n\t\t})\n\t\treturn value\n\t}\n\n\tprivate scheduleRefresh(\n\t\tkey: Key,\n\t\tcached: PromiseCacheEntry<Value>,\n\t\tload: () => Promise<Value>\n\t) {\n\t\tif (cached.refresh) return\n\t\tconst refresh = load()\n\t\tcached.refresh = refresh\n\t\trefresh\n\t\t\t.then((value) => {\n\t\t\t\tif (this.entries.get(key) !== cached) return\n\t\t\t\tthis.entries.set(key, this.entryFor(this.now(), Promise.resolve(value)))\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\t// Keep serving the stale value; the next read past `freshMs` retries.\n\t\t\t\tif (this.entries.get(key) === cached) cached.refresh = null\n\t\t\t})\n\t}\n\n\tprivate entryFor(\n\t\tnow: number,\n\t\tvalue: Promise<Value>\n\t): PromiseCacheEntry<Value> {\n\t\tconst { freshMs, staleMs = 0 } = this.options\n\t\treturn {\n\t\t\tfreshUntil: now + freshMs,\n\t\t\tstaleUntil: now + Math.max(freshMs, staleMs),\n\t\t\tvalue,\n\t\t\trefresh: null\n\t\t}\n\t}\n\n\tprivate evictIfNeeded(now: number) {\n\t\tif (this.entries.size < this.options.maxEntries) return\n\t\tfor (const [key, entry] of this.entries) {\n\t\t\tif (entry.staleUntil <= now) this.entries.delete(key)\n\t\t}\n\t\tif (this.entries.size < this.options.maxEntries) return\n\t\tconst oldest = this.entries.keys().next()\n\t\tif (!oldest.done) this.entries.delete(oldest.value)\n\t}\n}\n"]}
@@ -0,0 +1,50 @@
1
+ import { type EmbeddingModel } from 'ai';
2
+ import { type Logger } from '../models/route.js';
3
+ /** A shared cache for query embeddings; Redis `get`/`setex` fit as they are. */
4
+ export type EmbeddingCache = {
5
+ get: (key: string) => Promise<string | null>;
6
+ set: (key: string, value: string, ttlSeconds: number) => Promise<unknown>;
7
+ };
8
+ /** Tokens an embedding call used, with the caller's context. */
9
+ export type EmbeddingUsage<TContext> = {
10
+ tokens: number;
11
+ modelId: string;
12
+ context: TContext | undefined;
13
+ };
14
+ /** Case and whitespace do not change what a search query means. */
15
+ export declare function queryEmbeddingCacheKey(modelId: string, query: string, prefix?: string): string;
16
+ /**
17
+ * Query and document embeddings on one model. `embedQuery` is for the hot
18
+ * path: an in-process cache in front of the shared one, and concurrent calls
19
+ * for one query share a single request. `embedMany` is for indexing: batches
20
+ * of `batchSize` with `concurrency` requests at once, and every vector checked
21
+ * before it is returned. Reuse one embedder per model, so every call reuses
22
+ * the provider's connection.
23
+ */
24
+ export declare function createEmbedder<TContext = undefined>(input: {
25
+ model: EmbeddingModel;
26
+ /** Shared query cache (Redis), kept 24 hours. */
27
+ cache?: EmbeddingCache;
28
+ /** Prefix of the shared cache keys. */
29
+ cachePrefix?: string;
30
+ /** Tokens per call that reached the provider; cache hits cost none. */
31
+ usage?: (usage: EmbeddingUsage<TContext>) => void;
32
+ /** Every vector must have this length. */
33
+ dimensions?: number;
34
+ batchSize?: number;
35
+ concurrency?: number;
36
+ logger?: Logger;
37
+ }): {
38
+ modelId: string;
39
+ embedQuery: (value: string, context?: TContext) => Promise<{
40
+ embedding: number[];
41
+ tokens: number;
42
+ cached: boolean;
43
+ }>;
44
+ embedMany: (values: readonly string[], context?: TContext) => Promise<number[][]>;
45
+ prewarm: () => Promise<void>;
46
+ /** Drops the in-process query cache. */
47
+ clearCache: () => void;
48
+ };
49
+ export type Embedder<TContext = undefined> = ReturnType<typeof createEmbedder<TContext>>;
50
+ //# sourceMappingURL=embedder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embedder.d.ts","sourceRoot":"","sources":["../../src/embeddings/embedder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,cAAc,EAAmC,MAAM,IAAI,CAAA;AACzE,OAAO,EAAmB,KAAK,MAAM,EAAgB,MAAM,oBAAoB,CAAA;AAG/E,gFAAgF;AAChF,MAAM,MAAM,cAAc,GAAG;IAC5B,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAC5C,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CACzE,CAAA;AAED,gEAAgE;AAChE,MAAM,MAAM,cAAc,CAAC,QAAQ,IAAI;IACtC,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,QAAQ,GAAG,SAAS,CAAA;CAC7B,CAAA;AAWD,mEAAmE;AACnE,wBAAgB,sBAAsB,CACrC,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,MAAM,SAAS,UAOf;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,QAAQ,GAAG,SAAS,EAAE,KAAK,EAAE;IAC3D,KAAK,EAAE,cAAc,CAAA;IACrB,iDAAiD;IACjD,KAAK,CAAC,EAAE,cAAc,CAAA;IACtB,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,uEAAuE;IACvE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAA;IACjD,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;CACf;;wBA2CiC,MAAM,YAAY,QAAQ;;;;;wBAoB1B,SAAS,MAAM,EAAE,YAAY,QAAQ;;IAyCrE,wCAAwC;;EAGzC;AAED,MAAM,MAAM,QAAQ,CAAC,QAAQ,GAAG,SAAS,IAAI,UAAU,CACtD,OAAO,cAAc,CAAC,QAAQ,CAAC,CAC/B,CAAA"}
@@ -0,0 +1,127 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { embed, embedMany as aiEmbedMany } from 'ai';
3
+ import { errorAttributes, silentLogger } from '../models/route.js';
4
+ import { BoundedPromiseCache } from './bounded-promise-cache.js';
5
+ const SHARED_TTL_SECONDS = 24 * 60 * 60;
6
+ const MEMORY_TTL_MS = 5 * 60 * 1000;
7
+ const MEMORY_MAX_ENTRIES = 500;
8
+ const PREWARM_QUERY = 'warmup';
9
+ function modelIdOf(model) {
10
+ return typeof model === 'string' ? model : model.modelId;
11
+ }
12
+ /** Case and whitespace do not change what a search query means. */
13
+ export function queryEmbeddingCacheKey(modelId, query, prefix = 'qemb') {
14
+ const normalized = query.trim().toLowerCase().replace(/\s+/g, ' ');
15
+ const digest = createHash('sha256')
16
+ .update(`${modelId}:${normalized}`)
17
+ .digest('hex');
18
+ return `${prefix}:${digest}`;
19
+ }
20
+ /**
21
+ * Query and document embeddings on one model. `embedQuery` is for the hot
22
+ * path: an in-process cache in front of the shared one, and concurrent calls
23
+ * for one query share a single request. `embedMany` is for indexing: batches
24
+ * of `batchSize` with `concurrency` requests at once, and every vector checked
25
+ * before it is returned. Reuse one embedder per model, so every call reuses
26
+ * the provider's connection.
27
+ */
28
+ export function createEmbedder(input) {
29
+ const modelId = modelIdOf(input.model);
30
+ const batchSize = input.batchSize ?? 100;
31
+ const concurrency = input.concurrency ?? 3;
32
+ const logger = input.logger ?? silentLogger;
33
+ const memory = new BoundedPromiseCache({
34
+ freshMs: MEMORY_TTL_MS,
35
+ maxEntries: MEMORY_MAX_ENTRIES
36
+ });
37
+ function checked(vector) {
38
+ if (vector.length === 0 ||
39
+ (input.dimensions !== undefined && vector.length !== input.dimensions)) {
40
+ throw new Error(`Embedding has ${vector.length} dimensions, expected ${input.dimensions ?? 'more than 0'}`);
41
+ }
42
+ return vector;
43
+ }
44
+ function report(tokens, context) {
45
+ if (tokens > 0)
46
+ input.usage?.({ tokens, modelId, context });
47
+ }
48
+ async function readShared(key) {
49
+ if (!input.cache)
50
+ return null;
51
+ try {
52
+ const raw = await input.cache.get(key);
53
+ if (!raw)
54
+ return null;
55
+ const parsed = JSON.parse(raw);
56
+ return Array.isArray(parsed) &&
57
+ parsed.length > 0 &&
58
+ parsed.every((value) => typeof value === 'number')
59
+ ? parsed
60
+ : null;
61
+ }
62
+ catch (error) {
63
+ logger.warn('Query embedding cache read failed', errorAttributes(error));
64
+ return null;
65
+ }
66
+ }
67
+ async function embedQuery(value, context) {
68
+ const key = queryEmbeddingCacheKey(modelId, value, input.cachePrefix);
69
+ let tokens = 0;
70
+ let cached = true;
71
+ const embedding = await memory.getOrLoad(key, async () => {
72
+ const shared = await readShared(key);
73
+ if (shared)
74
+ return shared;
75
+ cached = false;
76
+ const result = await embed({ model: input.model, value });
77
+ tokens = result.usage.tokens ?? 0;
78
+ const vector = checked(result.embedding);
79
+ void input.cache
80
+ ?.set(key, JSON.stringify(vector), SHARED_TTL_SECONDS)
81
+ .catch(() => undefined);
82
+ return vector;
83
+ });
84
+ report(tokens, context);
85
+ return { embedding, tokens, cached };
86
+ }
87
+ async function embedMany(values, context) {
88
+ const batches = [];
89
+ for (let start = 0; start < values.length; start += batchSize) {
90
+ batches.push(values.slice(start, start + batchSize));
91
+ }
92
+ const results = new Array(batches.length);
93
+ let next = 0;
94
+ const worker = async () => {
95
+ while (next < batches.length) {
96
+ const index = next++;
97
+ const batch = batches[index];
98
+ const result = await aiEmbedMany({ model: input.model, values: batch });
99
+ if (result.embeddings.length !== batch.length) {
100
+ throw new Error(`Incomplete embedding batch: ${result.embeddings.length} of ${batch.length}`);
101
+ }
102
+ results[index] = result.embeddings.map(checked);
103
+ report(result.usage.tokens ?? 0, context);
104
+ }
105
+ };
106
+ await Promise.all(Array.from({ length: Math.min(concurrency, batches.length) }, worker));
107
+ return results.flat();
108
+ }
109
+ /** Best-effort connection warm, so the first real query skips the handshake. */
110
+ async function prewarm() {
111
+ try {
112
+ await embedQuery(PREWARM_QUERY);
113
+ }
114
+ catch (error) {
115
+ logger.warn('Embedding connection prewarm failed', errorAttributes(error));
116
+ }
117
+ }
118
+ return {
119
+ modelId,
120
+ embedQuery,
121
+ embedMany,
122
+ prewarm,
123
+ /** Drops the in-process query cache. */
124
+ clearCache: () => memory.clear()
125
+ };
126
+ }
127
+ //# sourceMappingURL=embedder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embedder.js","sourceRoot":"","sources":["../../src/embeddings/embedder.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'node:crypto'\nimport { type EmbeddingModel, embed, embedMany as aiEmbedMany } from 'ai'\nimport { errorAttributes, type Logger, silentLogger } from '../models/route.js'\nimport { BoundedPromiseCache } from './bounded-promise-cache.js'\n\n/** A shared cache for query embeddings; Redis `get`/`setex` fit as they are. */\nexport type EmbeddingCache = {\n\tget: (key: string) => Promise<string | null>\n\tset: (key: string, value: string, ttlSeconds: number) => Promise<unknown>\n}\n\n/** Tokens an embedding call used, with the caller's context. */\nexport type EmbeddingUsage<TContext> = {\n\ttokens: number\n\tmodelId: string\n\tcontext: TContext | undefined\n}\n\nconst SHARED_TTL_SECONDS = 24 * 60 * 60\nconst MEMORY_TTL_MS = 5 * 60 * 1000\nconst MEMORY_MAX_ENTRIES = 500\nconst PREWARM_QUERY = 'warmup'\n\nfunction modelIdOf(model: EmbeddingModel) {\n\treturn typeof model === 'string' ? model : model.modelId\n}\n\n/** Case and whitespace do not change what a search query means. */\nexport function queryEmbeddingCacheKey(\n\tmodelId: string,\n\tquery: string,\n\tprefix = 'qemb'\n) {\n\tconst normalized = query.trim().toLowerCase().replace(/\\s+/g, ' ')\n\tconst digest = createHash('sha256')\n\t\t.update(`${modelId}:${normalized}`)\n\t\t.digest('hex')\n\treturn `${prefix}:${digest}`\n}\n\n/**\n * Query and document embeddings on one model. `embedQuery` is for the hot\n * path: an in-process cache in front of the shared one, and concurrent calls\n * for one query share a single request. `embedMany` is for indexing: batches\n * of `batchSize` with `concurrency` requests at once, and every vector checked\n * before it is returned. Reuse one embedder per model, so every call reuses\n * the provider's connection.\n */\nexport function createEmbedder<TContext = undefined>(input: {\n\tmodel: EmbeddingModel\n\t/** Shared query cache (Redis), kept 24 hours. */\n\tcache?: EmbeddingCache\n\t/** Prefix of the shared cache keys. */\n\tcachePrefix?: string\n\t/** Tokens per call that reached the provider; cache hits cost none. */\n\tusage?: (usage: EmbeddingUsage<TContext>) => void\n\t/** Every vector must have this length. */\n\tdimensions?: number\n\tbatchSize?: number\n\tconcurrency?: number\n\tlogger?: Logger\n}) {\n\tconst modelId = modelIdOf(input.model)\n\tconst batchSize = input.batchSize ?? 100\n\tconst concurrency = input.concurrency ?? 3\n\tconst logger = input.logger ?? silentLogger\n\tconst memory = new BoundedPromiseCache<string, number[]>({\n\t\tfreshMs: MEMORY_TTL_MS,\n\t\tmaxEntries: MEMORY_MAX_ENTRIES\n\t})\n\n\tfunction checked(vector: number[]) {\n\t\tif (\n\t\t\tvector.length === 0 ||\n\t\t\t(input.dimensions !== undefined && vector.length !== input.dimensions)\n\t\t) {\n\t\t\tthrow new Error(\n\t\t\t\t`Embedding has ${vector.length} dimensions, expected ${input.dimensions ?? 'more than 0'}`\n\t\t\t)\n\t\t}\n\t\treturn vector\n\t}\n\n\tfunction report(tokens: number, context: TContext | undefined) {\n\t\tif (tokens > 0) input.usage?.({ tokens, modelId, context })\n\t}\n\n\tasync function readShared(key: string) {\n\t\tif (!input.cache) return null\n\t\ttry {\n\t\t\tconst raw = await input.cache.get(key)\n\t\t\tif (!raw) return null\n\t\t\tconst parsed: unknown = JSON.parse(raw)\n\t\t\treturn Array.isArray(parsed) &&\n\t\t\t\tparsed.length > 0 &&\n\t\t\t\tparsed.every((value) => typeof value === 'number')\n\t\t\t\t? (parsed as number[])\n\t\t\t\t: null\n\t\t} catch (error) {\n\t\t\tlogger.warn('Query embedding cache read failed', errorAttributes(error))\n\t\t\treturn null\n\t\t}\n\t}\n\n\tasync function embedQuery(value: string, context?: TContext) {\n\t\tconst key = queryEmbeddingCacheKey(modelId, value, input.cachePrefix)\n\t\tlet tokens = 0\n\t\tlet cached = true\n\t\tconst embedding = await memory.getOrLoad(key, async () => {\n\t\t\tconst shared = await readShared(key)\n\t\t\tif (shared) return shared\n\t\t\tcached = false\n\t\t\tconst result = await embed({ model: input.model, value })\n\t\t\ttokens = result.usage.tokens ?? 0\n\t\t\tconst vector = checked(result.embedding)\n\t\t\tvoid input.cache\n\t\t\t\t?.set(key, JSON.stringify(vector), SHARED_TTL_SECONDS)\n\t\t\t\t.catch(() => undefined)\n\t\t\treturn vector\n\t\t})\n\t\treport(tokens, context)\n\t\treturn { embedding, tokens, cached }\n\t}\n\n\tasync function embedMany(values: readonly string[], context?: TContext) {\n\t\tconst batches: string[][] = []\n\t\tfor (let start = 0; start < values.length; start += batchSize) {\n\t\t\tbatches.push(values.slice(start, start + batchSize))\n\t\t}\n\t\tconst results: number[][][] = new Array(batches.length)\n\t\tlet next = 0\n\t\tconst worker = async () => {\n\t\t\twhile (next < batches.length) {\n\t\t\t\tconst index = next++\n\t\t\t\tconst batch = batches[index]!\n\t\t\t\tconst result = await aiEmbedMany({ model: input.model, values: batch })\n\t\t\t\tif (result.embeddings.length !== batch.length) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Incomplete embedding batch: ${result.embeddings.length} of ${batch.length}`\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tresults[index] = result.embeddings.map(checked)\n\t\t\t\treport(result.usage.tokens ?? 0, context)\n\t\t\t}\n\t\t}\n\t\tawait Promise.all(\n\t\t\tArray.from({ length: Math.min(concurrency, batches.length) }, worker)\n\t\t)\n\t\treturn results.flat()\n\t}\n\n\t/** Best-effort connection warm, so the first real query skips the handshake. */\n\tasync function prewarm() {\n\t\ttry {\n\t\t\tawait embedQuery(PREWARM_QUERY)\n\t\t} catch (error) {\n\t\t\tlogger.warn('Embedding connection prewarm failed', errorAttributes(error))\n\t\t}\n\t}\n\n\treturn {\n\t\tmodelId,\n\t\tembedQuery,\n\t\tembedMany,\n\t\tprewarm,\n\t\t/** Drops the in-process query cache. */\n\t\tclearCache: () => memory.clear()\n\t}\n}\n\nexport type Embedder<TContext = undefined> = ReturnType<\n\ttypeof createEmbedder<TContext>\n>\n"]}
@@ -0,0 +1,3 @@
1
+ export { BoundedPromiseCache } from './bounded-promise-cache.js';
2
+ export { createEmbedder, type Embedder, type EmbeddingCache, type EmbeddingUsage, queryEmbeddingCacheKey } from './embedder.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/embeddings/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAChE,OAAO,EACN,cAAc,EACd,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,sBAAsB,EACtB,MAAM,eAAe,CAAA"}
@@ -0,0 +1,3 @@
1
+ export { BoundedPromiseCache } from './bounded-promise-cache.js';
2
+ export { createEmbedder, queryEmbeddingCacheKey } from './embedder.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/embeddings/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAChE,OAAO,EACN,cAAc,EAId,sBAAsB,EACtB,MAAM,eAAe,CAAA","sourcesContent":["export { BoundedPromiseCache } from './bounded-promise-cache.js'\nexport {\n\tcreateEmbedder,\n\ttype Embedder,\n\ttype EmbeddingCache,\n\ttype EmbeddingUsage,\n\tqueryEmbeddingCacheKey\n} from './embedder.js'\n"]}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * An eval case: one conversation the assistant must handle, sent turn by turn
3
+ * as a persona the app seeds, with what a good answer does. Every bucket
4
+ * should hold cases, so "always acts" and "always asks" both score badly.
5
+ */
6
+ export type EvalCase<Page = unknown, Persona extends string = string, Bucket extends string = string, Suite extends string = string> = {
7
+ id: string;
8
+ suites: readonly Suite[];
9
+ bucket: Bucket;
10
+ /** Who asks: a key into the app's seeded personas. */
11
+ persona: Persona;
12
+ /** The language the user writes in, which the reply must follow. */
13
+ language: string;
14
+ /** Where the conversation starts; the app's turn reads it. */
15
+ page?: Page;
16
+ /**
17
+ * The user's messages, one per turn, in order. A turn with its own page
18
+ * was sent from there: the user moved, or the page's data changed.
19
+ */
20
+ turns: readonly (string | {
21
+ text: string;
22
+ page: Page;
23
+ })[];
24
+ expect: EvalExpectation;
25
+ tags?: readonly string[];
26
+ };
27
+ /** Tool names are matched after the checks' `toolName` normalizer. */
28
+ export type EvalExpectation = {
29
+ /** Every one of these is called. */
30
+ tools?: readonly string[];
31
+ /** At least one of these is called. */
32
+ anyTool?: readonly string[];
33
+ /** None of these is called. */
34
+ forbidTools?: readonly string[];
35
+ /** No write and no approval card at all. */
36
+ noWrites?: true;
37
+ /** The reply names at least one of these (case-insensitive). */
38
+ replyMentionsAny?: readonly string[];
39
+ /** A call to `tool` whose input holds at least one of `anyOf` (case-insensitive). */
40
+ toolInputMentions?: {
41
+ tool: string;
42
+ anyOf: readonly string[];
43
+ };
44
+ /** What a good answer does, in words, for the judge. */
45
+ expectations?: readonly string[];
46
+ };
47
+ /** Identity function that keeps the case's literal types. */
48
+ export declare function defineEvalCase<const C extends EvalCase<unknown>>(testCase: C): C;
49
+ /** The text and page of one case turn; a plain string keeps the case's page. */
50
+ export declare function caseTurn<Page>(testCase: Pick<EvalCase<Page>, 'page'>, turn: EvalCase<Page>['turns'][number]): {
51
+ text: string;
52
+ page: Page | undefined;
53
+ };
54
+ export type EvalToolCall = {
55
+ name: string;
56
+ state: string;
57
+ input: unknown;
58
+ output: unknown;
59
+ errorText: string | null;
60
+ };
61
+ export type EvalTurn = {
62
+ userText: string;
63
+ replyText: string;
64
+ toolCalls: EvalToolCall[];
65
+ /** The turn stopped on a card waiting for the user's Confirm. */
66
+ heldForApproval: string[];
67
+ error: string | null;
68
+ /** Wait until the first word of the first answer, as the user feels it. */
69
+ firstTextMs: number | null;
70
+ durationMs: number;
71
+ steps: number;
72
+ inputTokens: number;
73
+ cacheReadTokens: number;
74
+ cacheWriteTokens: number;
75
+ outputTokens: number;
76
+ costUsd: number | null;
77
+ };
78
+ export type EvalTrace = {
79
+ caseId: string;
80
+ modelId: string;
81
+ /** Facts the turn's instructions held (profile, status line), so the judge does not call them invented. */
82
+ instructionFacts?: unknown;
83
+ turns: EvalTurn[];
84
+ };
85
+ //# sourceMappingURL=case.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"case.d.ts","sourceRoot":"","sources":["../../src/evals/case.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ /** Identity function that keeps the case's literal types. */
2
+ export function defineEvalCase(testCase) {
3
+ return testCase;
4
+ }
5
+ /** The text and page of one case turn; a plain string keeps the case's page. */
6
+ export function caseTurn(testCase, turn) {
7
+ return typeof turn === 'string'
8
+ ? { text: turn, page: testCase.page }
9
+ : { text: turn.text, page: turn.page };
10
+ }
11
+ //# sourceMappingURL=case.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"case.js","sourceRoot":"","sources":["../../src/evals/case.ts"],"names":[],"mappings":"AA+CA,6DAA6D;AAC7D,MAAM,UAAU,cAAc,CAC7B,QAAW;IAEX,OAAO,QAAQ,CAAA;AAChB,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,QAAQ,CACvB,QAAsC,EACtC,IAAqC;IAErC,OAAO,OAAO,IAAI,KAAK,QAAQ;QAC9B,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE;QACrC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAA;AACxC,CAAC","sourcesContent":["/**\n * An eval case: one conversation the assistant must handle, sent turn by turn\n * as a persona the app seeds, with what a good answer does. Every bucket\n * should hold cases, so \"always acts\" and \"always asks\" both score badly.\n */\nexport type EvalCase<\n\tPage = unknown,\n\tPersona extends string = string,\n\tBucket extends string = string,\n\tSuite extends string = string\n> = {\n\tid: string\n\tsuites: readonly Suite[]\n\tbucket: Bucket\n\t/** Who asks: a key into the app's seeded personas. */\n\tpersona: Persona\n\t/** The language the user writes in, which the reply must follow. */\n\tlanguage: string\n\t/** Where the conversation starts; the app's turn reads it. */\n\tpage?: Page\n\t/**\n\t * The user's messages, one per turn, in order. A turn with its own page\n\t * was sent from there: the user moved, or the page's data changed.\n\t */\n\tturns: readonly (string | { text: string; page: Page })[]\n\texpect: EvalExpectation\n\ttags?: readonly string[]\n}\n\n/** Tool names are matched after the checks' `toolName` normalizer. */\nexport type EvalExpectation = {\n\t/** Every one of these is called. */\n\ttools?: readonly string[]\n\t/** At least one of these is called. */\n\tanyTool?: readonly string[]\n\t/** None of these is called. */\n\tforbidTools?: readonly string[]\n\t/** No write and no approval card at all. */\n\tnoWrites?: true\n\t/** The reply names at least one of these (case-insensitive). */\n\treplyMentionsAny?: readonly string[]\n\t/** A call to `tool` whose input holds at least one of `anyOf` (case-insensitive). */\n\ttoolInputMentions?: { tool: string; anyOf: readonly string[] }\n\t/** What a good answer does, in words, for the judge. */\n\texpectations?: readonly string[]\n}\n\n/** Identity function that keeps the case's literal types. */\nexport function defineEvalCase<const C extends EvalCase<unknown>>(\n\ttestCase: C\n): C {\n\treturn testCase\n}\n\n/** The text and page of one case turn; a plain string keeps the case's page. */\nexport function caseTurn<Page>(\n\ttestCase: Pick<EvalCase<Page>, 'page'>,\n\tturn: EvalCase<Page>['turns'][number]\n): { text: string; page: Page | undefined } {\n\treturn typeof turn === 'string'\n\t\t? { text: turn, page: testCase.page }\n\t\t: { text: turn.text, page: turn.page }\n}\n\nexport type EvalToolCall = {\n\tname: string\n\tstate: string\n\tinput: unknown\n\toutput: unknown\n\terrorText: string | null\n}\n\nexport type EvalTurn = {\n\tuserText: string\n\treplyText: string\n\ttoolCalls: EvalToolCall[]\n\t/** The turn stopped on a card waiting for the user's Confirm. */\n\theldForApproval: string[]\n\terror: string | null\n\t/** Wait until the first word of the first answer, as the user feels it. */\n\tfirstTextMs: number | null\n\tdurationMs: number\n\tsteps: number\n\tinputTokens: number\n\tcacheReadTokens: number\n\tcacheWriteTokens: number\n\toutputTokens: number\n\tcostUsd: number | null\n}\n\nexport type EvalTrace = {\n\tcaseId: string\n\tmodelId: string\n\t/** Facts the turn's instructions held (profile, status line), so the judge does not call them invented. */\n\tinstructionFacts?: unknown\n\tturns: EvalTurn[]\n}\n"]}