@animalabs/membrane 0.5.80 → 0.5.82

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 (172) hide show
  1. package/dist/cache-wire-receipt.d.ts +13 -0
  2. package/dist/cache-wire-receipt.d.ts.map +1 -0
  3. package/dist/cache-wire-receipt.js +108 -0
  4. package/dist/cache-wire-receipt.js.map +1 -0
  5. package/dist/context/index.d.ts +1 -1
  6. package/dist/context/index.d.ts.map +1 -1
  7. package/dist/context/index.js +1 -1
  8. package/dist/context/index.js.map +1 -1
  9. package/dist/context/process.d.ts +3 -1
  10. package/dist/context/process.d.ts.map +1 -1
  11. package/dist/context/process.js +230 -49
  12. package/dist/context/process.js.map +1 -1
  13. package/dist/context/types.d.ts +30 -0
  14. package/dist/context/types.d.ts.map +1 -1
  15. package/dist/context/types.js +16 -0
  16. package/dist/context/types.js.map +1 -1
  17. package/dist/floating-cache-marker.test.js +21 -0
  18. package/dist/floating-cache-marker.test.js.map +1 -1
  19. package/dist/formatters/anthropic-xml.d.ts +2 -0
  20. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  21. package/dist/formatters/anthropic-xml.js +64 -23
  22. package/dist/formatters/anthropic-xml.js.map +1 -1
  23. package/dist/formatters/native.d.ts.map +1 -1
  24. package/dist/formatters/native.js +16 -3
  25. package/dist/formatters/native.js.map +1 -1
  26. package/dist/formatters/normalize-tool-pairs.d.ts +7 -0
  27. package/dist/formatters/normalize-tool-pairs.d.ts.map +1 -1
  28. package/dist/formatters/normalize-tool-pairs.js +45 -0
  29. package/dist/formatters/normalize-tool-pairs.js.map +1 -1
  30. package/dist/formatters/types.d.ts +31 -0
  31. package/dist/formatters/types.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/membrane.d.ts +137 -8
  37. package/dist/membrane.d.ts.map +1 -1
  38. package/dist/membrane.js +823 -310
  39. package/dist/membrane.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts +37 -0
  41. package/dist/providers/anthropic.d.ts.map +1 -1
  42. package/dist/providers/anthropic.js +142 -25
  43. package/dist/providers/anthropic.js.map +1 -1
  44. package/dist/providers/bedrock.d.ts +8 -0
  45. package/dist/providers/bedrock.d.ts.map +1 -1
  46. package/dist/providers/bedrock.js +24 -1
  47. package/dist/providers/bedrock.js.map +1 -1
  48. package/dist/providers/gemini.d.ts +11 -0
  49. package/dist/providers/gemini.d.ts.map +1 -1
  50. package/dist/providers/gemini.js +104 -82
  51. package/dist/providers/gemini.js.map +1 -1
  52. package/dist/providers/index.d.ts +1 -1
  53. package/dist/providers/index.d.ts.map +1 -1
  54. package/dist/providers/index.js.map +1 -1
  55. package/dist/providers/mock.d.ts +2 -0
  56. package/dist/providers/mock.d.ts.map +1 -1
  57. package/dist/providers/mock.js +2 -0
  58. package/dist/providers/mock.js.map +1 -1
  59. package/dist/providers/openai-compatible.d.ts +7 -0
  60. package/dist/providers/openai-compatible.d.ts.map +1 -1
  61. package/dist/providers/openai-compatible.js +99 -48
  62. package/dist/providers/openai-compatible.js.map +1 -1
  63. package/dist/providers/openai-completions.d.ts +5 -0
  64. package/dist/providers/openai-completions.d.ts.map +1 -1
  65. package/dist/providers/openai-completions.js +107 -44
  66. package/dist/providers/openai-completions.js.map +1 -1
  67. package/dist/providers/openai-responses-api.d.ts +16 -0
  68. package/dist/providers/openai-responses-api.d.ts.map +1 -1
  69. package/dist/providers/openai-responses-api.js +38 -10
  70. package/dist/providers/openai-responses-api.js.map +1 -1
  71. package/dist/providers/openai-responses.d.ts +2 -0
  72. package/dist/providers/openai-responses.d.ts.map +1 -1
  73. package/dist/providers/openai-responses.js +8 -1
  74. package/dist/providers/openai-responses.js.map +1 -1
  75. package/dist/providers/openai.d.ts +7 -0
  76. package/dist/providers/openai.d.ts.map +1 -1
  77. package/dist/providers/openai.js +92 -44
  78. package/dist/providers/openai.js.map +1 -1
  79. package/dist/providers/openrouter.d.ts +9 -0
  80. package/dist/providers/openrouter.d.ts.map +1 -1
  81. package/dist/providers/openrouter.js +118 -62
  82. package/dist/providers/openrouter.js.map +1 -1
  83. package/dist/providers/utils.d.ts +74 -2
  84. package/dist/providers/utils.d.ts.map +1 -1
  85. package/dist/providers/utils.js +184 -3
  86. package/dist/providers/utils.js.map +1 -1
  87. package/dist/registry/default-pricing.d.ts +22 -0
  88. package/dist/registry/default-pricing.d.ts.map +1 -1
  89. package/dist/registry/default-pricing.js +162 -28
  90. package/dist/registry/default-pricing.js.map +1 -1
  91. package/dist/types/content.d.ts +13 -0
  92. package/dist/types/content.d.ts.map +1 -1
  93. package/dist/types/content.js.map +1 -1
  94. package/dist/types/errors.d.ts +15 -0
  95. package/dist/types/errors.d.ts.map +1 -1
  96. package/dist/types/errors.js +26 -0
  97. package/dist/types/errors.js.map +1 -1
  98. package/dist/types/index.d.ts +3 -3
  99. package/dist/types/index.d.ts.map +1 -1
  100. package/dist/types/index.js +1 -1
  101. package/dist/types/index.js.map +1 -1
  102. package/dist/types/provider.d.ts +50 -0
  103. package/dist/types/provider.d.ts.map +1 -1
  104. package/dist/types/request.d.ts +6 -0
  105. package/dist/types/request.d.ts.map +1 -1
  106. package/dist/types/response.d.ts +85 -4
  107. package/dist/types/response.d.ts.map +1 -1
  108. package/dist/types/response.js.map +1 -1
  109. package/dist/types/streaming.d.ts +6 -1
  110. package/dist/types/streaming.d.ts.map +1 -1
  111. package/dist/utils/cache-marker-budget.d.ts +91 -0
  112. package/dist/utils/cache-marker-budget.d.ts.map +1 -0
  113. package/dist/utils/cache-marker-budget.js +208 -0
  114. package/dist/utils/cache-marker-budget.js.map +1 -0
  115. package/dist/utils/cost.d.ts +22 -0
  116. package/dist/utils/cost.d.ts.map +1 -1
  117. package/dist/utils/cost.js +55 -0
  118. package/dist/utils/cost.js.map +1 -1
  119. package/dist/utils/thinking-carriers.d.ts +80 -0
  120. package/dist/utils/thinking-carriers.d.ts.map +1 -0
  121. package/dist/utils/thinking-carriers.js +115 -0
  122. package/dist/utils/thinking-carriers.js.map +1 -0
  123. package/dist/utils/tool-parser.d.ts +24 -0
  124. package/dist/utils/tool-parser.d.ts.map +1 -1
  125. package/dist/utils/tool-parser.js +367 -161
  126. package/dist/utils/tool-parser.js.map +1 -1
  127. package/dist/utils/usage.d.ts +71 -0
  128. package/dist/utils/usage.d.ts.map +1 -0
  129. package/dist/utils/usage.js +161 -0
  130. package/dist/utils/usage.js.map +1 -0
  131. package/dist/yielding-stream.d.ts +8 -0
  132. package/dist/yielding-stream.d.ts.map +1 -1
  133. package/dist/yielding-stream.js +49 -3
  134. package/dist/yielding-stream.js.map +1 -1
  135. package/package.json +1 -1
  136. package/src/cache-wire-receipt.ts +125 -0
  137. package/src/context/index.ts +1 -0
  138. package/src/context/process.ts +305 -59
  139. package/src/context/types.ts +38 -0
  140. package/src/floating-cache-marker.test.ts +24 -0
  141. package/src/formatters/anthropic-xml.ts +66 -23
  142. package/src/formatters/native.ts +23 -2
  143. package/src/formatters/normalize-tool-pairs.ts +49 -0
  144. package/src/formatters/types.ts +35 -0
  145. package/src/index.ts +6 -0
  146. package/src/membrane.ts +974 -313
  147. package/src/providers/anthropic.ts +173 -19
  148. package/src/providers/bedrock.ts +25 -1
  149. package/src/providers/gemini.ts +125 -87
  150. package/src/providers/index.ts +1 -0
  151. package/src/providers/mock.ts +3 -0
  152. package/src/providers/openai-compatible.ts +102 -45
  153. package/src/providers/openai-completions.ts +112 -46
  154. package/src/providers/openai-responses-api.ts +45 -15
  155. package/src/providers/openai-responses.ts +8 -1
  156. package/src/providers/openai.ts +95 -42
  157. package/src/providers/openrouter.ts +125 -63
  158. package/src/providers/utils.ts +227 -6
  159. package/src/registry/default-pricing.ts +165 -28
  160. package/src/types/content.ts +14 -0
  161. package/src/types/errors.ts +28 -0
  162. package/src/types/index.ts +6 -0
  163. package/src/types/provider.ts +55 -0
  164. package/src/types/request.ts +7 -0
  165. package/src/types/response.ts +109 -8
  166. package/src/types/streaming.ts +8 -2
  167. package/src/utils/cache-marker-budget.ts +237 -0
  168. package/src/utils/cost.ts +64 -0
  169. package/src/utils/thinking-carriers.ts +125 -0
  170. package/src/utils/tool-parser.ts +473 -174
  171. package/src/utils/usage.ts +184 -0
  172. package/src/yielding-stream.ts +54 -3
