@ultimat3/ai 11.2.0 → 12.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 (3) hide show
  1. package/CLAUDE.md +11 -0
  2. package/package.json +10 -10
  3. package/src/llm-cache.ts +19 -1
package/CLAUDE.md CHANGED
@@ -80,6 +80,17 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
80
80
  - Semantic scopes are separate cache INSTANCES, never a filter over a shared one — cosine
81
81
  similarity has no notion of a tenant. The instance key carries the prompt hash too, which is
82
82
  what makes a version bump invalidate the cache.
83
+ - **The instance key carries `ctx.locale` too, `As of 2026-08-24`, and it sits with the prompt hash
84
+ rather than with the scope.** A prompt taking `locale` as a var — the reference app's `summarize`
85
+ does, and the model obeys it — differs by ONE token between languages while carrying a whole
86
+ document, so the two rendered prompts are neighbours: measured with this package's own
87
+ `HashEmbedder` over that template, **0.9986**, against the declaration's `threshold: 0.97`. The
88
+ Spanish summary was a hit for an English reader, and the model was never wrong — the cache was.
89
+ No threshold repairs it, because the same number has to keep an honest repeat above it. It is in
90
+ the UNCONDITIONAL half of the key because a `scope` answers "who may share this answer" and a
91
+ locale is part of what the answer IS: an app writing `scope: () => 'global'` is saying its
92
+ callers may read one another's summaries, never that a Spanish one will do. Same rule
93
+ `@ultimat3/render` applies to ISR.
83
94
  - **The default scope is the calling ACTOR, and `scope` receives `{ input, ctx }`** (`As of
84
95
  2026-08`). It defaulted to the literal string `'global'` and took the bare `input`, so the rule
85
96
  in the bullet above was contradicted by the very default that shipped: `cache: { semantic: { ttl:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "11.2.0",
3
+ "version": "12.0.0",
4
4
  "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,14 +32,14 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "11.2.0",
36
- "@ultimat3/cache": "11.2.0",
37
- "@ultimat3/core": "11.2.0",
38
- "@ultimat3/db": "11.2.0",
39
- "@ultimat3/jobs": "11.2.0",
40
- "@ultimat3/money": "11.2.0",
41
- "@ultimat3/policy": "11.2.0",
42
- "@ultimat3/schema": "11.2.0",
43
- "@ultimat3/time": "11.2.0"
35
+ "@ultimat3/action": "12.0.0",
36
+ "@ultimat3/cache": "12.0.0",
37
+ "@ultimat3/core": "12.0.0",
38
+ "@ultimat3/db": "12.0.0",
39
+ "@ultimat3/jobs": "12.0.0",
40
+ "@ultimat3/money": "12.0.0",
41
+ "@ultimat3/policy": "12.0.0",
42
+ "@ultimat3/schema": "12.0.0",
43
+ "@ultimat3/time": "12.0.0"
44
44
  }
45
45
  }
package/src/llm-cache.ts CHANGED
@@ -6,6 +6,13 @@
6
6
  * carries the prompt VERSION as well — which is what makes "editing a prompt requires a version
7
7
  * bump" invalidate the cache: a bumped version reaches a different store, so an old answer cannot
8
8
  * survive a prompt edit no matter how similar the text.
9
+ *
10
+ * It carries the request LOCALE for the same structural reason, `As of 2026-08-24`. A prompt that
11
+ * takes `locale` as a var — the reference app's `summarize` does, and the model obeys it — differs
12
+ * by one token between languages while carrying a whole document, so the two renderings are
13
+ * neighbours: measured with this package's own `HashEmbedder` over that template, **0.9986**
14
+ * against a declared threshold of `0.97`. The Spanish answer was therefore a hit for an English
15
+ * reader. No threshold fixes it, because the same number has to keep an honest repeat above it.
9
16
  */
10
17
 
11
18
  import type { Ctx } from '@ultimat3/core';
@@ -62,6 +69,10 @@ export interface PromptCache {
62
69
  * same reason it is JSON rather than a joined string — an actor id is app data and may carry any
63
70
  * separator, and a value that can spell a boundary can spell somebody else's.
64
71
  *
72
+ * The locale is deliberately NOT here. This function is the DEFAULT a declaration replaces by
73
+ * writing its own `scope`, and the locale has to survive that — so it lives in the unconditional
74
+ * half of the store key, beside the prompt hash.
75
+ *
65
76
  * `ctx.actor` is never absent (`createContext` defaults it to `anonymousActor()`), so every
66
77
  * anonymous caller shares one partition — which is what the anonymous actor already means
67
78
  * everywhere else in the framework.
@@ -92,7 +103,14 @@ export async function openCache<TParsed>(
92
103
  const semantic = args.cache?.semantic;
93
104
  if (semantic === undefined) return undefined;
94
105
  const scope = semantic.scope?.({ input: args.input, ctx: args.ctx }) ?? actorScope(args.ctx);
95
- const store = semanticCacheFor(`${args.prompt.ref}#${args.prompt.hash}::${scope}`);
106
+ // The locale sits with the prompt HASH and not with the scope, on purpose: a scope answers "who
107
+ // may share this answer" and the locale is part of what the answer IS — so a written-down
108
+ // `scope: () => 'global'` is still partitioned by it. An app declaring a shared store is saying
109
+ // its callers may read one another's summaries, never that a Spanish one will do for an English
110
+ // reader. `@ultimat3/render`'s ISR keys by locale for exactly this reason.
111
+ const store = semanticCacheFor(
112
+ `${args.prompt.ref}#${args.prompt.hash}@${args.ctx.locale}::${scope}`,
113
+ );
96
114
  const embedding = Array.from(await embedOne(aiEmbedder(), args.rendered));
97
115
  const ttlMs = semantic.ttl === undefined ? undefined : parseDuration(semantic.ttl);
98
116
  return {