@warlock.js/ai 4.14.0 → 4.16.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 (104) hide show
  1. package/CHANGELOG.md +182 -141
  2. package/cjs/index.cjs +640 -139
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/index.d.mts +3 -3
  5. package/esm/contracts/memory/index.d.mts +1 -1
  6. package/esm/contracts/memory/memory-config.type.d.mts +29 -3
  7. package/esm/contracts/memory/memory-config.type.d.mts.map +1 -1
  8. package/esm/contracts/memory/memory-item.type.d.mts +15 -1
  9. package/esm/contracts/memory/memory-item.type.d.mts.map +1 -1
  10. package/esm/contracts/memory/memory.contract.d.mts +15 -2
  11. package/esm/contracts/memory/memory.contract.d.mts.map +1 -1
  12. package/esm/contracts/memory/recall-options.type.d.mts +12 -0
  13. package/esm/contracts/memory/recall-options.type.d.mts.map +1 -1
  14. package/esm/contracts/orchestrator/index.d.mts +1 -1
  15. package/esm/contracts/orchestrator/orchestrator-config.type.d.mts +38 -1
  16. package/esm/contracts/orchestrator/orchestrator-config.type.d.mts.map +1 -1
  17. package/esm/contracts/orchestrator/orchestrator.contract.d.mts +67 -3
  18. package/esm/contracts/orchestrator/orchestrator.contract.d.mts.map +1 -1
  19. package/esm/contracts/supervisor/supervisor-config.type.d.mts +23 -0
  20. package/esm/contracts/supervisor/supervisor-config.type.d.mts.map +1 -1
  21. package/esm/contracts/team/team-config.type.d.mts +9 -6
  22. package/esm/contracts/team/team-config.type.d.mts.map +1 -1
  23. package/esm/contracts/tool.contract.d.mts +35 -3
  24. package/esm/contracts/tool.contract.d.mts.map +1 -1
  25. package/esm/index.d.mts +8 -7
  26. package/esm/index.mjs +3 -2
  27. package/esm/memory/episodic-memory.mjs +14 -6
  28. package/esm/memory/episodic-memory.mjs.map +1 -1
  29. package/esm/memory/index.d.mts +1 -1
  30. package/esm/memory/memory.d.mts +13 -1
  31. package/esm/memory/memory.d.mts.map +1 -1
  32. package/esm/memory/memory.mjs +41 -7
  33. package/esm/memory/memory.mjs.map +1 -1
  34. package/esm/memory/procedural-memory.mjs +20 -7
  35. package/esm/memory/procedural-memory.mjs.map +1 -1
  36. package/esm/memory/semantic-memory.mjs +27 -10
  37. package/esm/memory/semantic-memory.mjs.map +1 -1
  38. package/esm/memory/working-memory.mjs +70 -13
  39. package/esm/memory/working-memory.mjs.map +1 -1
  40. package/esm/middleware/builtins/semantic-cache.d.mts +46 -1
  41. package/esm/middleware/builtins/semantic-cache.d.mts.map +1 -1
  42. package/esm/middleware/builtins/semantic-cache.mjs +60 -15
  43. package/esm/middleware/builtins/semantic-cache.mjs.map +1 -1
  44. package/esm/middleware/index.d.mts +1 -1
  45. package/esm/mock/index.d.mts +1 -1
  46. package/esm/mock/mock-config.type.d.mts +33 -4
  47. package/esm/mock/mock-config.type.d.mts.map +1 -1
  48. package/esm/mock/mock-model.d.mts +2 -1
  49. package/esm/mock/mock-model.d.mts.map +1 -1
  50. package/esm/mock/mock-model.mjs +5 -4
  51. package/esm/mock/mock-model.mjs.map +1 -1
  52. package/esm/mock/mock-sdk.d.mts +11 -3
  53. package/esm/mock/mock-sdk.d.mts.map +1 -1
  54. package/esm/mock/mock-sdk.mjs.map +1 -1
  55. package/esm/orchestrator/as-tool.d.mts +35 -9
  56. package/esm/orchestrator/as-tool.d.mts.map +1 -1
  57. package/esm/orchestrator/as-tool.mjs +67 -19
  58. package/esm/orchestrator/as-tool.mjs.map +1 -1
  59. package/esm/orchestrator/execution.d.mts.map +1 -1
  60. package/esm/orchestrator/execution.mjs +2 -2
  61. package/esm/orchestrator/execution.mjs.map +1 -1
  62. package/esm/orchestrator/index.d.mts +1 -1
  63. package/esm/orchestrator/index.mjs +1 -1
  64. package/esm/orchestrator/memory.d.mts +41 -5
  65. package/esm/orchestrator/memory.d.mts.map +1 -1
  66. package/esm/orchestrator/memory.mjs +53 -5
  67. package/esm/orchestrator/memory.mjs.map +1 -1
  68. package/esm/planner/plan-schema.d.mts +15 -4
  69. package/esm/planner/plan-schema.d.mts.map +1 -1
  70. package/esm/planner/plan-schema.mjs +27 -16
  71. package/esm/planner/plan-schema.mjs.map +1 -1
  72. package/esm/security/index.mjs +1 -0
  73. package/esm/security/outbound-policy.d.mts +9 -0
  74. package/esm/security/outbound-policy.d.mts.map +1 -1
  75. package/esm/security/outbound-policy.mjs +79 -5
  76. package/esm/security/outbound-policy.mjs.map +1 -1
  77. package/esm/security/outbound-policy.type.d.mts +8 -0
  78. package/esm/security/outbound-policy.type.d.mts.map +1 -1
  79. package/esm/security/safe-merge.d.mts +52 -0
  80. package/esm/security/safe-merge.d.mts.map +1 -0
  81. package/esm/security/safe-merge.mjs +68 -0
  82. package/esm/security/safe-merge.mjs.map +1 -0
  83. package/esm/supervisor/decide.mjs +52 -5
  84. package/esm/supervisor/decide.mjs.map +1 -1
  85. package/esm/supervisor/execution.d.mts +22 -12
  86. package/esm/supervisor/execution.d.mts.map +1 -1
  87. package/esm/supervisor/execution.mjs +47 -24
  88. package/esm/supervisor/execution.mjs.map +1 -1
  89. package/esm/supervisor/supervisor.mjs +4 -0
  90. package/esm/supervisor/supervisor.mjs.map +1 -1
  91. package/llms-full.txt +185 -14
  92. package/llms.txt +5 -4
  93. package/package.json +24 -4
  94. package/skills/README.md +6 -2
  95. package/skills/attach-ai-middleware/SKILL.md +17 -1
  96. package/skills/generate-images/SKILL.md +11 -4
  97. package/skills/rag-loaders-and-stores/SKILL.md +3 -0
  98. package/skills/run-ai-agent/SKILL.md +3 -0
  99. package/skills/run-orchestrator/SKILL.md +6 -1
  100. package/skills/run-planner/SKILL.md +7 -3
  101. package/skills/run-supervisor/SKILL.md +11 -1
  102. package/skills/secure-outbound-requests/SKILL.md +85 -0
  103. package/skills/use-ai-memory/SKILL.md +36 -3
  104. package/skills/use-runtime-skills/SKILL.md +2 -1
@@ -2,6 +2,13 @@ import { deriveMemoryId } from "./derive-id.mjs";
2
2
 
3
3
  //#region ../ai/src/memory/semantic-memory.ts
4
4
  /**
5
+ * Extra candidates pulled from `similar()` on a SCOPED recall before the
6
+ * scope filter runs — the driver's ranking spans every scope in the
7
+ * index, so a bare top-`k` can come back entirely foreign. Mirrors the
8
+ * episodic / procedural tiers' overscan constant.
9
+ */
10
+ const RECALL_OVERSCAN = 5;
11
+ /**
5
12
  * Semantic recall tier (memory core M1).
6
13
  *
7
14
  * Owns: embedding remembered text, writing it to a `@warlock.js/cache`
@@ -36,30 +43,33 @@ var SemanticMemory = class {
36
43
  const value = {
37
44
  id,
38
45
  text: item.text,
46
+ scope: item.scope,
39
47
  metadata: item.metadata
40
48
  };
41
- await this.store.set(this.keyFor(id), value, { vector });
49
+ await this.store.set(this.keyFor(id, item.scope), value, { vector });
42
50
  }
43
51
  /**
44
52
  * Embed `query`, ask the driver for the `k` nearest entries clearing
45
- * `threshold`, and return those within this instance's namespace as
46
- * scored {@link RecalledMemory}. Hits indexed under a different
47
- * namespace (a shared driver) are filtered out.
53
+ * `threshold`, and return those within this instance's namespace AND
54
+ * this call's `scope` as scored {@link RecalledMemory}. Hits indexed
55
+ * under a different namespace (a shared driver) or a different scope
56
+ * (another tenant / session) are filtered out here, before the caller
57
+ * ever sees them — an unscoped recall reads only unscoped entries.
48
58
  */