@@ -0,0 +1,13 @@
1
+ export interface CacheWireMarkerReceipt {
2
+ ordinal: number;
3
+ prefixHash: string;
4
+ estimatedOffset: number;
5
+ }
6
+ export interface CacheWireReceipt {
7
+ requestHash: string;
8
+ markers: CacheWireMarkerReceipt[];
9
+ }
10
+ /** Hash the exact post-format/post-hook provider request and every marked
11
+ * prefix. Token offsets are estimates; provider usage reconciles them later. */
12
+ export declare function computeCacheWireReceipt(rawRequest: unknown): CacheWireReceipt;
13
+ //# sourceMappingURL=cache-wire-receipt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache-wire-receipt.d.ts","sourceRoot":"","sources":["../src/cache-wire-receipt.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,sBAAsB,EAAE,CAAC;CACnC;AAED;gFACgF;AAChF,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,OAAO,GAAG,gBAAgB,CAkB7E"}
@@ -0,0 +1,108 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Hash the exact post-format/post-hook provider request and every marked
3
+ * prefix. Token offsets are estimates; provider usage reconciles them later. */
4
+ export function computeCacheWireReceipt(rawRequest) {
5
+ const requestHash = sha(stableStringify(rawRequest));
6
+ const blocks = flattenWireBlocks(rawRequest);
7
+ const prefix = [];
8
+ const markers = [];
9
+ let chars = 0;
10
+ for (const unit of blocks) {
11
+ prefix.push(unit.identity);
12
+ chars += unit.estimatedChars;
13
+ if (unit.marked) {
14
+ markers.push({
15
+ ordinal: markers.length,
16
+ prefixHash: sha(stableStringify(prefix)),
17
+ estimatedOffset: Math.ceil(chars / 4),
18
+ });
19
+ }
20
+ }
21
+ return { requestHash, markers };
22
+ }
23
+ function flattenWireBlocks(rawRequest) {
24
+ if (!rawRequest || typeof rawRequest !== 'object') {
25
+ return [{ identity: rawRequest, marked: false, estimatedChars: stableStringify(rawRequest).length }];
26
+ }
27
+ const request = rawRequest;
28
+ const out = [];
29
+ if (Array.isArray(request.tools)) {
30
+ request.tools.forEach((tool, index) => out.push({
31
+ identity: { surface: 'tool', index, tool },
32
+ marked: hasCacheControl(tool),
33
+ estimatedChars: stableStringify(tool).length,
34
+ }));
35
+ }
36
+ else if (request.tools !== undefined) {
37
+ out.push({
38
+ identity: { surface: 'tools', value: request.tools },
39
+ marked: hasCacheControl(request.tools),
40
+ estimatedChars: stableStringify(request.tools).length,
41
+ });
42
+ }
43
+ if (request.system !== undefined) {
44
+ const system = request.system;
45
+ if (Array.isArray(system)) {
46
+ system.forEach((block, index) => out.push({
47
+ identity: { surface: 'system', index, block },
48
+ marked: hasCacheControl(block),
49
+ estimatedChars: stableStringify(block).length,
50
+ }));
51
+ }
52
+ else {
53
+ out.push({
54
+ identity: { surface: 'system', value: system },
55
+ marked: hasCacheControl(system),
56
+ estimatedChars: stableStringify(system).length,
57
+ });
58
+ }
59
+ }
60
+ if (Array.isArray(request.messages)) {
61
+ request.messages.forEach((message, messageIndex) => {
62
+ if (!message || typeof message !== 'object') {
63
+ out.push({
64
+ identity: { surface: 'message', messageIndex, value: message },
65
+ marked: false,
66
+ estimatedChars: stableStringify(message).length,
67
+ });
68
+ return;
69
+ }
70
+ const record = message;
71
+ const content = message.content;
72
+ if (Array.isArray(content)) {
73
+ content.forEach((block, blockIndex) => out.push({
74
+ identity: {
75
+ surface: 'message-block', messageIndex, blockIndex,
76
+ role: record.role, block,
77
+ },
78
+ marked: hasCacheControl(block),
79
+ estimatedChars: stableStringify({ role: record.role, content: [block] }).length,
80
+ }));
81
+ }
82
+ else {
83
+ out.push({
84
+ identity: { surface: 'message', messageIndex, role: record.role, content },
85
+ marked: hasCacheControl(message),
86
+ estimatedChars: stableStringify(message).length,
87
+ });
88
+ }
89
+ });
90
+ }
91
+ return out;
92
+ }
93
+ function hasCacheControl(value) {
94
+ return Boolean(value && typeof value === 'object' && value.cache_control);
95
+ }
96
+ function stableStringify(value) {
97
+ if (Array.isArray(value))
98
+ return `[${value.map(stableStringify).join(',')}]`;
99
+ if (value && typeof value === 'object') {
100
+ const record = value;
101
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(',')}}`;
102
+ }
103
+ return JSON.stringify(value) ?? 'null';
104
+ }
105
+ function sha(value) {
106
+ return createHash('sha256').update(value).digest('hex');
107
+ }
108
+ //# sourceMappingURL=cache-wire-receipt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache-wire-receipt.js","sourceRoot":"","sources":["../src/cache-wire-receipt.ts"],"names":[],"mappings":"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"}
@@ -7,6 +7,6 @@
7
7
  export { processContext } from './process.js';
8
8
  export { shouldRoll, truncateMessages, placeCacheMarkers, applyCacheMarkers, calculateCharacters, } from './process.js';
9
9
  export type { RollDecision, MessageWithTokens, } from './process.js';
10
- export { createInitialState, defaultTokenEstimator, DEFAULT_CONTEXT_CONFIG, } from './types.js';
10
+ export { createInitialState, defaultTokenEstimator, DEFAULT_CONTEXT_CONFIG, MembraneContextIdentityError, } from './types.js';
11
11
  export type { ContextInput, ContextOutput, ContextState, ContextConfig, ContextInfo, ContextStreamOptions, ContextToolCallback, ContextPreToolCallback, CacheMarker, } from './types.js';
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/context/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,EACL,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,YAAY,EACV,YAAY,EACZ,iBAAiB,GAClB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,sBAAsB,GACvB,MAAM,YAAY,CAAC;AAEpB,YAAY,EACV,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,aAAa,EACb,WAAW,EACX,oBAAoB,EACpB,mBAAmB,EACnB,sBAAsB,EACtB,WAAW,GACZ,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/context/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,EACL,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,YAAY,EACV,YAAY,EACZ,iBAAiB,GAClB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,sBAAsB,EACtB,4BAA4B,GAC7B,MAAM,YAAY,CAAC;AAEpB,YAAY,EACV,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,aAAa,EACb,WAAW,EACX,oBAAoB,EACpB,mBAAmB,EACnB,sBAAsB,EACtB,WAAW,GACZ,MAAM,YAAY,CAAC"}
@@ -7,5 +7,5 @@
7
7
  export { processContext } from './process.js';
8
8
  // Rolling helpers - can be used standalone by callers doing their own transforms
9
9
  export { shouldRoll, truncateMessages, placeCacheMarkers, applyCacheMarkers, calculateCharacters, } from './process.js';
10
- export { createInitialState, defaultTokenEstimator, DEFAULT_CONTEXT_CONFIG, } from './types.js';
10
+ export { createInitialState, defaultTokenEstimator, DEFAULT_CONTEXT_CONFIG, MembraneContextIdentityError, } from './types.js';
11
11
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/context/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,iFAAiF;AACjF,OAAO,EACL,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAOtB,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/context/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,iFAAiF;AACjF,OAAO,EACL,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAOtB,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,sBAAsB,EACtB,4BAA4B,GAC7B,MAAM,YAAY,CAAC"}
@@ -26,6 +26,8 @@ export interface RollDecision {
26
26
  reason?: 'threshold' | 'grace_exceeded' | 'hard_limit';
27
27
  targetTokens?: number;
28
28
  targetMessages?: number;
29
+ /** Character budget the kept window must fit (set by the maxCharacters limit). */
30
+ targetCharacters?: number;
29
31
  enteredGrace: boolean;
30
32
  }
31
33
  export declare function shouldRoll(state: ContextState, messageCount: number, totalTokens: number, totalCharacters: number, config: ContextConfig): RollDecision;
@@ -34,7 +36,7 @@ export interface MessageWithTokens {
34
36
  tokens: number;
35
37
  id: string;
36
38
  }
37
- export declare function truncateMessages(messages: MessageWithTokens[], targetTokens?: number, targetMessages?: number, config?: ContextConfig): {
39
+ export declare function truncateMessages(messages: MessageWithTokens[], targetTokens?: number, targetMessages?: number, config?: ContextConfig, targetCharacters?: number): {
38
40
  kept: MessageWithTokens[];
39
41
  dropped: number;
40
42
  };
@@ -1 +1 @@
1
- {"version":3,"file":"process.d.ts","sourceRoot":"","sources":["../../src/context/process.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,EAAE,iBAAiB,EAAqB,MAAM,mBAAmB,CAAC;AAC9E,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EACZ,aAAa,EAEb,aAAa,EACb,oBAAoB,EACpB,WAAW,EACZ,MAAM,YAAY,CAAC;AAWpB;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,cAAc,CAClC,QAAQ,EAAE,QAAQ,EAClB,KAAK,EAAE,YAAY,EACnB,KAAK,EAAE,YAAY,GAAG,IAAI,EAC1B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,OAAO,CAAC,aAAa,CAAC,CAgIxB;AA2CD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,iBAAiB,EAAE,GAAG,MAAM,CAgBzE;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,WAAW,GAAG,gBAAgB,GAAG,YAAY,CAAC;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,YAAY,EACnB,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,eAAe,EAAE,MAAM,EACvB,MAAM,EAAE,aAAa,GACpB,YAAY,CAiEd;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,YAAY,CAAC,EAAE,MAAM,EACrB,cAAc,CAAC,EAAE,MAAM,EACvB,MAAM,CAAC,EAAE,aAAa,GACrB;IAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CA+ChD;AAED,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,aAAa,EAAE,iBAAiB,EAAE,EAClC,KAAK,EAAE,YAAY,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,aAAa,GACpB,WAAW,EAAE,CAqGf;AA4BD,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,YAAY,EAAE,WAAW,EAAE,GAC1B,iBAAiB,EAAE,CAmBrB"}
1
+ {"version":3,"file":"process.d.ts","sourceRoot":"","sources":["../../src/context/process.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,EAAE,iBAAiB,EAAqB,MAAM,mBAAmB,CAAC;AAC9E,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EACZ,aAAa,EAEb,aAAa,EACb,oBAAoB,EACpB,WAAW,EACZ,MAAM,YAAY,CAAC;AA6BpB;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,cAAc,CAClC,QAAQ,EAAE,QAAQ,EAClB,KAAK,EAAE,YAAY,EACnB,KAAK,EAAE,YAAY,GAAG,IAAI,EAC1B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,OAAO,CAAC,aAAa,CAAC,CAkJxB;AAuGD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,iBAAiB,EAAE,GAAG,MAAM,CAgBzE;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,WAAW,GAAG,gBAAgB,GAAG,YAAY,CAAC;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kFAAkF;IAClF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,YAAY,EACnB,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,eAAe,EAAE,MAAM,EACvB,MAAM,EAAE,aAAa,GACpB,YAAY,CAuEd;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,YAAY,CAAC,EAAE,MAAM,EACrB,cAAc,CAAC,EAAE,MAAM,EACvB,MAAM,CAAC,EAAE,aAAa,EACtB,gBAAgB,CAAC,EAAE,MAAM,GACxB;IAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAkDhD;AA0GD,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,aAAa,EAAE,iBAAiB,EAAE,EAClC,KAAK,EAAE,YAAY,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,aAAa,GACpB,WAAW,EAAE,CAqHf;AA0CD,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,YAAY,EAAE,WAAW,EAAE,GAC1B,iBAAiB,EAAE,CAuBrB"}
@@ -1,7 +1,23 @@
1
1
  /**
2
2
  * Context processing - main entry point
3
3
  */
4
- import { createInitialState, defaultTokenEstimator, DEFAULT_CONTEXT_CONFIG, } from './types.js';
4
+ import { createInitialState, defaultTokenEstimator, DEFAULT_CONTEXT_CONFIG, MembraneContextIdentityError, } from './types.js';
5
+ /**
6
+ * Ceiling on markers this module places, regardless of `cache.points`.
7
+ *
8
+ * Anthropic accepts at most 4 `cache_control` blocks per request, and the
9
+ * request builders spend from that same budget (a system/tools fallback
10
+ * block, a `contextPrefix` block when that option is set, the floating
11
+ * tool-loop marker). Nothing reconciles those spends against
12
+ * `cache.points`, so the module keeps one slot free rather than risk a 400
13
+ * on the default XML path, which always marks the system block. A caller
14
+ * combining this module with MORE than one formatter spend (e.g. system
15
+ * prompt AND `contextPrefix`) can still exceed the provider budget: the
16
+ * module cannot see the formatter's choices from here, which is why the
17
+ * request-side wire clamp (the cache-marker budget PR) is the layer that
18
+ * makes the ceiling safe by construction rather than by convention.
19
+ */
20
+ const MAX_MODULE_CACHE_POINTS = 3;
5
21
  // ============================================================================
6
22
  // Main Entry Point
7
23
  // ============================================================================
@@ -24,6 +40,9 @@ export async function processContext(membrane, input, state, options) {
24
40
  // Merge config with defaults
25
41
  const contextConfig = mergeConfig(input.context);
26
42
  const tokenEstimator = contextConfig.tokenEstimator ?? defaultTokenEstimator;
43
+ // Stable identity is a precondition, not a nicety: without it every call
44
+ // looks like a new conversation and rolling/caching silently stop working.
45
+ assertStableMessageIds(input.messages);
27
46
  // Initialize or continue state
28
47
  let currentState = state ?? createInitialState();
29
48
  // Detect discontinuity (new conversation or branch switch)
@@ -47,10 +66,13 @@ export async function processContext(membrane, input, state, options) {
47
66
  let didRoll = false;
48
67
  let hardLimitHit = false;
49
68
  if (rollDecision.shouldRoll) {
50
- const truncateResult = truncateMessages(messageTokens, rollDecision.targetTokens, rollDecision.targetMessages, contextConfig);
69
+ const truncateResult = truncateMessages(messageTokens, rollDecision.targetTokens, rollDecision.targetMessages, contextConfig, rollDecision.targetCharacters);
51
70
  keptMessages = truncateResult.kept.map(m => m.message);
52
71
  messagesDropped = truncateResult.dropped;
53
- didRoll = true;
72
+ // A roll that dropped nothing is not a roll: reporting it as one both
73
+ // lies to the caller and resets the roll counters every call, which is
74
+ // exactly when threshold rolling is needed most.
75
+ didRoll = messagesDropped > 0;
54
76
  hardLimitHit = rollDecision.reason === 'hard_limit';
55
77
  }
56
78
  // Recalculate tokens after truncation
@@ -60,6 +82,10 @@ export async function processContext(membrane, input, state, options) {
60
82
  id: getMessageId(m),
61
83
  }));
62
84
  const keptTotalTokens = keptTokens.reduce((sum, m) => sum + m.tokens, 0);
85
+ // Re-assert the hard limits against the truncated window. The window is
86
+ // floored at one message, so a single oversize message survives every
87
+ // truncation - the caller is told rather than handed an empty array.
88
+ const residualOverflow = measureResidualOverflow(keptMessages, keptTotalTokens, contextConfig);
63
89
  // Place cache markers
64
90
  const cacheMarkers = placeCacheMarkers(keptMessages, keptTokens, currentState, didRoll, contextConfig);
65
91
  // Apply cache markers to messages
@@ -111,6 +137,7 @@ export async function processContext(membrane, input, state, options) {
111
137
  totalTokens: keptTotalTokens,
112
138
  hardLimitHit,
113
139
  cachedStartMessageId,
140
+ ...(residualOverflow ? { residualOverflow } : {}),
114
141
  };
115
142
  return { response, state: newState, info };
116
143
  }
@@ -118,7 +145,13 @@ export async function processContext(membrane, input, state, options) {
118
145
  // Helper Functions
119
146
  // ============================================================================
120
147
  function mergeConfig(config) {
148
+ // The caller's config is the base, so a top-level field is carried through
149
+ // by default and only the three sub-objects that have defaults are merged.
150
+ // Enumerating the survivors instead silently dropped assistantParticipant:
151
+ // every helper test passed a config straight in, so the loss was invisible
152
+ // until an end-to-end call classified the configured assistant as a user.
121
153
  return {
154
+ ...config,
122
155
  rolling: {
123
156
  ...DEFAULT_CONTEXT_CONFIG.rolling,
124
157
  ...config.rolling,
@@ -131,12 +164,50 @@ function mergeConfig(config) {
131
164
  ...DEFAULT_CONTEXT_CONFIG.cache,
132
165
  ...config.cache,
133
166
  },
134
- tokenEstimator: config.tokenEstimator,
135
167
  };
136
168
  }
137
169
  function getMessageId(message) {
138
170
  return message.metadata?.sourceId ?? `msg-${Math.random().toString(36).slice(2)}`;
139
171
  }
172
+ function assertStableMessageIds(messages) {
173
+ const messageIndicesWithoutSourceId = [];
174
+ messages.forEach((message, index) => {
175
+ const sourceId = message.metadata?.sourceId;
176
+ if (typeof sourceId !== 'string' || sourceId.length === 0) {
177
+ messageIndicesWithoutSourceId.push(index);
178
+ }
179
+ });
180
+ if (messageIndicesWithoutSourceId.length === 0) {
181
+ return;
182
+ }
183
+ const shown = messageIndicesWithoutSourceId.slice(0, 10).join(', ');
184
+ const ellipsis = messageIndicesWithoutSourceId.length > 10 ? ', ...' : '';
185
+ throw new MembraneContextIdentityError(`processContext requires stable message identity: ` +
186
+ `${messageIndicesWithoutSourceId.length} of ${messages.length} messages carry no ` +
187
+ `metadata.sourceId (indices ${shown}${ellipsis}). Without it every call is detected ` +
188
+ `as a new conversation, so the roll threshold never accumulates, cache markers never ` +
189
+ `stay stable, and cachedStartMessageId is meaningless. Populate metadata.sourceId ` +
190
+ `with the originating system's message id.`, messageIndicesWithoutSourceId);
191
+ }
192
+ function measureResidualOverflow(keptMessages, keptTotalTokens, config) {
193
+ const limits = config.limits;
194
+ if (!limits) {
195
+ return undefined;
196
+ }
197
+ if (limits.maxCharacters) {
198
+ const keptCharacters = calculateCharacters(keptMessages);
199
+ if (keptCharacters > limits.maxCharacters) {
200
+ return { unit: 'characters', limit: limits.maxCharacters, actual: keptCharacters };
201
+ }
202
+ }
203
+ if (limits.maxTokens && keptTotalTokens > limits.maxTokens) {
204
+ return { unit: 'tokens', limit: limits.maxTokens, actual: keptTotalTokens };
205
+ }
206
+ if (limits.maxMessages && keptMessages.length > limits.maxMessages) {
207
+ return { unit: 'messages', limit: limits.maxMessages, actual: keptMessages.length };
208
+ }
209
+ return undefined;
210
+ }
140
211
  function detectDiscontinuity(messages, state) {
141
212
  if (state.windowMessageIds.length === 0) {
142
213
  return false; // First call, not a discontinuity
@@ -177,6 +248,7 @@ export function shouldRoll(state, messageCount, totalTokens, totalCharacters, co
177
248
  reason: 'hard_limit',
178
249
  targetTokens: limits.maxTokens,
179
250
  targetMessages: limits.maxMessages,
251
+ targetCharacters: limits.maxCharacters,
180
252
  enteredGrace: false,
181
253
  };
182
254
  }
@@ -186,6 +258,7 @@ export function shouldRoll(state, messageCount, totalTokens, totalCharacters, co
186
258
  reason: 'hard_limit',
187
259
  targetTokens: limits.maxTokens,
188
260
  targetMessages: limits.maxMessages,
261
+ targetCharacters: limits.maxCharacters,
189
262
  enteredGrace: false,
190
263
  };
191
264
  }
