@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.
- package/CHANGELOG.md +182 -141
- package/cjs/index.cjs +640 -139
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/index.d.mts +3 -3
- package/esm/contracts/memory/index.d.mts +1 -1
- package/esm/contracts/memory/memory-config.type.d.mts +29 -3
- package/esm/contracts/memory/memory-config.type.d.mts.map +1 -1
- package/esm/contracts/memory/memory-item.type.d.mts +15 -1
- package/esm/contracts/memory/memory-item.type.d.mts.map +1 -1
- package/esm/contracts/memory/memory.contract.d.mts +15 -2
- package/esm/contracts/memory/memory.contract.d.mts.map +1 -1
- package/esm/contracts/memory/recall-options.type.d.mts +12 -0
- package/esm/contracts/memory/recall-options.type.d.mts.map +1 -1
- package/esm/contracts/orchestrator/index.d.mts +1 -1
- package/esm/contracts/orchestrator/orchestrator-config.type.d.mts +38 -1
- package/esm/contracts/orchestrator/orchestrator-config.type.d.mts.map +1 -1
- package/esm/contracts/orchestrator/orchestrator.contract.d.mts +67 -3
- package/esm/contracts/orchestrator/orchestrator.contract.d.mts.map +1 -1
- package/esm/contracts/supervisor/supervisor-config.type.d.mts +23 -0
- package/esm/contracts/supervisor/supervisor-config.type.d.mts.map +1 -1
- package/esm/contracts/team/team-config.type.d.mts +9 -6
- package/esm/contracts/team/team-config.type.d.mts.map +1 -1
- package/esm/contracts/tool.contract.d.mts +35 -3
- package/esm/contracts/tool.contract.d.mts.map +1 -1
- package/esm/index.d.mts +8 -7
- package/esm/index.mjs +3 -2
- package/esm/memory/episodic-memory.mjs +14 -6
- package/esm/memory/episodic-memory.mjs.map +1 -1
- package/esm/memory/index.d.mts +1 -1
- package/esm/memory/memory.d.mts +13 -1
- package/esm/memory/memory.d.mts.map +1 -1
- package/esm/memory/memory.mjs +41 -7
- package/esm/memory/memory.mjs.map +1 -1
- package/esm/memory/procedural-memory.mjs +20 -7
- package/esm/memory/procedural-memory.mjs.map +1 -1
- package/esm/memory/semantic-memory.mjs +27 -10
- package/esm/memory/semantic-memory.mjs.map +1 -1
- package/esm/memory/working-memory.mjs +70 -13
- package/esm/memory/working-memory.mjs.map +1 -1
- package/esm/middleware/builtins/semantic-cache.d.mts +46 -1
- package/esm/middleware/builtins/semantic-cache.d.mts.map +1 -1
- package/esm/middleware/builtins/semantic-cache.mjs +60 -15
- package/esm/middleware/builtins/semantic-cache.mjs.map +1 -1
- package/esm/middleware/index.d.mts +1 -1
- package/esm/mock/index.d.mts +1 -1
- package/esm/mock/mock-config.type.d.mts +33 -4
- package/esm/mock/mock-config.type.d.mts.map +1 -1
- package/esm/mock/mock-model.d.mts +2 -1
- package/esm/mock/mock-model.d.mts.map +1 -1
- package/esm/mock/mock-model.mjs +5 -4
- package/esm/mock/mock-model.mjs.map +1 -1
- package/esm/mock/mock-sdk.d.mts +11 -3
- package/esm/mock/mock-sdk.d.mts.map +1 -1
- package/esm/mock/mock-sdk.mjs.map +1 -1
- package/esm/orchestrator/as-tool.d.mts +35 -9
- package/esm/orchestrator/as-tool.d.mts.map +1 -1
- package/esm/orchestrator/as-tool.mjs +67 -19
- package/esm/orchestrator/as-tool.mjs.map +1 -1
- package/esm/orchestrator/execution.d.mts.map +1 -1
- package/esm/orchestrator/execution.mjs +2 -2
- package/esm/orchestrator/execution.mjs.map +1 -1
- package/esm/orchestrator/index.d.mts +1 -1
- package/esm/orchestrator/index.mjs +1 -1
- package/esm/orchestrator/memory.d.mts +41 -5
- package/esm/orchestrator/memory.d.mts.map +1 -1
- package/esm/orchestrator/memory.mjs +53 -5
- package/esm/orchestrator/memory.mjs.map +1 -1
- package/esm/planner/plan-schema.d.mts +15 -4
- package/esm/planner/plan-schema.d.mts.map +1 -1
- package/esm/planner/plan-schema.mjs +27 -16
- package/esm/planner/plan-schema.mjs.map +1 -1
- package/esm/security/index.mjs +1 -0
- package/esm/security/outbound-policy.d.mts +9 -0
- package/esm/security/outbound-policy.d.mts.map +1 -1
- package/esm/security/outbound-policy.mjs +79 -5
- package/esm/security/outbound-policy.mjs.map +1 -1
- package/esm/security/outbound-policy.type.d.mts +8 -0
- package/esm/security/outbound-policy.type.d.mts.map +1 -1
- package/esm/security/safe-merge.d.mts +52 -0
- package/esm/security/safe-merge.d.mts.map +1 -0
- package/esm/security/safe-merge.mjs +68 -0
- package/esm/security/safe-merge.mjs.map +1 -0
- package/esm/supervisor/decide.mjs +52 -5
- package/esm/supervisor/decide.mjs.map +1 -1
- package/esm/supervisor/execution.d.mts +22 -12
- package/esm/supervisor/execution.d.mts.map +1 -1
- package/esm/supervisor/execution.mjs +47 -24
- package/esm/supervisor/execution.mjs.map +1 -1
- package/esm/supervisor/supervisor.mjs +4 -0
- package/esm/supervisor/supervisor.mjs.map +1 -1
- package/llms-full.txt +185 -14
- package/llms.txt +5 -4
- package/package.json +24 -4
- package/skills/README.md +6 -2
- package/skills/attach-ai-middleware/SKILL.md +17 -1
- package/skills/generate-images/SKILL.md +11 -4
- package/skills/rag-loaders-and-stores/SKILL.md +3 -0
- package/skills/run-ai-agent/SKILL.md +3 -0
- package/skills/run-orchestrator/SKILL.md +6 -1
- package/skills/run-planner/SKILL.md +7 -3
- package/skills/run-supervisor/SKILL.md +11 -1
- package/skills/secure-outbound-requests/SKILL.md +85 -0
- package/skills/use-ai-memory/SKILL.md +36 -3
- 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
|
|
46
|
-
* scored {@link RecalledMemory}. Hits indexed
|
|
47
|
-
* namespace (a shared driver)
|
|
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
|
|
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
|
|
28
|
-
* + set would move it to the end and lie
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
46
|
-
return slice.map((
|
|
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 *
|
|
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":"
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
|
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";
|
package/esm/mock/index.d.mts
CHANGED
|
@@ -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?:
|
|
21
|
-
toolCalls?: ModelToolCallRequest[];
|
|
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":"
|
|
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"}
|