49
- async recall(query, k, threshold) {
59
+ async recall(query, k, threshold, scope) {
50
60
  const { vector } = await this.embedder.embed(query);
51
61
  const hits = await this.store.similar(vector, {
52
- topK: k,
62
+ topK: scope === void 0 ? k : Math.max(k * RECALL_OVERSCAN, k),
53
63
  threshold
54
64
  });
55
65
  const prefix = `${this.namespace}.`;
56
- return hits.filter((hit) => hit.key.startsWith(prefix)).map((hit) => ({
66
+ return hits.filter((hit) => hit.key.startsWith(prefix) && hit.value?.scope === scope).map((hit) => ({
57
67
  id: hit.value.id,
58
68
  text: hit.value.text,
59
69
  tier: "semantic",
60
70
  score: hit.score,
61
71
  metadata: hit.value.metadata
62
- }));
72
+ })).slice(0, Math.max(0, k));
63
73
  }
64
74
  /** Drop every semantic entry written under this instance's namespace. */
65
75
  async clear() {
@@ -69,9 +79,16 @@ var SemanticMemory = class {
69
79
  * Namespaced key for an entry. The cache's `parseKey` normalizes `:`
70
80
  * to `.`, so a dot separator keeps the prefix used here aligned with
71
81
  * the `hit.key` the driver returns from `similar()`.
82
+ *
83
+ * A scoped entry gets an extra hashed segment so two scopes writing
84
+ * identical text (same derived id) don't overwrite each other; the
85
+ * unscoped key shape is unchanged, so entries written before 4.15.0
86
+ * still resolve. The hash is a write-separation device only — recall
87
+ * authorization is the exact `value.scope` equality check, so even a
88
+ * hash collision cannot widen what a scope can read.
72
89
  */
73
- keyFor(id) {
74
- return `${this.namespace}.${id}`;
90
+ keyFor(id, scope) {
91
+ return scope === void 0 ? `${this.namespace}.${id}` : `${this.namespace}.${deriveMemoryId(scope)}.${id}`;
75
92
  }
76
93
  };
77
94
 
@@ -1 +1 @@
1
- {"version":3,"file":"semantic-memory.mjs","names":[],"sources":["../../../../../../../ai/src/memory/semantic-memory.ts"],"sourcesContent":["import type { CacheDriver, CacheSimilarHit } from \"@warlock.js/cache\";\nimport type { EmbedderContract } from \"../contracts/embedder.contract\";\nimport type {\n MemoryItem,\n RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport { deriveMemoryId } from \"./derive-id\";\n\n/**\n * Shape persisted per semantic memory in the cache driver. The vector\n * itself is stored by the driver's own index (passed via\n * `set({ vector })`), so it is not duplicated in the value.\n */\ntype StoredMemory = {\n id: string;\n text: string;\n metadata?: Record<string, unknown>;\n};\n\n/**\n * Semantic recall tier (memory core M1).\n *\n * Owns: embedding remembered text, writing it to a `@warlock.js/cache`\n * driver with `set({ vector })`, and retrieving by cosine similarity via\n * the driver's `similar()`. Does NOT own: the similarity algorithm or\n * the ANN index — those belong to the cache driver. This mirrors the\n * delegation model of `middleware/builtins/semantic-cache.ts`: memory is\n * embedding-agnostic and store-agnostic, gluing an {@link EmbedderContract}\n * to a {@link CacheDriver}.\n *\n * The driver may be shared across memory instances, so every key carries\n * the configured `namespace` and recall filters hits to that prefix —\n * foreign entries indexed by another instance never leak into a query.\n *\n * Internal to the `memory()` factory — never exported on the package\n * surface.\n */\nexport class SemanticMemory {\n public constructor(\n private readonly embedder: EmbedderContract,\n private readonly store: CacheDriver<any, any>,\n private readonly namespace: string,\n ) {}\n\n /**\n * Embed the item's text and index it under a namespaced, id-derived\n * key. Re-remembering the same id overwrites the prior vector +\n * value (the driver upserts by key).\n */\n public async remember(item: MemoryItem): Promise<void> {\n const id = item.id ?? deriveMemoryId(item.text);\n const { vector } = await this.embedder.embed(item.text);\n\n const value: StoredMemory = {\n id,\n text: item.text,\n metadata: item.metadata,\n };\n\n await this.store.set(this.keyFor(id), value, { vector });\n }\n\n /**\n * Embed `query`, ask the driver for the `k` nearest entries clearing\n * `threshold`, and return those within this instance's namespace as\n * scored {@link RecalledMemory}. Hits indexed under a different\n * namespace (a shared driver) are filtered out.\n */\n public async recall(\n query: string,\n k: number,\n threshold: number,\n ): Promise<RecalledMemory[]> {\n const { vector } = await this.embedder.embed(query);\n\n const hits = await this.store.similar<StoredMemory>(vector, {\n topK: k,\n threshold,\n });\n\n const prefix = `${this.namespace}.`;\n\n return hits\n .filter((hit: CacheSimilarHit<StoredMemory>) =>\n hit.key.startsWith(prefix),\n )\n .map((hit: CacheSimilarHit<StoredMemory>) => ({\n id: hit.value.id,\n text: hit.value.text,\n tier: \"semantic\" as const,\n score: hit.score,\n metadata: hit.value.metadata,\n }));\n }\n\n /** Drop every semantic entry written under this instance's namespace. */\n public async clear(): Promise<void> {\n await this.store.removeNamespace(this.namespace);\n }\n\n /**\n * Namespaced key for an entry. The cache's `parseKey` normalizes `:`\n * to `.`, so a dot separator keeps the prefix used here aligned with\n * the `hit.key` the driver returns from `similar()`.\n */\n private keyFor(id: string): string {\n return `${this.namespace}.${id}`;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAqCA,IAAa,iBAAb,MAA4B;CAC1B,AAAO,YACL,AAAiB,UACjB,AAAiB,OACjB,AAAiB,WACjB;EAHiB;EACA;EACA;CAChB;;;;;;CAOH,MAAa,SAAS,MAAiC;EACrD,MAAM,KAAK,KAAK,MAAM,eAAe,KAAK,IAAI;EAC9C,MAAM,EAAE,WAAW,MAAM,KAAK,SAAS,MAAM,KAAK,IAAI;EAEtD,MAAM,QAAsB;GAC1B;GACA,MAAM,KAAK;GACX,UAAU,KAAK;EACjB;EAEA,MAAM,KAAK,MAAM,IAAI,KAAK,OAAO,EAAE,GAAG,OAAO,EAAE,OAAO,CAAC;CACzD;;;;;;;CAQA,MAAa,OACX,OACA,GACA,WAC2B;EAC3B,MAAM,EAAE,WAAW,MAAM,KAAK,SAAS,MAAM,KAAK;EAElD,MAAM,OAAO,MAAM,KAAK,MAAM,QAAsB,QAAQ;GAC1D,MAAM;GACN;EACF,CAAC;EAED,MAAM,SAAS,GAAG,KAAK,UAAU;EAEjC,OAAO,KACJ,QAAQ,QACP,IAAI,IAAI,WAAW,MAAM,CAC3B,CAAC,CACA,KAAK,SAAwC;GAC5C,IAAI,IAAI,MAAM;GACd,MAAM,IAAI,MAAM;GAChB,MAAM;GACN,OAAO,IAAI;GACX,UAAU,IAAI,MAAM;EACtB,EAAE;CACN;;CAGA,MAAa,QAAuB;EAClC,MAAM,KAAK,MAAM,gBAAgB,KAAK,SAAS;CACjD;;;;;;CAOA,AAAQ,OAAO,IAAoB;EACjC,OAAO,GAAG,KAAK,UAAU,GAAG;CAC9B;AACF"}
1
+ {"version":3,"file":"semantic-memory.mjs","names":[],"sources":["../../../../../../../ai/src/memory/semantic-memory.ts"],"sourcesContent":["import type { CacheDriver, CacheSimilarHit } from \"@warlock.js/cache\";\nimport type { EmbedderContract } from \"../contracts/embedder.contract\";\nimport type {\n MemoryItem,\n RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport { deriveMemoryId } from \"./derive-id\";\n\n/**\n * Extra candidates pulled from `similar()` on a SCOPED recall before the\n * scope filter runs — the driver's ranking spans every scope in the\n * index, so a bare top-`k` can come back entirely foreign. Mirrors the\n * episodic / procedural tiers' overscan constant.\n */\nconst RECALL_OVERSCAN = 5;\n\n/**\n * Shape persisted per semantic memory in the cache driver. The vector\n * itself is stored by the driver's own index (passed via\n * `set({ vector })`), so it is not duplicated in the value.\n */\ntype StoredMemory = {\n id: string;\n text: string;\n /** Isolation key the entry was written under; absent = the shared pool. */\n scope?: string;\n metadata?: Record<string, unknown>;\n};\n\n/**\n * Semantic recall tier (memory core M1).\n *\n * Owns: embedding remembered text, writing it to a `@warlock.js/cache`\n * driver with `set({ vector })`, and retrieving by cosine similarity via\n * the driver's `similar()`. Does NOT own: the similarity algorithm or\n * the ANN index — those belong to the cache driver. This mirrors the\n * delegation model of `middleware/builtins/semantic-cache.ts`: memory is\n * embedding-agnostic and store-agnostic, gluing an {@link EmbedderContract}\n * to a {@link CacheDriver}.\n *\n * The driver may be shared across memory instances, so every key carries\n * the configured `namespace` and recall filters hits to that prefix —\n * foreign entries indexed by another instance never leak into a query.\n *\n * Internal to the `memory()` factory — never exported on the package\n * surface.\n */\nexport class SemanticMemory {\n public constructor(\n private readonly embedder: EmbedderContract,\n private readonly store: CacheDriver<any, any>,\n private readonly namespace: string,\n ) {}\n\n /**\n * Embed the item's text and index it under a namespaced, id-derived\n * key. Re-remembering the same id overwrites the prior vector +\n * value (the driver upserts by key).\n */\n public async remember(item: MemoryItem): Promise<void> {\n const id = item.id ?? deriveMemoryId(item.text);\n const { vector } = await this.embedder.embed(item.text);\n\n const value: StoredMemory = {\n id,\n text: item.text,\n scope: item.scope,\n metadata: item.metadata,\n };\n\n await this.store.set(this.keyFor(id, item.scope), value, { vector });\n }\n\n /**\n * Embed `query`, ask the driver for the `k` nearest entries clearing\n * `threshold`, and return those within this instance's namespace AND\n * this call's `scope` as scored {@link RecalledMemory}. Hits indexed\n * under a different namespace (a shared driver) or a different scope\n * (another tenant / session) are filtered out here, before the caller\n * ever sees them — an unscoped recall reads only unscoped entries.\n */\n public async recall(\n query: string,\n k: number,\n threshold: number,\n scope?: string,\n ): Promise<RecalledMemory[]> {\n const { vector } = await this.embedder.embed(query);\n\n // A scoped recall overscans: the driver ranks across every scope in\n // the index, so a plain top-`k` could be filled entirely by foreign\n // scopes and starve this one. Pull extra candidates, filter, then cap.\n const hits = await this.store.similar<StoredMemory>(vector, {\n topK: scope === undefined ? k : Math.max(k * RECALL_OVERSCAN, k),\n threshold,\n });\n\n const prefix = `${this.namespace}.`;\n\n return hits\n .filter(\n (hit: CacheSimilarHit<StoredMemory>) =>\n hit.key.startsWith(prefix) && hit.value?.scope === scope,\n )\n .map((hit: CacheSimilarHit<StoredMemory>) => ({\n id: hit.value.id,\n text: hit.value.text,\n tier: \"semantic\" as const,\n score: hit.score,\n metadata: hit.value.metadata,\n }))\n .slice(0, Math.max(0, k));\n }\n\n /** Drop every semantic entry written under this instance's namespace. */\n public async clear(): Promise<void> {\n await this.store.removeNamespace(this.namespace);\n }\n\n /**\n * Namespaced key for an entry. The cache's `parseKey` normalizes `:`\n * to `.`, so a dot separator keeps the prefix used here aligned with\n * the `hit.key` the driver returns from `similar()`.\n *\n * A scoped entry gets an extra hashed segment so two scopes writing\n * identical text (same derived id) don't overwrite each other; the\n * unscoped key shape is unchanged, so entries written before 4.15.0\n * still resolve. The hash is a write-separation device only — recall\n * authorization is the exact `value.scope` equality check, so even a\n * hash collision cannot widen what a scope can read.\n */\n private keyFor(id: string, scope?: string): string {\n return scope === undefined\n ? `${this.namespace}.${id}`\n : `${this.namespace}.${deriveMemoryId(scope)}.${id}`;\n }\n}\n"],"mappings":";;;;;;;;;AAcA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;AAiCxB,IAAa,iBAAb,MAA4B;CAC1B,AAAO,YACL,AAAiB,UACjB,AAAiB,OACjB,AAAiB,WACjB;EAHiB;EACA;EACA;CAChB;;;;;;CAOH,MAAa,SAAS,MAAiC;EACrD,MAAM,KAAK,KAAK,MAAM,eAAe,KAAK,IAAI;EAC9C,MAAM,EAAE,WAAW,MAAM,KAAK,SAAS,MAAM,KAAK,IAAI;EAEtD,MAAM,QAAsB;GAC1B;GACA,MAAM,KAAK;GACX,OAAO,KAAK;GACZ,UAAU,KAAK;EACjB;EAEA,MAAM,KAAK,MAAM,IAAI,KAAK,OAAO,IAAI,KAAK,KAAK,GAAG,OAAO,EAAE,OAAO,CAAC;CACrE;;;;;;;;;CAUA,MAAa,OACX,OACA,GACA,WACA,OAC2B;EAC3B,MAAM,EAAE,WAAW,MAAM,KAAK,SAAS,MAAM,KAAK;EAKlD,MAAM,OAAO,MAAM,KAAK,MAAM,QAAsB,QAAQ;GAC1D,MAAM,UAAU,SAAY,IAAI,KAAK,IAAI,IAAI,iBAAiB,CAAC;GAC/D;EACF,CAAC;EAED,MAAM,SAAS,GAAG,KAAK,UAAU;EAEjC,OAAO,KACJ,QACE,QACC,IAAI,IAAI,WAAW,MAAM,KAAK,IAAI,OAAO,UAAU,KACvD,CAAC,CACA,KAAK,SAAwC;GAC5C,IAAI,IAAI,MAAM;GACd,MAAM,IAAI,MAAM;GAChB,MAAM;GACN,OAAO,IAAI;GACX,UAAU,IAAI,MAAM;EACtB,EAAE,CAAC,CACF,MAAM,GAAG,KAAK,IAAI,GAAG,CAAC,CAAC;CAC5B;;CAGA,MAAa,QAAuB;EAClC,MAAM,KAAK,MAAM,gBAAgB,KAAK,SAAS;CACjD;;;;;;;;;;;;;CAcA,AAAQ,OAAO,IAAY,OAAwB;EACjD,OAAO,UAAU,SACb,GAAG,KAAK,UAAU,GAAG,OACrB,GAAG,KAAK,UAAU,GAAG,eAAe,KAAK,EAAE,GAAG;CACpD;AACF"}
@@ -15,47 +15,104 @@ import { deriveMemoryId } from "./derive-id.mjs";
15
15
  * first, each scored on a `[0, 1]` recency proxy so a caller can merge
16
16
  * working hits with semantic hits and sort on one `score` field.
17
17
  *
18
+ * **Bounded (4.15.0).** The buffer holds at most `maxItems` entries
19
+ * across every scope; the oldest-written entry is evicted on overflow
20
+ * (FIFO). The tier lives in process memory for the lifetime of the
21
+ * `memory()` instance — which the orchestrator resolves once and reuses
22
+ * for every session — so an unbounded buffer was a memory-exhaustion
23
+ * vector for any long-lived, internet-reachable deployment.
24
+ *
18
25
  * Internal to the `memory()` factory — never exported on the package
19
26
  * surface.
20
27
  */
21
28
  var WorkingMemory = class {
22
- constructor() {
29
+ constructor(maxItems) {
23
30
  this.entries = /* @__PURE__ */ new Map();
31
+ this.maxItems = maxItems;
24
32
  }
25
33
  /**
26
34
  * Append an item to the buffer (or overwrite the entry sharing its
27
- * id). Re-inserting an existing id keeps its original position; delete
28
- * + set would move it to the end and lie about recency, so the value
29
- * is updated in place.
35
+ * id *within the same scope*). Re-inserting an existing key keeps its
36
+ * original position; delete + set would move it to the end and lie
37
+ * about recency, so the value is updated in place.
38
+ *
39
+ * Overflowing `maxItems` evicts from the front — see
40
+ * {@link evictOverflow}.
30
41
  */
31
42
  remember(item) {
32
43
  const id = item.id ?? deriveMemoryId(item.text);
33
- this.entries.set(id, {
44
+ this.entries.set(scopedKey(item.scope, id), {
45
+ id,
34
46
  text: item.text,
47
+ scope: item.scope,
35
48
  metadata: item.metadata
36
49
  });
50
+ this.evictOverflow();
51
+ }
52
+ /**
53
+ * Enforce the size bound by dropping oldest-written entries first
54
+ * (FIFO over the `Map`'s insertion order).
55
+ *
56
+ * **Why FIFO, not LRU.** Recall here is a pure recency proxy — it
57
+ * reverses insertion order and slices the newest `k` — and never
58
+ * reorders anything, so the front of the buffer is by construction the
59
+ * region recall reaches last. FIFO therefore evicts exactly the
60
+ * entries a bounded recall would never have returned. True LRU would
61
+ * need read-time reordering, which would also rewrite the `score`
62
+ * every recall reports (a re-read entry would masquerade as freshly
63
+ * remembered), trading a real correctness property for no gain.
64
+ *
65
+ * **Known limitation (documented, not a regression).** The bound is
66
+ * global, not per-scope: a session writing heavily can push another
67
+ * session's older entries out of the buffer. That is a recall-quality
68
+ * degradation on a volatile scratch tier, never a disclosure — the
69
+ * scope filter in {@link recall} still applies — and a per-scope quota
70
+ * would not help anyway, since an attacker holding many sessions
71
+ * evicts through the global bound regardless. Durable recall belongs
72
+ * in the semantic / episodic tiers.
73
+ */
74
+ evictOverflow() {
75
+ while (this.entries.size > this.maxItems) {
76
+ const oldest = this.entries.keys().next();
77
+ if (oldest.done) return;
78
+ this.entries.delete(oldest.value);
79
+ }
37
80
  }
38
81
  /**
39
- * Return up to `k` most-recently-remembered items, newest first. The
40
- * `score` is a linear recency proxy: the newest item scores `1`, the
41
- * oldest of the returned slice trends toward `0`. Working memory
82
+ * Return up to `k` most-recently-remembered items *within `scope`*,
83
+ * newest first. The scope match is exact equality (an unscoped recall
84
+ * sees only unscoped entries) and is applied BEFORE the slice, so a
85
+ * foreign scope's entries can never consume a slot or leak out.
86
+ *
87
+ * The `score` is a linear recency proxy: the newest item scores `1`,
88
+ * the oldest of the returned slice trends toward `0`. Working memory
42
89
  * ignores any similarity threshold — it has no vector to compare.
43
90
  */
44
- recall(k) {
45
- const slice = [...this.entries.entries()].reverse().slice(0, Math.max(0, k));
46
- return slice.map(([id, entry], index) => ({
47
- id,
91
+ recall(k, scope) {
92
+ const slice = [...this.entries.values()].reverse().filter((entry) => entry.scope === scope).slice(0, Math.max(0, k));
93
+ return slice.map((entry, index) => ({
94
+ id: entry.id,
48
95
  text: entry.text,
49
96
  tier: "working",
50
97
  score: slice.length <= 1 ? 1 : 1 - index / slice.length,
51
98
  metadata: entry.metadata
52
99
  }));
53
100
  }
54
- /** Drop every working-tier entry. */
101
+ /** Drop every working-tier entry, across every scope. */
55
102
  clear() {
56
103
  this.entries.clear();
57
104
  }
58
105
  };
106
+ /**
107
+ * Map key for a buffer entry: the isolation `scope` (empty for the
108
+ * unscoped pool) length-prefixed and joined to the logical id. The
109
+ * length prefix makes the encoding injective — no crafted scope/id pair
110
+ * can collide with a different scope's entry the way a plain `:` join
111
+ * would allow.
112
+ */
113
+ function scopedKey(scope, id) {
114
+ return `${scope?.length ?? 0}:${scope ?? ""}:${id}`;
115
+ }
59
116
 
60
117
  //#endregion
61
118
  export { WorkingMemory };
@@ -1 +1 @@
1
- {"version":3,"file":"working-memory.mjs","names":[],"sources":["../../../../../../../ai/src/memory/working-memory.ts"],"sourcesContent":["import type {\n MemoryItem,\n RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport { deriveMemoryId } from \"./derive-id\";\n\n/**\n * In-run working memory — the volatile scratch tier (memory core M1).\n *\n * Owns: an insertion-ordered buffer of remembered items keyed by id,\n * with overwrite-in-place on a repeated id. Does NOT own: durability,\n * cross-process sharing, embeddings, or similarity — working memory is\n * a plain in-process buffer the orchestrator threads across the turns of\n * a single run.\n *\n * Recall here is not semantic: with no vector index, \"relevant\" reduces\n * to \"recent.\" `recall()` returns the most-recently-remembered items\n * first, each scored on a `[0, 1]` recency proxy so a caller can merge\n * working hits with semantic hits and sort on one `score` field.\n *\n * Internal to the `memory()` factory — never exported on the package\n * surface.\n */\nexport class WorkingMemory {\n /**\n * Idtext/metadata. A `Map` preserves insertion order, so iteration\n * yields oldest-first; recall reverses it for most-recent-first.\n */\n private readonly entries = new Map<\n string,\n { text: string; metadata?: Record<string, unknown> }\n >();\n\n /**\n * Append an item to the buffer (or overwrite the entry sharing its\n * id). Re-inserting an existing id keeps its original position; delete\n * + set would move it to the end and lie about recency, so the value\n * is updated in place.\n */\n public remember(item: MemoryItem): void {\n const id = item.id ?? deriveMemoryId(item.text);\n\n this.entries.set(id, { text: item.text, metadata: item.metadata });\n }\n\n /**\n * Return up to `k` most-recently-remembered items, newest first. The\n * `score` is a linear recency proxy: the newest item scores `1`, the\n * oldest of the returned slice trends toward `0`. Working memory\n * ignores any similarity threshold — it has no vector to compare.\n */\n public recall(k: number): RecalledMemory[] {\n const ordered = [...this.entries.entries()].reverse();\n const slice = ordered.slice(0, Math.max(0, k));\n\n return slice.map(([id, entry], index) => ({\n id,\n text: entry.text,\n tier: \"working\" as const,\n score: slice.length <= 1 ? 1 : 1 - index / slice.length,\n metadata: entry.metadata,\n }));\n }\n\n /** Drop every working-tier entry. */\n public clear(): void {\n this.entries.clear();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAuBA,IAAa,gBAAb,MAA2B;;iCAKE,IAAI,IAG7B;;;;;;;;CAQF,AAAO,SAAS,MAAwB;EACtC,MAAM,KAAK,KAAK,MAAM,eAAe,KAAK,IAAI;EAE9C,KAAK,QAAQ,IAAI,IAAI;GAAE,MAAM,KAAK;GAAM,UAAU,KAAK;EAAS,CAAC;CACnE;;;;;;;CAQA,AAAO,OAAO,GAA6B;EAEzC,MAAM,QADU,CAAC,GAAG,KAAK,QAAQ,QAAQ,CAAC,CAAC,CAAC,QACxB,CAAC,CAAC,MAAM,GAAG,KAAK,IAAI,GAAG,CAAC,CAAC;EAE7C,OAAO,MAAM,KAAK,CAAC,IAAI,QAAQ,WAAW;GACxC;GACA,MAAM,MAAM;GACZ,MAAM;GACN,OAAO,MAAM,UAAU,IAAI,IAAI,IAAI,QAAQ,MAAM;GACjD,UAAU,MAAM;EAClB,EAAE;CACJ;;CAGA,AAAO,QAAc;EACnB,KAAK,QAAQ,MAAM;CACrB;AACF"}
1
+ {"version":3,"file":"working-memory.mjs","names":[],"sources":["../../../../../../../ai/src/memory/working-memory.ts"],"sourcesContent":["import type {\n MemoryItem,\n RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport { deriveMemoryId } from \"./derive-id\";\n\n/**\n * In-run working memory — the volatile scratch tier (memory core M1).\n *\n * Owns: an insertion-ordered buffer of remembered items keyed by id,\n * with overwrite-in-place on a repeated id. Does NOT own: durability,\n * cross-process sharing, embeddings, or similarity — working memory is\n * a plain in-process buffer the orchestrator threads across the turns of\n * a single run.\n *\n * Recall here is not semantic: with no vector index, \"relevant\" reduces\n * to \"recent.\" `recall()` returns the most-recently-remembered items\n * first, each scored on a `[0, 1]` recency proxy so a caller can merge\n * working hits with semantic hits and sort on one `score` field.\n *\n * **Bounded (4.15.0).** The buffer holds at most `maxItems` entries\n * across every scope; the oldest-written entry is evicted on overflow\n * (FIFO). The tier lives in process memory for the lifetime of the\n * `memory()` instance — which the orchestrator resolves once and reuses\n * for every session — so an unbounded buffer was a memory-exhaustion\n * vector for any long-lived, internet-reachable deployment.\n *\n * Internal to the `memory()` factory — never exported on the package\n * surface.\n */\nexport class WorkingMemory {\n /**\n * Hard ceiling on buffered entries, across all scopes. Enforced on\n * every `remember()`; see {@link evictOverflow} for the policy.\n */\n private readonly maxItems: number;\n\n public constructor(maxItems: number) {\n this.maxItems = maxItems;\n }\n\n /**\n * Scoped key entry. A `Map` preserves insertion order, so iteration\n * yields oldest-first; recall reverses it for most-recent-first.\n *\n * The map key folds in the item's `scope` so two scopes remembering\n * identical text (same derived id) stay two independent entries\n * instead of clobbering one another; the entry keeps its logical `id`\n * and its `scope` so recall can filter and still report the id the\n * caller knows.\n */\n private readonly entries = new Map<\n string,\n {\n id: string;\n text: string;\n scope?: string;\n metadata?: Record<string, unknown>;\n }\n >();\n\n /**\n * Append an item to the buffer (or overwrite the entry sharing its\n * id *within the same scope*). Re-inserting an existing key keeps its\n * original position; delete + set would move it to the end and lie\n * about recency, so the value is updated in place.\n *\n * Overflowing `maxItems` evicts from the front — see\n * {@link evictOverflow}.\n */\n public remember(item: MemoryItem): void {\n const id = item.id ?? deriveMemoryId(item.text);\n\n this.entries.set(scopedKey(item.scope, id), {\n id,\n text: item.text,\n scope: item.scope,\n metadata: item.metadata,\n });\n\n this.evictOverflow();\n }\n\n /**\n * Enforce the size bound by dropping oldest-written entries first\n * (FIFO over the `Map`'s insertion order).\n *\n * **Why FIFO, not LRU.** Recall here is a pure recency proxy — it\n * reverses insertion order and slices the newest `k` — and never\n * reorders anything, so the front of the buffer is by construction the\n * region recall reaches last. FIFO therefore evicts exactly the\n * entries a bounded recall would never have returned. True LRU would\n * need read-time reordering, which would also rewrite the `score`\n * every recall reports (a re-read entry would masquerade as freshly\n * remembered), trading a real correctness property for no gain.\n *\n * **Known limitation (documented, not a regression).** The bound is\n * global, not per-scope: a session writing heavily can push another\n * session's older entries out of the buffer. That is a recall-quality\n * degradation on a volatile scratch tier, never a disclosure — the\n * scope filter in {@link recall} still applies — and a per-scope quota\n * would not help anyway, since an attacker holding many sessions\n * evicts through the global bound regardless. Durable recall belongs\n * in the semantic / episodic tiers.\n */\n private evictOverflow(): void {\n while (this.entries.size > this.maxItems) {\n const oldest = this.entries.keys().next();\n\n if (oldest.done) {\n return;\n }\n\n this.entries.delete(oldest.value);\n }\n }\n\n /**\n * Return up to `k` most-recently-remembered items *within `scope`*,\n * newest first. The scope match is exact equality (an unscoped recall\n * sees only unscoped entries) and is applied BEFORE the slice, so a\n * foreign scope's entries can never consume a slot or leak out.\n *\n * The `score` is a linear recency proxy: the newest item scores `1`,\n * the oldest of the returned slice trends toward `0`. Working memory\n * ignores any similarity threshold — it has no vector to compare.\n */\n public recall(k: number, scope?: string): RecalledMemory[] {\n const ordered = [...this.entries.values()]\n .reverse()\n .filter((entry) => entry.scope === scope);\n\n const slice = ordered.slice(0, Math.max(0, k));\n\n return slice.map((entry, index) => ({\n id: entry.id,\n text: entry.text,\n tier: \"working\" as const,\n score: slice.length <= 1 ? 1 : 1 - index / slice.length,\n metadata: entry.metadata,\n }));\n }\n\n /** Drop every working-tier entry, across every scope. */\n public clear(): void {\n this.entries.clear();\n }\n}\n\n/**\n * Map key for a buffer entry: the isolation `scope` (empty for the\n * unscoped pool) length-prefixed and joined to the logical id. The\n * length prefix makes the encoding injective — no crafted scope/id pair\n * can collide with a different scope's entry the way a plain `:` join\n * would allow.\n */\nfunction scopedKey(scope: string | undefined, id: string): string {\n return `${scope?.length ?? 0}:${scope ?? \"\"}:${id}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,IAAa,gBAAb,MAA2B;CAOzB,AAAO,YAAY,UAAkB;iCAcV,IAAI,IAQ7B;EArBA,KAAK,WAAW;CAClB;;;;;;;;;;CA+BA,AAAO,SAAS,MAAwB;EACtC,MAAM,KAAK,KAAK,MAAM,eAAe,KAAK,IAAI;EAE9C,KAAK,QAAQ,IAAI,UAAU,KAAK,OAAO,EAAE,GAAG;GAC1C;GACA,MAAM,KAAK;GACX,OAAO,KAAK;GACZ,UAAU,KAAK;EACjB,CAAC;EAED,KAAK,cAAc;CACrB;;;;;;;;;;;;;;;;;;;;;;;CAwBA,AAAQ,gBAAsB;EAC5B,OAAO,KAAK,QAAQ,OAAO,KAAK,UAAU;GACxC,MAAM,SAAS,KAAK,QAAQ,KAAK,CAAC,CAAC,KAAK;GAExC,IAAI,OAAO,MACT;GAGF,KAAK,QAAQ,OAAO,OAAO,KAAK;EAClC;CACF;;;;;;;;;;;CAYA,AAAO,OAAO,GAAW,OAAkC;EAKzD,MAAM,QAJU,CAAC,GAAG,KAAK,QAAQ,OAAO,CAAC,CAAC,CACvC,QAAQ,CAAC,CACT,QAAQ,UAAU,MAAM,UAAU,KAEjB,CAAC,CAAC,MAAM,GAAG,KAAK,IAAI,GAAG,CAAC,CAAC;EAE7C,OAAO,MAAM,KAAK,OAAO,WAAW;GAClC,IAAI,MAAM;GACV,MAAM,MAAM;GACZ,MAAM;GACN,OAAO,MAAM,UAAU,IAAI,IAAI,IAAI,QAAQ,MAAM;GACjD,UAAU,MAAM;EAClB,EAAE;CACJ;;CAGA,AAAO,QAAc;EACnB,KAAK,QAAQ,MAAM;CACrB;AACF;;;;;;;;AASA,SAAS,UAAU,OAA2B,IAAoB;CAChE,OAAO,GAAG,OAAO,UAAU,EAAE,GAAG,SAAS,GAAG,GAAG;AACjD"}
@@ -1,8 +1,27 @@
1
+ import { MiddlewareTripContext } from "../../contracts/middleware/middleware-context.type.mjs";
1
2
  import { AgentMiddleware } from "../../contracts/middleware/middleware.contract.mjs";
2
3
  import { EmbedderContract } from "../../contracts/embedder.contract.mjs";
3
4
  import { CacheDriver } from "@warlock.js/cache";
4
5
 
5
6
  //#region ../ai/src/middleware/builtins/semantic-cache.d.ts
7
+ /**
8
+ * Isolation boundary for cache reads and writes.
9
+ *
10
+ * - `"session"` (default) — key every entry off the run's
11
+ * `AgentExecuteOptions.sessionId`, so one session never receives a
12
+ * response cached for another. Calls made WITHOUT a `sessionId` share
13
+ * one unscoped pool (the pre-4.15.0 behavior); an unscoped read never
14
+ * sees a session-scoped entry and vice versa.
15
+ * - `"shared"` — one pool for every caller, regardless of session. The
16
+ * explicit opt-in for genuinely public Q&A (docs bots, FAQ) where the
17
+ * cross-user hit rate is the point and no response can carry one
18
+ * caller's private context.
19
+ * - a resolver — derive the key yourself, e.g. per tenant
20
+ * (`ctx => ctx.options?.toolCtx?.tenantId`). Returning `undefined`
21
+ * falls back to the unscoped pool, so return a constant sentinel (or
22
+ * throw) if you need the call to fail closed instead.
23
+ */
24
+ type SemanticCacheScope = "session" | "shared" | ((context: MiddlewareTripContext) => string | undefined);
6
25
  /**
7
26
  * Configuration for `semanticCache()`.
8
27
  */
@@ -39,6 +58,20 @@ type SemanticCacheOptions = {
39
58
  * `"ai.cache"`.
40
59
  */
41
60
  namespace?: string;
61
+ /**
62
+ * Per-caller isolation boundary. Default `"session"` — a cached
63
+ * response is served back only to the session that produced it.
64
+ *
65
+ * A `semanticCache` is normally built once at app boot and shared by
66
+ * every end user, and a hit is returned as the model's answer with no
67
+ * LLM call in between; without a scope that pools every caller's Q&A
68
+ * pairs into one namespace, which is both a disclosure path (user B's
69
+ * near-enough prompt gets served user A's answer, personal context
70
+ * included) and a poisoning path (an attacker seeds an entry near a
71
+ * predictable future query). Set `"shared"` to opt back into pooling
72
+ * where that is actually desirable. See {@link SemanticCacheScope}.
73
+ */
74
+ scope?: SemanticCacheScope;
42
75
  /**
43
76
  * Middleware name — also the state-bag key prefix inside a single
44
77
  * execution. Default `"semantic-cache"`.
@@ -84,6 +117,18 @@ type SemanticCacheOptions = {
84
117
  * `usage: { input: 0, output: 0, total: 0 }` so budget /
85
118
  * observability correctly exclude the saved trip.
86
119
  *
120
+ * **Per-session scoping (4.15.0).** One `semanticCache` instance
121
+ * normally serves every end user, and a hit is returned as the answer
122
+ * with no model call in between — so entries are keyed by the run's
123
+ * `sessionId` (`scope`, default `"session"`) and a lookup only ever
124
+ * sees entries written under the same key. Runs made without a
125
+ * `sessionId` share one unscoped pool; pass `sessionId` on
126
+ * `agent.execute()` (composites thread their own through automatically)
127
+ * to get the isolation, or set `scope: "shared"` to pool deliberately.
128
+ * Note the cost/benefit shift: scoping trades cross-user hit rate for
129
+ * isolation, so public-FAQ deployments where no response can carry a
130
+ * caller's private context should opt into `"shared"` explicitly.
131
+ *
87
132
  * @example
88
133
  * import { semanticCache } from "@warlock.js/ai";
89
134
  * import { MemoryCacheDriver } from "@warlock.js/cache";
@@ -102,5 +147,5 @@ type SemanticCacheOptions = {
102
147
  */
103
148
  declare function semanticCache(options: SemanticCacheOptions): AgentMiddleware;
104
149
  //#endregion
105
- export { SemanticCacheOptions, semanticCache };
150
+ export { SemanticCacheOptions, SemanticCacheScope, semanticCache };
106
151
  //# sourceMappingURL=semantic-cache.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"semantic-cache.d.mts","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"mappings":";;;;;;;;KAWY,oBAAA;EAAoB,sEAE9B,QAAA,EAAU,gBAAA;EAaS;;;;;;;;;;AAuBf;AAkHN;EAzIE,KAAA,GAAQ,WAAW;;;;;EAKnB,SAAA;EAoI2E;AAAA;;;;;EA7H3E,KAAA;;;;;;EAMA,SAAA;;;;;EAKA,IAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAkHc,aAAA,CAAc,OAAA,EAAS,oBAAA,GAAuB,eAAe"}
1
+ {"version":3,"file":"semantic-cache.d.mts","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"mappings":";;;;;;;;;AA0BA;;;;AAGoC;AAKpC;;;;;;;;;KARY,kBAAA,4BAGN,OAA8B,EAArB,qBAAqB;;;;KAKxB,oBAAA;EAiCV,sEA/BA,QAAA,EAAU,gBAAA;EA6CF;;;AAKJ;AAmLN;;;;;;;;EAxNE,KAAA,GAAQ,WAAA;;;;;EAKR,SAAA;;;;;;;EAOA,KAAA;;;;;;EAMA,SAAA;;;;;;;;;;;;;;EAcA,KAAA,GAAQ,kBAAA;;;;;EAKR,IAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAmLc,aAAA,CAAc,OAAA,EAAS,oBAAA,GAAuB,eAAe"}
@@ -4,6 +4,13 @@ import { resolveDefaultStore } from "../../config.mjs";
4
4
  //#region ../ai/src/middleware/builtins/semantic-cache.ts
5
5
  const DEFAULT_NAMESPACE = "ai.cache";
6
6
  /**
7
+ * Extra candidates pulled from `similar()` on a SCOPED lookup before the
8
+ * scope filter runs. The driver ranks across every scope in the index,
9
+ * so a bare `topK: 1` can come back as a foreign entry and mask this
10
+ * scope's own legitimate hit. Mirrors the memory tiers' overscan.
11
+ */
12
+ const SIMILAR_OVERSCAN = 5;
13
+ /**
7
14
  * Build a stable fingerprint for a prompt covering the full message
8
15
  * list (system + history + user turn). Ensures two prompts sharing
9
16
  * the user text but differing in prior context do not collide on
@@ -15,9 +22,12 @@ const DEFAULT_NAMESPACE = "ai.cache";
15
22
  * trust model.
16
23
  */
17
24
  function hashPrompt(messages) {
18
- const serialized = messages.map((message) => {
25
+ return fnv1a(messages.map((message) => {
19
26
  return `${message.role}:${Array.isArray(message.content) ? message.content.filter((part) => part.type === "text").map((part) => part.text).join("|") : message.content}`;
20
- }).join("||");
27
+ }).join("||"));
28
+ }
29
+ /** FNV-1a over a string — see {@link hashPrompt} for the caveats. */
30
+ function fnv1a(serialized) {
21
31
  let hash = 2166136261;
22
32
  for (let index = 0; index < serialized.length; index++) {
23
33
  hash ^= serialized.charCodeAt(index);
@@ -25,6 +35,28 @@ function hashPrompt(messages) {
25
35
  }
26
36
  return (hash >>> 0).toString(16);
27
37
  }
38
+ /**
39
+ * Resolve the isolation key this trip reads and writes under.
40
+ *
41
+ * Derived from the run's own `sessionId` (or the developer's resolver) —
42
+ * never from the prompt, the model's output, or anything the LLM can
43
+ * write to. `"shared"` and an unidentified run both resolve to
44
+ * `undefined`, i.e. the unscoped pool, which a scoped lookup can never
45
+ * read.
46
+ */
47
+ function resolveScope(scope, context) {
48
+ if (scope === "shared") return;
49
+ const key = typeof scope === "function" ? scope(context) : sessionScope(context.options?.sessionId);
50
+ return key ? key : void 0;
51
+ }
52
+ /**
53
+ * The default `"session"` key: the session id under a reserved prefix so
54
+ * a custom resolver returning a bare tenant id can't collide with a
55
+ * session pool. Mirrors the orchestrator's `sessionMemoryScope`.
56
+ */
57
+ function sessionScope(sessionId) {
58
+ return sessionId ? `session:${sessionId}` : void 0;
59
+ }
28
60
  function isFresh(entry, ttlMs) {
29
61
  if (ttlMs === void 0) return true;
30
62
  return Date.now() - entry.storedAt <= ttlMs;
@@ -68,6 +100,18 @@ function isFresh(entry, ttlMs) {
68
100
  * `usage: { input: 0, output: 0, total: 0 }` so budget /
69
101
  * observability correctly exclude the saved trip.
70
102
  *
103
+ * **Per-session scoping (4.15.0).** One `semanticCache` instance
104
+ * normally serves every end user, and a hit is returned as the answer
105
+ * with no model call in between — so entries are keyed by the run's
106
+ * `sessionId` (`scope`, default `"session"`) and a lookup only ever
107
+ * sees entries written under the same key. Runs made without a
108
+ * `sessionId` share one unscoped pool; pass `sessionId` on
109
+ * `agent.execute()` (composites thread their own through automatically)
110
+ * to get the isolation, or set `scope: "shared"` to pool deliberately.
111
+ * Note the cost/benefit shift: scoping trades cross-user hit rate for
112
+ * isolation, so public-FAQ deployments where no response can carry a
113
+ * caller's private context should opt into `"shared"` explicitly.
114
+ *
71
115
  * @example
72
116
  * import { semanticCache } from "@warlock.js/ai";
73
117
  * import { MemoryCacheDriver } from "@warlock.js/cache";
@@ -87,10 +131,11 @@ function isFresh(entry, ttlMs) {
87
131
  function semanticCache(options) {
88
132
  const name = options.name ?? "semantic-cache";
89
133
  const namespace = options.namespace ?? DEFAULT_NAMESPACE;
134
+ const scopeMode = options.scope ?? "session";
90
135
  const pendingKey = `${name}.pending`;
91
136
  const store = options.store ?? resolveDefaultStore();
92
137
  if (!store) throw new Error(`semanticCache: no store supplied — pass \`store\` in options or call \`ai.config({ defaultStore })\` at app boot before constructing the middleware`);
93
- const keyFor = (hash) => `${namespace}.${hash}`;
138
+ const keyFor = (hash, scope) => scope === void 0 ? `${namespace}.${hash}` : `${namespace}.${fnv1a(scope)}.${hash}`;
94
139
  return {
95
140
  name,
96
141
  log: true,
@@ -99,21 +144,20 @@ function semanticCache(options) {
99
144
  if (context.tripIndex !== 0) return;
100
145
  const promptText = extractUserText(context.messages);
101
146
  if (!promptText) return;
147
+ const scope = resolveScope(scopeMode, context);
102
148
  const promptKey = hashPrompt(context.messages);
103
- const scopedKey = keyFor(promptKey);
104
- const exact = await store.get(scopedKey);
105
- if (exact && isFresh(exact, options.ttlMs)) return toSyntheticResponse(exact.response);
149
+ const exact = await store.get(keyFor(promptKey, scope));
150
+ if (exact && exact.scope === scope && isFresh(exact, options.ttlMs)) return toSyntheticResponse(exact.response);
106
151
  const query = await options.embedder.embed(promptText);
107
- const [hit] = await store.similar(query.vector, {
108
- topK: 1,
152
+ const hit = (await store.similar(query.vector, {
153
+ topK: scope === void 0 ? 1 : SIMILAR_OVERSCAN,
109
154
  threshold: options.threshold
110
- });
111
- if (hit && isFresh(hit.value, options.ttlMs)) {
112
- if (hit.key.startsWith(`${namespace}.`)) return toSyntheticResponse(hit.value.response);
113
- }
155
+ })).find((candidate) => candidate.key.startsWith(`${namespace}.`) && candidate.value?.scope === scope && isFresh(candidate.value, options.ttlMs));
156
+ if (hit) return toSyntheticResponse(hit.value.response);
114
157
  const pending = {
115
158
  promptKey,
116
- vector: query.vector
159
+ vector: query.vector,
160
+ scope
117
161
  };
118
162
  context.state.set(pendingKey, pending);
119
163
  },
@@ -124,9 +168,10 @@ function semanticCache(options) {
124
168
  context.state.delete(pendingKey);
125
169
  const entry = {
126
170
  response,
127
- storedAt: Date.now()
171
+ storedAt: Date.now(),
172
+ scope: pending.scope
128
173
  };
129
- await store.set(keyFor(pending.promptKey), entry, { vector: pending.vector });
174
+ await store.set(keyFor(pending.promptKey, pending.scope), entry, { vector: pending.vector });
130
175
  }
131
176
  }
132
177
  };
@@ -1 +1 @@
1
- {"version":3,"file":"semantic-cache.mjs","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"sourcesContent":["import type { CacheDriver } from \"@warlock.js/cache\";\nimport { resolveDefaultStore } from \"../../config\";\nimport type { Message } from \"../../contracts/conversation-message.type\";\nimport type { EmbedderContract } from \"../../contracts/embedder.contract\";\nimport type { AgentMiddleware } from \"../../contracts/middleware\";\nimport type { ModelResponse } from \"../../contracts/model.contract\";\nimport { extractUserText } from \"../utils\";\n\n/**\n * Configuration for `semanticCache()`.\n */\nexport type SemanticCacheOptions = {\n /** Embedder used to produce the query vector from the prompt text. */\n embedder: EmbedderContract;\n /**\n * Vector-capable cache driver from `@warlock.js/cache`. Production\n * deployments pick a driver with a real ANN index (`pg` with\n * pgvector, `redis` with RediSearch). Dev / test environments use\n * `new MemoryCacheDriver()` — zero config, correct, but O(N) per\n * query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * Falls back to `ai.config({ defaultStore })` when omitted. When\n * neither is set, the factory throws at construction time —\n * semantic cache cannot operate without a store.\n */\n store?: CacheDriver<any, any>;\n /**\n * Minimum cosine similarity for a vector hit. Between 0 and 1 —\n * 0.95 is a solid default for question-answering caches.\n */\n threshold: number;\n /**\n * Optional TTL in milliseconds. Entries whose `storedAt` is older\n * than this are treated as misses on read and overwritten on the\n * next write. Default: no expiry — entries live until the store\n * evicts them (per its own TTL/eviction policy).\n */\n ttlMs?: number;\n /**\n * Namespace prefix applied to every key the cache writes. Lets\n * multiple agents share one driver without collision. Default\n * `\"ai.cache\"`.\n */\n namespace?: string;\n /**\n * Middleware name — also the state-bag key prefix inside a single\n * execution. Default `\"semantic-cache\"`.\n */\n name?: string;\n};\n\ntype CachedEntry = {\n response: ModelResponse;\n storedAt: number;\n};\n\ntype PendingWrite = {\n promptKey: string;\n vector: number[];\n};\n\nconst DEFAULT_NAMESPACE = \"ai.cache\";\n\n/**\n * Build a stable fingerprint for a prompt covering the full message\n * list (system + history + user turn). Ensures two prompts sharing\n * the user text but differing in prior context do not collide on\n * the exact-match fast path.\n *\n * FNV-1a variant — cheap, collision-resistant enough for a cache,\n * dependency-free. NOT a cryptographic hash: collisions would\n * surface as wrong cache hits, not a security issue in the current\n * trust model.\n */\nfunction hashPrompt(messages: ReadonlyArray<Message>): string {\n const serialized = messages\n .map((message) => {\n const role = message.role;\n const content = Array.isArray(message.content)\n ? message.content\n .filter((part) => part.type === \"text\")\n .map((part) => (part as { text: string }).text)\n .join(\"|\")\n : message.content;\n\n return `${role}:${content}`;\n })\n .join(\"||\");\n\n let hash = 0x811c9dc5;\n\n for (let index = 0; index < serialized.length; index++) {\n hash ^= serialized.charCodeAt(index);\n hash = Math.imul(hash, 0x01000193);\n }\n\n return (hash >>> 0).toString(16);\n}\n\nfunction isFresh(entry: CachedEntry, ttlMs: number | undefined): boolean {\n if (ttlMs === undefined) {\n return true;\n }\n\n return Date.now() - entry.storedAt <= ttlMs;\n}\n\n/**\n * Semantic-similarity response cache for an agent run.\n *\n * **Role.** Skips LLM round-trips when the current prompt is\n * semantically close to one the agent has already answered. For\n * FAQ / support-style traffic this often eliminates 60–80% of\n * model calls — the production win is massive for cost and\n * latency.\n *\n * **Delegation to `@warlock.js/cache`.** This middleware does NOT\n * implement similarity search itself. It delegates to the supplied\n * `CacheDriver`. Production deployments pick a driver with an ANN\n * index (`pg` + pgvector, `redis` + RediSearch). Dev / test\n * environments pass `new MemoryCacheDriver()` — zero config, correct,\n * but O(N) per query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * **Two-tier lookup.**\n * 1. *Exact-match key* — a cheap FNV hash over the entire message\n * list. `store.get(hash)` returns the entry without an embedding\n * round trip when the prompt hasn't changed at all.\n * 2. *Vector-match* — on exact-match miss, embed the prompt and\n * call `store.similar(vector, { topK: 1, threshold })`. The\n * driver uses its native similarity index; anything clearing\n * `threshold` is returned as a hit.\n *\n * **Write-on-miss.** When both tiers miss, `trip.before` stashes\n * the prompt hash + vector in `ctx.state`; `trip.after` reads back\n * the pending entry and calls\n * `store.set(hash, entry, { vector })`. If an outer middleware\n * (guardrail) throws in `trip.after` before the cache's `trip.after`\n * runs, the pending entry is never written — bad responses stay out\n * of the cache **as long as the canonical install order is followed**\n * (cache outermost).\n *\n * **Synthetic-response on hit.** Returns a `ModelResponse` with\n * `usage: { input: 0, output: 0, total: 0 }` so budget /\n * observability correctly exclude the saved trip.\n *\n * @example\n * import { semanticCache } from \"@warlock.js/ai\";\n * import { MemoryCacheDriver } from \"@warlock.js/cache\";\n *\n * const store = new MemoryCacheDriver();\n * store.setOptions({});\n *\n * const cache = semanticCache({\n * embedder: openai.embedder({ name: \"text-embedding-3-small\" }),\n * store,\n * threshold: 0.95,\n * ttlMs: 60 * 60 * 1000,\n * });\n *\n * const myAgent = agent({ model, middleware: [cache] });\n */\nexport function semanticCache(options: SemanticCacheOptions): AgentMiddleware {\n const name = options.name ?? \"semantic-cache\";\n const namespace = options.namespace ?? DEFAULT_NAMESPACE;\n const pendingKey = `${name}.pending`;\n\n // Resolve the effective store at factory time, not per-call. Every\n // subsequent hook closes over `store` so the resolution happens once.\n // Throws now (loud, at construction) instead of later during the\n // first trip (silent until the agent actually runs).\n const store = options.store ?? resolveDefaultStore();\n\n if (!store) {\n throw new Error(\n `semanticCache: no store supplied — pass \\`store\\` in options or call \\`ai.config({ defaultStore })\\` at app boot before constructing the middleware`,\n );\n }\n\n // Cache's parseKey replaces \":\" with \".\" so the namespace boundary\n // matches what `similar()` actually returns in `hit.key`. Using a\n // dot here keeps prefix checks aligned with stored keys.\n const keyFor = (hash: string): string => `${namespace}.${hash}`;\n\n return {\n name,\n log: true,\n trip: {\n async before(context) {\n // Only cache the first trip's response. Subsequent trips\n // happen because the previous trip requested tool calls — the\n // message list now carries tool results the original prompt\n // never saw, so a semantic match on the unchanged user text\n // would serve back the prior `tool_calls` response and loop\n // the agent forever. The first turn is also the only one\n // where a \"same question → same final answer\" caching story\n // is sound.\n if (context.tripIndex !== 0) {\n return;\n }\n\n const promptText = extractUserText(context.messages);\n\n if (!promptText) {\n return;\n }\n\n const promptKey = hashPrompt(context.messages);\n const scopedKey = keyFor(promptKey);\n\n const exact = await store.get<CachedEntry>(scopedKey);\n\n if (exact && isFresh(exact, options.ttlMs)) {\n return toSyntheticResponse(exact.response);\n }\n\n const query = await options.embedder.embed(promptText);\n\n const [hit] = await store.similar<CachedEntry>(query.vector, {\n topK: 1,\n threshold: options.threshold,\n });\n\n if (hit && isFresh(hit.value, options.ttlMs)) {\n // Only return hits whose stored key is within this cache's\n // namespace. Drivers shared across namespaces would otherwise\n // leak foreign entries into queries.\n if (hit.key.startsWith(`${namespace}.`)) {\n return toSyntheticResponse(hit.value.response);\n }\n }\n\n const pending: PendingWrite = { promptKey, vector: query.vector };\n context.state.set(pendingKey, pending);\n\n return;\n },\n async after(context, response) {\n const pending = context.state.get(pendingKey) as PendingWrite | undefined;\n\n if (!pending) {\n return;\n }\n\n // Mid-stream tool-call responses must not be cached — the\n // useful answer comes from the trip *after* the tool returns.\n // Crucially, leave the pending entry in place so a later trip\n // (the one that actually finishes with `stop`) can read it\n // and write the final response under the *original* trip-0\n // prompt key. Deleting here would orphan the pending and the\n // post-tool answer would never make it into the store.\n if (response.finishReason === \"tool_calls\") {\n return;\n }\n\n context.state.delete(pendingKey);\n\n const entry: CachedEntry = { response, storedAt: Date.now() };\n\n await store.set(keyFor(pending.promptKey), entry, {\n vector: pending.vector,\n });\n\n return;\n },\n },\n };\n}\n\nfunction toSyntheticResponse(response: ModelResponse): ModelResponse {\n return {\n content: response.content,\n finishReason: response.finishReason,\n usage: { input: 0, output: 0, total: 0 },\n toolCalls: response.toolCalls,\n };\n}\n"],"mappings":";;;;AA8DA,MAAM,oBAAoB;;;;;;;;;;;;AAa1B,SAAS,WAAW,UAA0C;CAC5D,MAAM,aAAa,SAChB,KAAK,YAAY;EAShB,OAAO,GARM,QAAQ,KAQN,GAPC,MAAM,QAAQ,QAAQ,OAAO,IACzC,QAAQ,QACL,QAAQ,SAAS,KAAK,SAAS,MAAM,CAAC,CACtC,KAAK,SAAU,KAA0B,IAAI,CAAC,CAC9C,KAAK,GAAG,IACX,QAAQ;CAGd,CAAC,CAAC,CACD,KAAK,IAAI;CAEZ,IAAI,OAAO;CAEX,KAAK,IAAI,QAAQ,GAAG,QAAQ,WAAW,QAAQ,SAAS;EACtD,QAAQ,WAAW,WAAW,KAAK;EACnC,OAAO,KAAK,KAAK,MAAM,QAAU;CACnC;CAEA,QAAQ,SAAS,EAAC,CAAE,SAAS,EAAE;AACjC;AAEA,SAAS,QAAQ,OAAoB,OAAoC;CACvE,IAAI,UAAU,QACZ,OAAO;CAGT,OAAO,KAAK,IAAI,IAAI,MAAM,YAAY;AACxC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyDA,SAAgB,cAAc,SAAgD;CAC5E,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,aAAa,GAAG,KAAK;CAM3B,MAAM,QAAQ,QAAQ,SAAS,oBAAoB;CAEnD,IAAI,CAAC,OACH,MAAM,IAAI,MACR,qJACF;CAMF,MAAM,UAAU,SAAyB,GAAG,UAAU,GAAG;CAEzD,OAAO;EACL;EACA,KAAK;EACL,MAAM;GACJ,MAAM,OAAO,SAAS;IASpB,IAAI,QAAQ,cAAc,GACxB;IAGF,MAAM,aAAa,gBAAgB,QAAQ,QAAQ;IAEnD,IAAI,CAAC,YACH;IAGF,MAAM,YAAY,WAAW,QAAQ,QAAQ;IAC7C,MAAM,YAAY,OAAO,SAAS;IAElC,MAAM,QAAQ,MAAM,MAAM,IAAiB,SAAS;IAEpD,IAAI,SAAS,QAAQ,OAAO,QAAQ,KAAK,GACvC,OAAO,oBAAoB,MAAM,QAAQ;IAG3C,MAAM,QAAQ,MAAM,QAAQ,SAAS,MAAM,UAAU;IAErD,MAAM,CAAC,OAAO,MAAM,MAAM,QAAqB,MAAM,QAAQ;KAC3D,MAAM;KACN,WAAW,QAAQ;IACrB,CAAC;IAED,IAAI,OAAO,QAAQ,IAAI,OAAO,QAAQ,KAAK,GAIzC;SAAI,IAAI,IAAI,WAAW,GAAG,UAAU,EAAE,GACpC,OAAO,oBAAoB,IAAI,MAAM,QAAQ;IAC/C;IAGF,MAAM,UAAwB;KAAE;KAAW,QAAQ,MAAM;IAAO;IAChE,QAAQ,MAAM,IAAI,YAAY,OAAO;GAGvC;GACA,MAAM,MAAM,SAAS,UAAU;IAC7B,MAAM,UAAU,QAAQ,MAAM,IAAI,UAAU;IAE5C,IAAI,CAAC,SACH;IAUF,IAAI,SAAS,iBAAiB,cAC5B;IAGF,QAAQ,MAAM,OAAO,UAAU;IAE/B,MAAM,QAAqB;KAAE;KAAU,UAAU,KAAK,IAAI;IAAE;IAE5D,MAAM,MAAM,IAAI,OAAO,QAAQ,SAAS,GAAG,OAAO,EAChD,QAAQ,QAAQ,OAClB,CAAC;GAGH;EACF;CACF;AACF;AAEA,SAAS,oBAAoB,UAAwC;CACnE,OAAO;EACL,SAAS,SAAS;EAClB,cAAc,SAAS;EACvB,OAAO;GAAE,OAAO;GAAG,QAAQ;GAAG,OAAO;EAAE;EACvC,WAAW,SAAS;CACtB;AACF"}
1
+ {"version":3,"file":"semantic-cache.mjs","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"sourcesContent":["import type { CacheDriver } from \"@warlock.js/cache\";\nimport { resolveDefaultStore } from \"../../config\";\nimport type { Message } from \"../../contracts/conversation-message.type\";\nimport type { EmbedderContract } from \"../../contracts/embedder.contract\";\nimport type { AgentMiddleware } from \"../../contracts/middleware\";\nimport type { MiddlewareTripContext } from \"../../contracts/middleware/middleware-context.type\";\nimport type { ModelResponse } from \"../../contracts/model.contract\";\nimport { extractUserText } from \"../utils\";\n\n/**\n * Isolation boundary for cache reads and writes.\n *\n * - `\"session\"` (default) — key every entry off the run's\n * `AgentExecuteOptions.sessionId`, so one session never receives a\n * response cached for another. Calls made WITHOUT a `sessionId` share\n * one unscoped pool (the pre-4.15.0 behavior); an unscoped read never\n * sees a session-scoped entry and vice versa.\n * - `\"shared\"` — one pool for every caller, regardless of session. The\n * explicit opt-in for genuinely public Q&A (docs bots, FAQ) where the\n * cross-user hit rate is the point and no response can carry one\n * caller's private context.\n * - a resolver — derive the key yourself, e.g. per tenant\n * (`ctx => ctx.options?.toolCtx?.tenantId`). Returning `undefined`\n * falls back to the unscoped pool, so return a constant sentinel (or\n * throw) if you need the call to fail closed instead.\n */\nexport type SemanticCacheScope =\n | \"session\"\n | \"shared\"\n | ((context: MiddlewareTripContext) => string | undefined);\n\n/**\n * Configuration for `semanticCache()`.\n */\nexport type SemanticCacheOptions = {\n /** Embedder used to produce the query vector from the prompt text. */\n embedder: EmbedderContract;\n /**\n * Vector-capable cache driver from `@warlock.js/cache`. Production\n * deployments pick a driver with a real ANN index (`pg` with\n * pgvector, `redis` with RediSearch). Dev / test environments use\n * `new MemoryCacheDriver()` — zero config, correct, but O(N) per\n * query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * Falls back to `ai.config({ defaultStore })` when omitted. When\n * neither is set, the factory throws at construction time —\n * semantic cache cannot operate without a store.\n */\n store?: CacheDriver<any, any>;\n /**\n * Minimum cosine similarity for a vector hit. Between 0 and 1 —\n * 0.95 is a solid default for question-answering caches.\n */\n threshold: number;\n /**\n * Optional TTL in milliseconds. Entries whose `storedAt` is older\n * than this are treated as misses on read and overwritten on the\n * next write. Default: no expiry — entries live until the store\n * evicts them (per its own TTL/eviction policy).\n */\n ttlMs?: number;\n /**\n * Namespace prefix applied to every key the cache writes. Lets\n * multiple agents share one driver without collision. Default\n * `\"ai.cache\"`.\n */\n namespace?: string;\n /**\n * Per-caller isolation boundary. Default `\"session\"` — a cached\n * response is served back only to the session that produced it.\n *\n * A `semanticCache` is normally built once at app boot and shared by\n * every end user, and a hit is returned as the model's answer with no\n * LLM call in between; without a scope that pools every caller's Q&A\n * pairs into one namespace, which is both a disclosure path (user B's\n * near-enough prompt gets served user A's answer, personal context\n * included) and a poisoning path (an attacker seeds an entry near a\n * predictable future query). Set `\"shared\"` to opt back into pooling\n * where that is actually desirable. See {@link SemanticCacheScope}.\n */\n scope?: SemanticCacheScope;\n /**\n * Middleware name — also the state-bag key prefix inside a single\n * execution. Default `\"semantic-cache\"`.\n */\n name?: string;\n};\n\ntype CachedEntry = {\n response: ModelResponse;\n storedAt: number;\n /**\n * Isolation key the entry was written under; absent = the unscoped\n * pool (also the shape of every entry written before 4.15.0).\n */\n scope?: string;\n};\n\ntype PendingWrite = {\n promptKey: string;\n vector: number[];\n scope?: string;\n};\n\nconst DEFAULT_NAMESPACE = \"ai.cache\";\n\n/**\n * Extra candidates pulled from `similar()` on a SCOPED lookup before the\n * scope filter runs. The driver ranks across every scope in the index,\n * so a bare `topK: 1` can come back as a foreign entry and mask this\n * scope's own legitimate hit. Mirrors the memory tiers' overscan.\n */\nconst SIMILAR_OVERSCAN = 5;\n\n/**\n * Build a stable fingerprint for a prompt covering the full message\n * list (system + history + user turn). Ensures two prompts sharing\n * the user text but differing in prior context do not collide on\n * the exact-match fast path.\n *\n * FNV-1a variant — cheap, collision-resistant enough for a cache,\n * dependency-free. NOT a cryptographic hash: collisions would\n * surface as wrong cache hits, not a security issue in the current\n * trust model.\n */\nfunction hashPrompt(messages: ReadonlyArray<Message>): string {\n return fnv1a(\n messages\n .map((message) => {\n const role = message.role;\n const content = Array.isArray(message.content)\n ? message.content\n .filter((part) => part.type === \"text\")\n .map((part) => (part as { text: string }).text)\n .join(\"|\")\n : message.content;\n\n return `${role}:${content}`;\n })\n .join(\"||\"),\n );\n}\n\n/** FNV-1a over a string — see {@link hashPrompt} for the caveats. */\nfunction fnv1a(serialized: string): string {\n let hash = 0x811c9dc5;\n\n for (let index = 0; index < serialized.length; index++) {\n hash ^= serialized.charCodeAt(index);\n hash = Math.imul(hash, 0x01000193);\n }\n\n return (hash >>> 0).toString(16);\n}\n\n/**\n * Resolve the isolation key this trip reads and writes under.\n *\n * Derived from the run's own `sessionId` (or the developer's resolver) —\n * never from the prompt, the model's output, or anything the LLM can\n * write to. `\"shared\"` and an unidentified run both resolve to\n * `undefined`, i.e. the unscoped pool, which a scoped lookup can never\n * read.\n */\nfunction resolveScope(\n scope: SemanticCacheScope,\n context: MiddlewareTripContext,\n): string | undefined {\n if (scope === \"shared\") {\n return undefined;\n }\n\n const key =\n typeof scope === \"function\"\n ? scope(context)\n : sessionScope(context.options?.sessionId);\n\n return key ? key : undefined;\n}\n\n/**\n * The default `\"session\"` key: the session id under a reserved prefix so\n * a custom resolver returning a bare tenant id can't collide with a\n * session pool. Mirrors the orchestrator's `sessionMemoryScope`.\n */\nfunction sessionScope(sessionId: string | undefined): string | undefined {\n return sessionId ? `session:${sessionId}` : undefined;\n}\n\nfunction isFresh(entry: CachedEntry, ttlMs: number | undefined): boolean {\n if (ttlMs === undefined) {\n return true;\n }\n\n return Date.now() - entry.storedAt <= ttlMs;\n}\n\n/**\n * Semantic-similarity response cache for an agent run.\n *\n * **Role.** Skips LLM round-trips when the current prompt is\n * semantically close to one the agent has already answered. For\n * FAQ / support-style traffic this often eliminates 60–80% of\n * model calls — the production win is massive for cost and\n * latency.\n *\n * **Delegation to `@warlock.js/cache`.** This middleware does NOT\n * implement similarity search itself. It delegates to the supplied\n * `CacheDriver`. Production deployments pick a driver with an ANN\n * index (`pg` + pgvector, `redis` + RediSearch). Dev / test\n * environments pass `new MemoryCacheDriver()` — zero config, correct,\n * but O(N) per query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * **Two-tier lookup.**\n * 1. *Exact-match key* — a cheap FNV hash over the entire message\n * list. `store.get(hash)` returns the entry without an embedding\n * round trip when the prompt hasn't changed at all.\n * 2. *Vector-match* — on exact-match miss, embed the prompt and\n * call `store.similar(vector, { topK: 1, threshold })`. The\n * driver uses its native similarity index; anything clearing\n * `threshold` is returned as a hit.\n *\n * **Write-on-miss.** When both tiers miss, `trip.before` stashes\n * the prompt hash + vector in `ctx.state`; `trip.after` reads back\n * the pending entry and calls\n * `store.set(hash, entry, { vector })`. If an outer middleware\n * (guardrail) throws in `trip.after` before the cache's `trip.after`\n * runs, the pending entry is never written — bad responses stay out\n * of the cache **as long as the canonical install order is followed**\n * (cache outermost).\n *\n * **Synthetic-response on hit.** Returns a `ModelResponse` with\n * `usage: { input: 0, output: 0, total: 0 }` so budget /\n * observability correctly exclude the saved trip.\n *\n * **Per-session scoping (4.15.0).** One `semanticCache` instance\n * normally serves every end user, and a hit is returned as the answer\n * with no model call in between — so entries are keyed by the run's\n * `sessionId` (`scope`, default `\"session\"`) and a lookup only ever\n * sees entries written under the same key. Runs made without a\n * `sessionId` share one unscoped pool; pass `sessionId` on\n * `agent.execute()` (composites thread their own through automatically)\n * to get the isolation, or set `scope: \"shared\"` to pool deliberately.\n * Note the cost/benefit shift: scoping trades cross-user hit rate for\n * isolation, so public-FAQ deployments where no response can carry a\n * caller's private context should opt into `\"shared\"` explicitly.\n *\n * @example\n * import { semanticCache } from \"@warlock.js/ai\";\n * import { MemoryCacheDriver } from \"@warlock.js/cache\";\n *\n * const store = new MemoryCacheDriver();\n * store.setOptions({});\n *\n * const cache = semanticCache({\n * embedder: openai.embedder({ name: \"text-embedding-3-small\" }),\n * store,\n * threshold: 0.95,\n * ttlMs: 60 * 60 * 1000,\n * });\n *\n * const myAgent = agent({ model, middleware: [cache] });\n */\nexport function semanticCache(options: SemanticCacheOptions): AgentMiddleware {\n const name = options.name ?? \"semantic-cache\";\n const namespace = options.namespace ?? DEFAULT_NAMESPACE;\n const scopeMode: SemanticCacheScope = options.scope ?? \"session\";\n const pendingKey = `${name}.pending`;\n\n // Resolve the effective store at factory time, not per-call. Every\n // subsequent hook closes over `store` so the resolution happens once.\n // Throws now (loud, at construction) instead of later during the\n // first trip (silent until the agent actually runs).\n const store = options.store ?? resolveDefaultStore();\n\n if (!store) {\n throw new Error(\n `semanticCache: no store supplied — pass \\`store\\` in options or call \\`ai.config({ defaultStore })\\` at app boot before constructing the middleware`,\n );\n }\n\n // Cache's parseKey replaces \":\" with \".\" so the namespace boundary\n // matches what `similar()` actually returns in `hit.key`. Using a\n // dot here keeps prefix checks aligned with stored keys.\n //\n // A scoped entry gets an extra hashed segment, so two sessions asking\n // the identical question stay two entries instead of overwriting each\n // other; the scope is hashed because a `sessionId` is caller-supplied\n // and may contain the key delimiter. The unscoped key shape is\n // unchanged, so pre-4.15.0 entries still resolve. The hash is a\n // write-separation device only — a read is authorized by the exact\n // `entry.scope` equality check below, so even a hash collision cannot\n // widen what a session can read.\n const keyFor = (hash: string, scope: string | undefined): string =>\n scope === undefined\n ? `${namespace}.${hash}`\n : `${namespace}.${fnv1a(scope)}.${hash}`;\n\n return {\n name,\n log: true,\n trip: {\n async before(context) {\n // Only cache the first trip's response. Subsequent trips\n // happen because the previous trip requested tool calls — the\n // message list now carries tool results the original prompt\n // never saw, so a semantic match on the unchanged user text\n // would serve back the prior `tool_calls` response and loop\n // the agent forever. The first turn is also the only one\n // where a \"same question → same final answer\" caching story\n // is sound.\n if (context.tripIndex !== 0) {\n return;\n }\n\n const promptText = extractUserText(context.messages);\n\n if (!promptText) {\n return;\n }\n\n const scope = resolveScope(scopeMode, context);\n const promptKey = hashPrompt(context.messages);\n\n const exact = await store.get<CachedEntry>(keyFor(promptKey, scope));\n\n // The key already carries the scope; re-checking the stored\n // `scope` is the actual authorization step, so a key collision\n // or a hand-written entry can't serve across the boundary.\n if (exact && exact.scope === scope && isFresh(exact, options.ttlMs)) {\n return toSyntheticResponse(exact.response);\n }\n\n const query = await options.embedder.embed(promptText);\n\n const hits = await store.similar<CachedEntry>(query.vector, {\n topK: scope === undefined ? 1 : SIMILAR_OVERSCAN,\n threshold: options.threshold,\n });\n\n // Only entries written inside this cache's namespace AND this\n // caller's scope are eligible. A shared driver would otherwise\n // leak a foreign namespace's entries; a shared namespace would\n // leak another session's answer to this one.\n const hit = hits.find(\n (candidate) =>\n candidate.key.startsWith(`${namespace}.`) &&\n candidate.value?.scope === scope &&\n isFresh(candidate.value, options.ttlMs),\n );\n\n if (hit) {\n return toSyntheticResponse(hit.value.response);\n }\n\n const pending: PendingWrite = {\n promptKey,\n vector: query.vector,\n scope,\n };\n context.state.set(pendingKey, pending);\n\n return;\n },\n async after(context, response) {\n const pending = context.state.get(pendingKey) as PendingWrite | undefined;\n\n if (!pending) {\n return;\n }\n\n // Mid-stream tool-call responses must not be cached — the\n // useful answer comes from the trip *after* the tool returns.\n // Crucially, leave the pending entry in place so a later trip\n // (the one that actually finishes with `stop`) can read it\n // and write the final response under the *original* trip-0\n // prompt key. Deleting here would orphan the pending and the\n // post-tool answer would never make it into the store.\n if (response.finishReason === \"tool_calls\") {\n return;\n }\n\n context.state.delete(pendingKey);\n\n const entry: CachedEntry = {\n response,\n storedAt: Date.now(),\n scope: pending.scope,\n };\n\n await store.set(keyFor(pending.promptKey, pending.scope), entry, {\n vector: pending.vector,\n });\n\n return;\n },\n },\n };\n}\n\nfunction toSyntheticResponse(response: ModelResponse): ModelResponse {\n return {\n content: response.content,\n finishReason: response.finishReason,\n usage: { input: 0, output: 0, total: 0 },\n toolCalls: response.toolCalls,\n };\n}\n"],"mappings":";;;;AAyGA,MAAM,oBAAoB;;;;;;;AAQ1B,MAAM,mBAAmB;;;;;;;;;;;;AAazB,SAAS,WAAW,UAA0C;CAC5D,OAAO,MACL,SACG,KAAK,YAAY;EAShB,OAAO,GARM,QAAQ,KAQN,GAPC,MAAM,QAAQ,QAAQ,OAAO,IACzC,QAAQ,QACL,QAAQ,SAAS,KAAK,SAAS,MAAM,CAAC,CACtC,KAAK,SAAU,KAA0B,IAAI,CAAC,CAC9C,KAAK,GAAG,IACX,QAAQ;CAGd,CAAC,CAAC,CACD,KAAK,IAAI,CACd;AACF;;AAGA,SAAS,MAAM,YAA4B;CACzC,IAAI,OAAO;CAEX,KAAK,IAAI,QAAQ,GAAG,QAAQ,WAAW,QAAQ,SAAS;EACtD,QAAQ,WAAW,WAAW,KAAK;EACnC,OAAO,KAAK,KAAK,MAAM,QAAU;CACnC;CAEA,QAAQ,SAAS,EAAC,CAAE,SAAS,EAAE;AACjC;;;;;;;;;;AAWA,SAAS,aACP,OACA,SACoB;CACpB,IAAI,UAAU,UACZ;CAGF,MAAM,MACJ,OAAO,UAAU,aACb,MAAM,OAAO,IACb,aAAa,QAAQ,SAAS,SAAS;CAE7C,OAAO,MAAM,MAAM;AACrB;;;;;;AAOA,SAAS,aAAa,WAAmD;CACvE,OAAO,YAAY,WAAW,cAAc;AAC9C;AAEA,SAAS,QAAQ,OAAoB,OAAoC;CACvE,IAAI,UAAU,QACZ,OAAO;CAGT,OAAO,KAAK,IAAI,IAAI,MAAM,YAAY;AACxC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqEA,SAAgB,cAAc,SAAgD;CAC5E,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,YAAgC,QAAQ,SAAS;CACvD,MAAM,aAAa,GAAG,KAAK;CAM3B,MAAM,QAAQ,QAAQ,SAAS,oBAAoB;CAEnD,IAAI,CAAC,OACH,MAAM,IAAI,MACR,qJACF;CAeF,MAAM,UAAU,MAAc,UAC5B,UAAU,SACN,GAAG,UAAU,GAAG,SAChB,GAAG,UAAU,GAAG,MAAM,KAAK,EAAE,GAAG;CAEtC,OAAO;EACL;EACA,KAAK;EACL,MAAM;GACJ,MAAM,OAAO,SAAS;IASpB,IAAI,QAAQ,cAAc,GACxB;IAGF,MAAM,aAAa,gBAAgB,QAAQ,QAAQ;IAEnD,IAAI,CAAC,YACH;IAGF,MAAM,QAAQ,aAAa,WAAW,OAAO;IAC7C,MAAM,YAAY,WAAW,QAAQ,QAAQ;IAE7C,MAAM,QAAQ,MAAM,MAAM,IAAiB,OAAO,WAAW,KAAK,CAAC;IAKnE,IAAI,SAAS,MAAM,UAAU,SAAS,QAAQ,OAAO,QAAQ,KAAK,GAChE,OAAO,oBAAoB,MAAM,QAAQ;IAG3C,MAAM,QAAQ,MAAM,QAAQ,SAAS,MAAM,UAAU;IAWrD,MAAM,OAAM,MATO,MAAM,QAAqB,MAAM,QAAQ;KAC1D,MAAM,UAAU,SAAY,IAAI;KAChC,WAAW,QAAQ;IACrB,CAAC,EAMe,CAAC,MACd,cACC,UAAU,IAAI,WAAW,GAAG,UAAU,EAAE,KACxC,UAAU,OAAO,UAAU,SAC3B,QAAQ,UAAU,OAAO,QAAQ,KAAK,CAC1C;IAEA,IAAI,KACF,OAAO,oBAAoB,IAAI,MAAM,QAAQ;IAG/C,MAAM,UAAwB;KAC5B;KACA,QAAQ,MAAM;KACd;IACF;IACA,QAAQ,MAAM,IAAI,YAAY,OAAO;GAGvC;GACA,MAAM,MAAM,SAAS,UAAU;IAC7B,MAAM,UAAU,QAAQ,MAAM,IAAI,UAAU;IAE5C,IAAI,CAAC,SACH;IAUF,IAAI,SAAS,iBAAiB,cAC5B;IAGF,QAAQ,MAAM,OAAO,UAAU;IAE/B,MAAM,QAAqB;KACzB;KACA,UAAU,KAAK,IAAI;KACnB,OAAO,QAAQ;IACjB;IAEA,MAAM,MAAM,IAAI,OAAO,QAAQ,WAAW,QAAQ,KAAK,GAAG,OAAO,EAC/D,QAAQ,QAAQ,OAClB,CAAC;GAGH;EACF;CACF;AACF;AAEA,SAAS,oBAAoB,UAAwC;CACnE,OAAO;EACL,SAAS,SAAS;EAClB,cAAc,SAAS;EACvB,OAAO;GAAE,OAAO;GAAG,QAAQ;GAAG,OAAO;EAAE;EACvC,WAAW,SAAS;CACtB;AACF"}
@@ -1,7 +1,7 @@
1
1
  import { BudgetContract, BudgetContractDimension, BudgetContractFallback, BudgetContractViolation, BudgetContractViolationMode } from "./builtins/budget-contract.type.mjs";
2
2
  import { BudgetFallbackSignal, BudgetOptions, BudgetPricing, budget, readBudgetFallbackSignal } from "./builtins/budget.mjs";
3
3
  import { GuardrailCheck, GuardrailCheckResult, GuardrailOptions, guardrail } from "./builtins/guardrail.mjs";
4
- import { SemanticCacheOptions, semanticCache } from "./builtins/semantic-cache.mjs";
4
+ import { SemanticCacheOptions, SemanticCacheScope, semanticCache } from "./builtins/semantic-cache.mjs";
5
5
  import { composeMiddleware } from "./helpers/compose.mjs";
6
6
  import { forTool } from "./helpers/for-tool.mjs";
7
7
  import { MiddlewareContextByLevel, MiddlewareLevel, runPipeline } from "./pipeline.mjs";
@@ -1,7 +1,7 @@
1
1
  import { MockImageCall, MockImageModel, MockImageResponse } from "./mock-image-model.mjs";
2
2
  import { MockSpeechCall, MockSpeechModel, MockSpeechResponse } from "./mock-speech-model.mjs";
3
3
  import { MockTranscriptionCall, MockTranscriptionModel, MockTranscriptionResponse } from "./mock-transcription-model.mjs";
4
- import { MockModelResponse, MockSDKConfig } from "./mock-config.type.mjs";
4
+ import { MockModelResponse, MockSDKConfig, MockUsage } from "./mock-config.type.mjs";
5
5
  import { mockAgent } from "./mock-agent.mjs";
6
6
  import { MockModel } from "./mock-model.mjs";
7
7
  import { MockRouterDecision, MockRouterExhaustion, MockRouterOptions, mockRouter } from "./mock-router.mjs";
@@ -1,5 +1,4 @@
1
1
  import { ModelToolCallRequest } from "../contracts/model-tool-call-request.type.mjs";
2
- import { Usage } from "../contracts/result/usage.type.mjs";
3
2
  import { FinishReason } from "../contracts/finish-reason.type.mjs";
4
3
  import { ModelCapabilities } from "../contracts/model.contract.mjs";
5
4
  import { ImageModelPricing } from "../contracts/image-model.contract.mjs";
@@ -10,6 +9,29 @@ import { MockSpeechResponse } from "./mock-speech-model.mjs";
10
9
  import { MockTranscriptionResponse } from "./mock-transcription-model.mjs";
11
10
 
12
11
  //#region ../ai/src/mock/mock-config.type.d.ts
12
+ /**
13
+ * Token counts a scripted `MockModelResponse` may declare.
14
+ *
15
+ * Deliberately NOT `Usage`. `Usage` is an emitted result — every field
16
+ * on it is authoritative and `total` is always present. This is script
17
+ * *input*, and `MockModel.buildResponse()` only honors `input`,
18
+ * `output` and `cachedTokens`; it always recomputes `total` as
19
+ * `input + output`, so a scripted `total` can never disagree with the
20
+ * numbers it is derived from. `reasoningTokens`, `cacheWriteTokens`
21
+ * and `cost` are omitted because the mock does not forward them —
22
+ * declaring one here would be silently dropped.
23
+ */
24
+ type MockUsage = {
25
+ /** Prompt tokens the scripted response reports. */input: number; /** Completion tokens the scripted response reports. */
26
+ output: number;
27
+ /**
28
+ * Ignored — `MockModel` always recomputes `total` as
29
+ * `input + output`. Accepted so existing fixtures that spell it out
30
+ * still compile.
31
+ */
32
+ total?: number; /** Subset of `input` served from the provider's prompt cache. */
33
+ cachedTokens?: number;
34
+ };
13
35
  /**
14
36
  * Configuration for a single mock model response.
15
37
  * Responses are consumed in order — last one repeats if list is exhausted.
@@ -17,8 +39,15 @@ import { MockTranscriptionResponse } from "./mock-transcription-model.mjs";
17
39
  type MockModelResponse = {
18
40
  content: string;
19
41
  finishReason?: FinishReason;
20
- usage?: Usage;
21
- toolCalls?: ModelToolCallRequest[]; /** Simulate a delay in ms before resolving */
42
+ usage?: MockUsage;
43
+ toolCalls?: ModelToolCallRequest[];
44
+ /**
45
+ * Exact chunk boundaries `stream()` should emit for `content`.
46
+ * Omitted = the mock splits `content` on whitespace. Use this when a
47
+ * test asserts on delta boundaries themselves (partial JSON, prose
48
+ * fragments) rather than on the assembled text.
49
+ */
50
+ deltas?: string[]; /** Simulate a delay in ms before resolving */
22
51
  delay?: number; /** Throw this error instead of returning a response */
23
52
  error?: Error;
24
53
  };
@@ -49,5 +78,5 @@ type MockSDKConfig = {
49
78
  transcriptionPricing?: TranscriptionModelPricing;
50
79
  };
51
80
  //#endregion
52
- export { MockModelResponse, MockSDKConfig };
81
+ export { MockModelResponse, MockSDKConfig, MockUsage };
53
82
  //# sourceMappingURL=mock-config.type.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mock-config.type.d.mts","names":[],"sources":["../../../../../../../ai/src/mock/mock-config.type.ts"],"mappings":";;;;;;;;;;;;;;;AAeA;KAAY,iBAAA;EACV,OAAA;EACA,YAAA,GAAe,YAAA;EACf,KAAA,GAAQ,KAAA;EACR,SAAA,GAAY,oBAAA,IAIJ;EAFR,KAAA,WAEa;EAAb,KAAA,GAAQ,KAAA;AAAA;AAAA,KAGE,aAAA;EARV,wEAUA,SAAA,GAAY,iBAAA,IATZ;EAWA,gBAAA;EATA;;;;AAEa;AAGf;EAWE,YAAA,GAAe,iBAAA;;;;;;EAMf,cAAA,GAAiB,iBAAA;EASD;;;;EAJhB,YAAA,GAAe,iBAAA,EApBf;EAsBA,eAAA,GAAkB,kBAAA,IApBlB;EAsBA,aAAA,GAAgB,kBAAA,EAfD;EAiBf,sBAAA,GAAyB,yBAAA,IAXR;EAajB,oBAAA,GAAuB,yBAAA;AAAA"}
1
+ {"version":3,"file":"mock-config.type.d.mts","names":[],"sources":["../../../../../../../ai/src/mock/mock-config.type.ts"],"mappings":";;;;;;;;;;;;;AAsBA;;;;;;;;;AAYc;KAZF,SAAA;EAmBiB,mDAjB3B,KAAA,UAmBe;EAjBf,MAAA;EAmBY;;;;;EAbZ,KAAA,WAWe;EATf,YAAA;AAAA;;;;;KAOU,iBAAA;EACV,OAAA;EACA,YAAA,GAAe,YAAA;EACf,KAAA,GAAQ,SAAA;EACR,SAAA,GAAY,oBAAA;EAcW;;;;;;EAPvB,MAAA,aAiCgB;EA/BhB,KAAA,WAmCuB;EAjCvB,KAAA,GAAQ,KAAA;AAAA;AAAA,KAGE,aAAA;EAEE,wEAAZ,SAAA,GAAY,iBAAA,IASZ;EAPA,gBAAA;EAaA;;;;;;EANA,YAAA,GAAe,iBAAA;EAeC;;;;;EAThB,cAAA,GAAiB,iBAAA;EAa+B;;;;EARhD,YAAA,GAAe,iBAAA;EAEf,eAAA,GAAkB,kBAAA;EAElB,aAAA,GAAgB,kBAAA;EAEhB,sBAAA,GAAyB,yBAAA;EAEzB,oBAAA,GAAuB,yBAAA;AAAA"}