@@ -195,11 +268,15 @@ export function shouldRoll(state, messageCount, totalTokens, totalCharacters, co
195
268
  reason: 'hard_limit',
196
269
  targetTokens: limits.maxTokens,
197
270
  targetMessages: limits.maxMessages,
271
+ targetCharacters: limits.maxCharacters,
198
272
  enteredGrace: false,
199
273
  };
200
274
  }
201
- // Check rolling threshold
202
- const current = unit === 'messages' ? state.messagesSinceRoll : state.tokensSinceRoll;
275
+ // Check rolling threshold against the MEASURED window. state.messagesSinceRoll
276
+ // counts calls (not messages) and state.tokensSinceRoll re-adds the whole
277
+ // window every call, so both cross any threshold as a function of call count
278
+ // alone; they stay as telemetry and no longer decide the roll.
279
+ const current = unit === 'messages' ? messageCount : totalTokens;
203
280
  if (current >= maxThreshold) {
204
281
  // Exceeded grace, must roll
205
282
  return {
@@ -222,53 +299,132 @@ export function shouldRoll(state, messageCount, totalTokens, totalCharacters, co
222
299
  enteredGrace: false,
223
300
  };
224
301
  }
225
- export function truncateMessages(messages, targetTokens, targetMessages, config) {
226
- // Truncate from the beginning, keeping most recent
227
- if (targetMessages && messages.length > targetMessages) {
228
- const startIdx = messages.length - targetMessages;
229
- return {
230
- kept: messages.slice(startIdx),
231
- dropped: startIdx,
232
- };
302
+ export function truncateMessages(messages, targetTokens, targetMessages, config, targetCharacters) {
303
+ // Truncate from the beginning, keeping most recent.
304
+ // Every supplied target contributes a candidate start index; the window has
305
+ // to satisfy all of them, so the deepest cut wins.
306
+ if (messages.length === 0) {
307
+ return { kept: messages, dropped: 0 };
308
+ }
309
+ const candidateStartIndices = [];
310
+ if (targetMessages !== undefined && messages.length > targetMessages) {
311
+ candidateStartIndices.push(messages.length - targetMessages);
312
+ }
313
+ if (targetTokens !== undefined) {
314
+ candidateStartIndices.push(startIndexForBudget(messages, targetTokens, m => m.tokens));
315
+ }
316
+ if (targetCharacters !== undefined) {
317
+ candidateStartIndices.push(startIndexForBudget(messages, targetCharacters, m => calculateCharacters([m.message])));
233
318
  }
234
- if (targetTokens) {
235
- let tokenSum = 0;
236
- let startIdx = messages.length;
237
- // Count from end backwards
238
- for (let i = messages.length - 1; i >= 0; i--) {
239
- tokenSum += messages[i].tokens;
240
- if (tokenSum > targetTokens) {
241
- startIdx = i + 1;
242
- break;
319
+ if (candidateStartIndices.length === 0) {
320
+ // Default: use buffer from config
321
+ const buffer = config?.rolling.buffer ?? 20;
322
+ const unit = config?.rolling.unit ?? 'messages';
323
+ if (unit === 'messages') {
324
+ const targetCount = Math.max(buffer * 2, messages.length - buffer);
325
+ if (messages.length > targetCount) {
326
+ candidateStartIndices.push(messages.length - targetCount);
243
327
  }
244
- startIdx = i;
245
328
  }
246
- return {
247
- kept: messages.slice(startIdx),
248
- dropped: startIdx,
249
- };
250
329
  }
251
- // Default: use buffer from config
252
- const buffer = config?.rolling.buffer ?? 20;
253
- const unit = config?.rolling.unit ?? 'messages';
254
- if (unit === 'messages') {
255
- const targetCount = Math.max(buffer * 2, messages.length - buffer);
256
- if (messages.length > targetCount) {
257
- const startIdx = messages.length - targetCount;
258
- return {
259
- kept: messages.slice(startIdx),
260
- dropped: startIdx,
261
- };
330
+ if (candidateStartIndices.length === 0) {
331
+ return { kept: messages, dropped: 0 };
332
+ }
333
+ const startIdx = resolveWindowStart(messages, Math.max(0, ...candidateStartIndices));
334
+ return {
335
+ kept: messages.slice(startIdx),
336
+ dropped: startIdx,
337
+ };
338
+ }
339
+ /**
340
+ * Walk backwards from the newest message, accumulating cost, and return the
341
+ * first index whose window fits the budget. Mirrors the original token walk:
342
+ * the message that tips the sum past the budget is excluded.
343
+ */
344
+ function startIndexForBudget(messages, budget, costOf) {
345
+ let sum = 0;
346
+ let startIdx = messages.length;
347
+ for (let i = messages.length - 1; i >= 0; i--) {
348
+ sum += costOf(messages[i]);
349
+ if (sum > budget) {
350
+ return i + 1;
351
+ }
352
+ startIdx = i;
353
+ }
354
+ return startIdx;
355
+ }
356
+ /**
357
+ * Resolve the index the kept window actually starts at: the deepest cut at or
358
+ * after the requested one whose window is both non-empty and free of
359
+ * tool_results whose tool_use was dropped.
360
+ *
361
+ * Snapping forward and flooring at one message used to be two independent
362
+ * steps that could undo each other. When the newest message is a tool_result,
363
+ * the forward snap runs off the end of the array and the floor pulls the index
364
+ * back onto that same orphan - so truncating a final tool cycle to one message
365
+ * kept exactly the result, which is what the snap exists to prevent. A cut that
366
+ * cannot go forward now walks BACKWARD to the call that opens the final cycle:
367
+ * the window overshoots the requested size (reported through
368
+ * ContextInfo.residualOverflow) rather than shipping a corrupt transcript.
369
+ */
370
+ function resolveWindowStart(messages, desiredStartIdx) {
371
+ const lastIdx = messages.length - 1;
372
+ if (desiredStartIdx <= 0) {
373
+ return 0;
374
+ }
375
+ const windowIsCleanAt = markCleanWindowStarts(messages);
376
+ const cappedStartIdx = Math.min(desiredStartIdx, lastIdx);
377
+ for (let idx = cappedStartIdx; idx <= lastIdx; idx++) {
378
+ if (windowIsCleanAt[idx]) {
379
+ return idx;
380
+ }
381
+ }
382
+ for (let idx = cappedStartIdx - 1; idx >= 0; idx--) {
383
+ if (windowIsCleanAt[idx]) {
384
+ return idx;
385
+ }
386
+ }
387
+ // No clean boundary exists anywhere: the supplied history itself carries a
388
+ // tool_result whose tool_use is not in it. Dropping messages cannot repair
389
+ // that, so the requested cut stands, floored at one message.
390
+ return cappedStartIdx;
391
+ }
392
+ /**
393
+ * For every index, whether the window starting there contains no tool_result
394
+ * whose tool_use is missing from it. A result is matched by a tool_use in an
395
+ * earlier message or earlier in its own message, so one backward pass carrying
396
+ * the still-unmatched result ids answers every index in linear time.
397
+ */
398
+ function markCleanWindowStarts(messages) {
399
+ const windowIsCleanAt = new Array(messages.length).fill(false);
400
+ const resultIdsAwaitingTheirCall = new Set();
401
+ for (let idx = messages.length - 1; idx >= 0; idx--) {
402
+ const callIdsInMessage = new Set();
403
+ const resultIdsNeedingAnEarlierCall = [];
404
+ for (const block of messages[idx].message.content) {
405
+ if (block.type === 'tool_use') {
406
+ callIdsInMessage.add(block.id);
407
+ }
408
+ else if (block.type === 'tool_result' && !callIdsInMessage.has(block.toolUseId)) {
409
+ resultIdsNeedingAnEarlierCall.push(block.toolUseId);
410
+ }
411
+ }
412
+ for (const callId of callIdsInMessage) {
413
+ resultIdsAwaitingTheirCall.delete(callId);
414
+ }
415
+ for (const resultId of resultIdsNeedingAnEarlierCall) {
416
+ resultIdsAwaitingTheirCall.add(resultId);
262
417
  }
418
+ windowIsCleanAt[idx] = resultIdsAwaitingTheirCall.size === 0;
263
419
  }
264
- return { kept: messages, dropped: 0 };
420
+ return windowIsCleanAt;
265
421
  }
266
422
  export function placeCacheMarkers(messages, messageTokens, state, didRoll, config) {
267
423
  const cacheConfig = config.cache ?? {};
268
424
  if (cacheConfig.enabled === false) {
269
425
  return [];
270
426
  }
271
- const numPoints = cacheConfig.points ?? 1;
427
+ const numPoints = Math.min(cacheConfig.points ?? 1, MAX_MODULE_CACHE_POINTS);
272
428
  const minTokens = cacheConfig.minTokens ?? 1024;
273
429
  const preferUser = cacheConfig.preferUserMessages ?? true;
274
430
  const totalTokens = messageTokens.reduce((sum, m) => sum + m.tokens, 0);
@@ -281,8 +437,16 @@ export function placeCacheMarkers(messages, messageTokens, state, didRoll, confi
281
437
  const currentIds = new Set(messages.map(m => getMessageId(m)));
282
438
  const validMarkers = state.cacheMarkers.filter(m => currentIds.has(m.messageId));
283
439
  if (validMarkers.length > 0) {
284
- // Recalculate token estimates for valid markers
285
- return validMarkers.map(marker => {
440
+ // Recalculate token estimates for valid markers, then apply the same cap
441
+ // fresh placement obeys. The cap is a property of the REQUEST (Anthropic
442
+ // accepts four cache_control blocks and the builders spend from the same
443
+ // budget), so honouring it only on the fresh path let a state written
444
+ // before the cap existed - or by a caller that asked for four points -
445
+ // keep re-spending four slots on every subsequent call, which is where
446
+ // the builders' own marker turns the request into a 400. The deepest
447
+ // markers cover the longest prefixes, so those are the ones kept.
448
+ const retainedMarkers = validMarkers
449
+ .map(marker => {
286
450
  const idx = messages.findIndex(m => getMessageId(m) === marker.messageId);
287
451
  const tokenEstimate = messageTokens
288
452
  .slice(0, idx + 1)
@@ -292,7 +456,9 @@ export function placeCacheMarkers(messages, messageTokens, state, didRoll, confi
292
456
  messageIndex: idx,
293
457
  tokenEstimate,
294
458
  };
295
- });
459
+ })
460
+ .sort((a, b) => a.messageIndex - b.messageIndex);
461
+ return retainedMarkers.slice(-MAX_MODULE_CACHE_POINTS);
296
462
  }
297
463
  }
298
464
  // Place new markers using arithmetic positioning
@@ -322,7 +488,7 @@ export function placeCacheMarkers(messages, messageTokens, state, didRoll, confi
322
488
  let markerTokens = runningTokens;
323
489
  // Adjust to user message if preferred
324
490
  if (preferUser) {
325
- const adjusted = findNearestUserMessage(messages, markerIdx, messageTokens);
491
+ const adjusted = findNearestUserMessage(messages, markerIdx, messageTokens, config.assistantParticipant);
326
492
  if (adjusted) {
327
493
  markerIdx = adjusted.index;
328
494
  markerTokens = adjusted.tokens;
@@ -344,15 +510,26 @@ export function placeCacheMarkers(messages, messageTokens, state, didRoll, confi
344
510
  }
345
511
  return markers;
346
512
  }
347
- function findNearestUserMessage(messages, startIdx, messageTokens) {
513
+ /** Assistant names assumed when the deployment configures none. */
514
+ const LEGACY_ASSISTANT_PARTICIPANTS = ['claude', 'assistant', 'bot', 'ai'];
515
+ function findNearestUserMessage(messages, startIdx, messageTokens, assistantParticipant) {
348
516
  // Search backwards for a user message (non-assistant participant)
349
517
  const maxSearch = 5;
518
+ // A deployment whose assistant is named anything else (Sol, a persona name)
519
+ // had every assistant turn classified as a user turn by the legacy list.
520
+ const assistantNames = assistantParticipant
521
+ ? [assistantParticipant.toLowerCase()]
522
+ : LEGACY_ASSISTANT_PARTICIPANTS;
350
523
  let tokens = messageTokens.slice(0, startIdx + 1).reduce((sum, m) => sum + m.tokens, 0);
351
524
  for (let i = startIdx; i >= Math.max(0, startIdx - maxSearch); i--) {
352
525
  const msg = messages[i];
353
- // Heuristic: if participant isn't a common assistant name, it's probably a user
354
- const participant = msg.participant.toLowerCase();
355
- const isUser = !['claude', 'assistant', 'bot', 'ai'].includes(participant);
526
+ // Heuristic: if participant isn't a known assistant name, it's probably a
527
+ // user. A message with no participant at all (role-shaped producers) falls
528
+ // through the same way rather than crashing the whole call.
529
+ const participant = typeof msg.participant === 'string'
530
+ ? msg.participant.toLowerCase()
531
+ : '';
532
+ const isUser = !assistantNames.includes(participant);
356
533
  if (isUser) {
357
534
  return { index: i, tokens };
358
535
  }
@@ -369,6 +546,10 @@ export function applyCacheMarkers(messages, cacheMarkers) {
369
546
  if (markerIndices.has(idx)) {
370
547
  return {
371
548
  ...msg,
549
+ // cacheBreakpoint is the field every request builder reads; the
550
+ // metadata.cacheControl write has no reader inside membrane and is
551
+ // kept only for external consumers that may already read it.
552
+ cacheBreakpoint: true,
372
553
  metadata: {
373
554
  ...msg.metadata,
374
555
  cacheControl: { type: 'ephemeral' },