@trazum/core 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/advisories.d.ts +23 -0
  4. package/dist/advisories.d.ts.map +1 -0
  5. package/dist/advisories.js +376 -0
  6. package/dist/advisories.js.map +1 -0
  7. package/dist/aws-sigv4.d.ts +88 -0
  8. package/dist/aws-sigv4.d.ts.map +1 -0
  9. package/dist/aws-sigv4.js +117 -0
  10. package/dist/aws-sigv4.js.map +1 -0
  11. package/dist/baseline.d.ts +171 -0
  12. package/dist/baseline.d.ts.map +1 -0
  13. package/dist/baseline.js +273 -0
  14. package/dist/baseline.js.map +1 -0
  15. package/dist/cache.d.ts +26 -0
  16. package/dist/cache.d.ts.map +1 -0
  17. package/dist/cache.js +28 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/changes.d.ts +29 -0
  20. package/dist/changes.d.ts.map +1 -0
  21. package/dist/changes.js +142 -0
  22. package/dist/changes.js.map +1 -0
  23. package/dist/compare.d.ts +65 -0
  24. package/dist/compare.d.ts.map +1 -0
  25. package/dist/compare.js +58 -0
  26. package/dist/compare.js.map +1 -0
  27. package/dist/config-schema.d.ts +118 -0
  28. package/dist/config-schema.d.ts.map +1 -0
  29. package/dist/config-schema.js +315 -0
  30. package/dist/config-schema.js.map +1 -0
  31. package/dist/config.d.ts +47 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +112 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/detect.d.ts +70 -0
  36. package/dist/detect.d.ts.map +1 -0
  37. package/dist/detect.js +228 -0
  38. package/dist/detect.js.map +1 -0
  39. package/dist/evaluate.d.ts +98 -0
  40. package/dist/evaluate.d.ts.map +1 -0
  41. package/dist/evaluate.js +110 -0
  42. package/dist/evaluate.js.map +1 -0
  43. package/dist/extract.d.ts +81 -0
  44. package/dist/extract.d.ts.map +1 -0
  45. package/dist/extract.js +280 -0
  46. package/dist/extract.js.map +1 -0
  47. package/dist/gcp-auth.d.ts +58 -0
  48. package/dist/gcp-auth.d.ts.map +1 -0
  49. package/dist/gcp-auth.js +113 -0
  50. package/dist/gcp-auth.js.map +1 -0
  51. package/dist/glob.d.ts +49 -0
  52. package/dist/glob.d.ts.map +1 -0
  53. package/dist/glob.js +154 -0
  54. package/dist/glob.js.map +1 -0
  55. package/dist/host.d.ts +30 -0
  56. package/dist/host.d.ts.map +1 -0
  57. package/dist/host.js +69 -0
  58. package/dist/host.js.map +1 -0
  59. package/dist/i18n/en.d.ts +4 -0
  60. package/dist/i18n/en.d.ts.map +1 -0
  61. package/dist/i18n/en.js +168 -0
  62. package/dist/i18n/en.js.map +1 -0
  63. package/dist/i18n/es.d.ts +4 -0
  64. package/dist/i18n/es.d.ts.map +1 -0
  65. package/dist/i18n/es.js +168 -0
  66. package/dist/i18n/es.js.map +1 -0
  67. package/dist/i18n/index.d.ts +36 -0
  68. package/dist/i18n/index.d.ts.map +1 -0
  69. package/dist/i18n/index.js +50 -0
  70. package/dist/i18n/index.js.map +1 -0
  71. package/dist/i18n/types.d.ts +180 -0
  72. package/dist/i18n/types.d.ts.map +1 -0
  73. package/dist/i18n/types.js +11 -0
  74. package/dist/i18n/types.js.map +1 -0
  75. package/dist/index.d.ts +66 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +69 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/llm.d.ts +226 -0
  80. package/dist/llm.d.ts.map +1 -0
  81. package/dist/llm.js +485 -0
  82. package/dist/llm.js.map +1 -0
  83. package/dist/nearest.d.ts +20 -0
  84. package/dist/nearest.d.ts.map +1 -0
  85. package/dist/nearest.js +54 -0
  86. package/dist/nearest.js.map +1 -0
  87. package/dist/net.d.ts +90 -0
  88. package/dist/net.d.ts.map +1 -0
  89. package/dist/net.js +203 -0
  90. package/dist/net.js.map +1 -0
  91. package/dist/node.d.ts +32 -0
  92. package/dist/node.d.ts.map +1 -0
  93. package/dist/node.js +33 -0
  94. package/dist/node.js.map +1 -0
  95. package/dist/openrouter.d.ts +25 -0
  96. package/dist/openrouter.d.ts.map +1 -0
  97. package/dist/openrouter.js +72 -0
  98. package/dist/openrouter.js.map +1 -0
  99. package/dist/optimize.d.ts +38 -0
  100. package/dist/optimize.d.ts.map +1 -0
  101. package/dist/optimize.js +183 -0
  102. package/dist/optimize.js.map +1 -0
  103. package/dist/otlp.d.ts +91 -0
  104. package/dist/otlp.d.ts.map +1 -0
  105. package/dist/otlp.js +102 -0
  106. package/dist/otlp.js.map +1 -0
  107. package/dist/phrases.d.ts +169 -0
  108. package/dist/phrases.d.ts.map +1 -0
  109. package/dist/phrases.js +939 -0
  110. package/dist/phrases.js.map +1 -0
  111. package/dist/pricing-overlay.d.ts +55 -0
  112. package/dist/pricing-overlay.d.ts.map +1 -0
  113. package/dist/pricing-overlay.js +241 -0
  114. package/dist/pricing-overlay.js.map +1 -0
  115. package/dist/pricing.d.ts +115 -0
  116. package/dist/pricing.d.ts.map +1 -0
  117. package/dist/pricing.js +400 -0
  118. package/dist/pricing.js.map +1 -0
  119. package/dist/profile.d.ts +71 -0
  120. package/dist/profile.d.ts.map +1 -0
  121. package/dist/profile.js +55 -0
  122. package/dist/profile.js.map +1 -0
  123. package/dist/promptfoo.d.ts +58 -0
  124. package/dist/promptfoo.d.ts.map +1 -0
  125. package/dist/promptfoo.js +149 -0
  126. package/dist/promptfoo.js.map +1 -0
  127. package/dist/prune.d.ts +91 -0
  128. package/dist/prune.d.ts.map +1 -0
  129. package/dist/prune.js +110 -0
  130. package/dist/prune.js.map +1 -0
  131. package/dist/reorder.d.ts +82 -0
  132. package/dist/reorder.d.ts.map +1 -0
  133. package/dist/reorder.js +215 -0
  134. package/dist/reorder.js.map +1 -0
  135. package/dist/review.d.ts +54 -0
  136. package/dist/review.d.ts.map +1 -0
  137. package/dist/review.js +131 -0
  138. package/dist/review.js.map +1 -0
  139. package/dist/rules.d.ts +5 -0
  140. package/dist/rules.d.ts.map +1 -0
  141. package/dist/rules.js +279 -0
  142. package/dist/rules.js.map +1 -0
  143. package/dist/savings.d.ts +36 -0
  144. package/dist/savings.d.ts.map +1 -0
  145. package/dist/savings.js +83 -0
  146. package/dist/savings.js.map +1 -0
  147. package/dist/segment.d.ts +8 -0
  148. package/dist/segment.d.ts.map +1 -0
  149. package/dist/segment.js +74 -0
  150. package/dist/segment.js.map +1 -0
  151. package/dist/shared-prefix.d.ts +63 -0
  152. package/dist/shared-prefix.d.ts.map +1 -0
  153. package/dist/shared-prefix.js +151 -0
  154. package/dist/shared-prefix.js.map +1 -0
  155. package/dist/similarity.d.ts +13 -0
  156. package/dist/similarity.d.ts.map +1 -0
  157. package/dist/similarity.js +30 -0
  158. package/dist/similarity.js.map +1 -0
  159. package/dist/structure.d.ts +144 -0
  160. package/dist/structure.d.ts.map +1 -0
  161. package/dist/structure.js +455 -0
  162. package/dist/structure.js.map +1 -0
  163. package/dist/suggest.d.ts +100 -0
  164. package/dist/suggest.d.ts.map +1 -0
  165. package/dist/suggest.js +151 -0
  166. package/dist/suggest.js.map +1 -0
  167. package/dist/tokenizer.d.ts +57 -0
  168. package/dist/tokenizer.d.ts.map +1 -0
  169. package/dist/tokenizer.js +157 -0
  170. package/dist/tokenizer.js.map +1 -0
  171. package/dist/types.d.ts +296 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +3 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/walk.d.ts +40 -0
  176. package/dist/walk.d.ts.map +1 -0
  177. package/dist/walk.js +95 -0
  178. package/dist/walk.js.map +1 -0
  179. package/package.json +56 -0
  180. package/src/advisories.ts +431 -0
  181. package/src/aws-sigv4.ts +174 -0
  182. package/src/baseline.ts +390 -0
  183. package/src/cache.ts +54 -0
  184. package/src/changes.ts +158 -0
  185. package/src/compare.ts +131 -0
  186. package/src/config-schema.ts +451 -0
  187. package/src/config.ts +161 -0
  188. package/src/detect.ts +312 -0
  189. package/src/evaluate.ts +188 -0
  190. package/src/extract.ts +336 -0
  191. package/src/gcp-auth.ts +166 -0
  192. package/src/glob.ts +160 -0
  193. package/src/host.ts +90 -0
  194. package/src/i18n/en.ts +236 -0
  195. package/src/i18n/es.ts +236 -0
  196. package/src/i18n/index.ts +68 -0
  197. package/src/i18n/types.ts +230 -0
  198. package/src/index.ts +228 -0
  199. package/src/llm.ts +708 -0
  200. package/src/nearest.ts +61 -0
  201. package/src/net.ts +233 -0
  202. package/src/node.ts +63 -0
  203. package/src/openrouter.ts +125 -0
  204. package/src/optimize.ts +228 -0
  205. package/src/otlp.ts +179 -0
  206. package/src/phrases.ts +1047 -0
  207. package/src/pricing-overlay.ts +319 -0
  208. package/src/pricing.ts +468 -0
  209. package/src/profile.ts +124 -0
  210. package/src/promptfoo.ts +213 -0
  211. package/src/prune.ts +211 -0
  212. package/src/reorder.ts +307 -0
  213. package/src/review.ts +180 -0
  214. package/src/rules.ts +324 -0
  215. package/src/savings.ts +121 -0
  216. package/src/segment.ts +106 -0
  217. package/src/shared-prefix.ts +198 -0
  218. package/src/similarity.ts +28 -0
  219. package/src/structure.ts +652 -0
  220. package/src/suggest.ts +254 -0
  221. package/src/tokenizer.ts +190 -0
  222. package/src/types.ts +323 -0
  223. package/src/walk.ts +117 -0
package/src/reorder.ts ADDED
@@ -0,0 +1,307 @@
1
+ import { BACKWARD_REFERENCES_BY_LANGUAGE, UNCOVERED_SCRIPTS } from './phrases.js';
2
+ import { segment } from './segment.js';
3
+ import { estimateTokens } from './tokenizer.js';
4
+ import type { TokenCounter } from './types.js';
5
+
6
+ /**
7
+ * Moving stable instructions in front of the first placeholder.
8
+ *
9
+ * This is the largest saving Trazum knows about and the only one it used to
10
+ * report without acting on. Prompt caching is a byte-for-byte prefix match, so
11
+ * everything after the first `{{placeholder}}` is re-read at full price on every
12
+ * call. Measured on a 1,178-token support prompt: 14 tokens cacheable as written,
13
+ * 1,174 after rearranging the *same content* — $227.65 a month at 50,000 calls.
14
+ *
15
+ * No rule can compete with that, because a rule deletes a few percent of tokens
16
+ * while this changes the price of 98% of them.
17
+ *
18
+ * **It is also the most dangerous thing in this repository**, which is why it is
19
+ * not a rule and not part of `aggressive`. Every other transformation removes
20
+ * text whose absence is local. This one moves text, and order carries meaning:
21
+ * "Summarise the text above" is correct where it sits and nonsense in front of
22
+ * the text it points at. So the whole design here is about what to *refuse*.
23
+ *
24
+ * Three refusals, in order of how much they cost:
25
+ *
26
+ * 1. **A block containing a backward reference stays put** — and so does
27
+ * everything after it. Moving a later block past a pinned one changes their
28
+ * order relative to each other, which is the same class of harm.
29
+ * 2. **Only whole blocks move.** Blocks are separated by blank lines, so a
30
+ * sentence is never severed from the paragraph that qualifies it.
31
+ * 3. **Nothing moves if the prompt has no placeholder**, or if the resulting
32
+ * prefix would not clear the model's cacheable minimum anyway — a
33
+ * rearrangement that buys nothing is a diff for its own sake.
34
+ */
35
+
36
+ export interface ReorderedBlock {
37
+ text: string;
38
+ tokens: number;
39
+ }
40
+
41
+ export interface DeclinedBlock {
42
+ text: string;
43
+ /**
44
+ * Why it stayed. `backward-reference` names the phrase found; `after-pinned`
45
+ * means an earlier block was pinned and moving this one would reorder the two.
46
+ */
47
+ reason: 'backward-reference' | 'after-pinned' | 'uncovered-script';
48
+ /** The phrase that pinned it, for `backward-reference`. */
49
+ phrase?: string;
50
+ /** The script with no phrase list, for `uncovered-script`. */
51
+ script?: string;
52
+ }
53
+
54
+ export interface ReorderResult {
55
+ /** The rearranged prompt, or the original when nothing could move. */
56
+ text: string;
57
+ /** Blocks moved ahead of the first placeholder, in their original order. */
58
+ moved: ReorderedBlock[];
59
+ /** Blocks left where they were, with the reason. */
60
+ declined: DeclinedBlock[];
61
+ /** Tokens that moved from unpriced-every-call into the cacheable prefix. */
62
+ tokensMoved: number;
63
+ /** Cacheable prefix before and after, so the gain is visible rather than claimed. */
64
+ prefixTokensBefore: number;
65
+ prefixTokensAfter: number;
66
+ }
67
+
68
+ /**
69
+ * Finds the offset of the first template placeholder.
70
+ *
71
+ * Uses the same segmentation as everything else, so "placeholder" means exactly
72
+ * what the protection pass means by it — there is no second definition to drift.
73
+ */
74
+ function firstPlaceholderOffset(prompt: string): number | null {
75
+ let offset = 0;
76
+ for (const seg of segment(prompt)) {
77
+ if (seg.kind === 'protected' && seg.protection === 'placeholder') return offset;
78
+ offset += seg.text.length;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** Blank-line separated blocks, with their original separators preserved. */
84
+ function toBlocks(text: string): string[] {
85
+ // Splitting on a blank line and keeping the delimiter means rejoining cannot
86
+ // silently normalise somebody's spacing.
87
+ //
88
+ // Written as a line scan rather than the `split(/(?<=\n)(?=\s*\n)/)` it
89
+ // replaces. That regex reads better and is quadratic in the length of a run
90
+ // of blank lines, because the lookahead re-consumes the whole run at every
91
+ // position in it: 3.3 seconds on 60 KB of newlines, in a library reachable
92
+ // over HTTP. Here every character is visited once.
93
+ const blocks: string[] = [];
94
+ let start = 0;
95
+ let i = 0;
96
+ while (i < text.length) {
97
+ const nl = text.indexOf('\n', i);
98
+ const end = nl === -1 ? text.length : nl + 1;
99
+ // A blank line opens a block, and travels with the block it precedes.
100
+ if (i > start && text.slice(i, end).trim() === '') {
101
+ blocks.push(text.slice(start, i));
102
+ start = i;
103
+ }
104
+ i = end;
105
+ }
106
+ if (start < text.length) blocks.push(text.slice(start));
107
+ return blocks;
108
+ }
109
+
110
+ const hasPlaceholder = (text: string): boolean =>
111
+ segment(text).some((s) => s.kind === 'protected' && s.protection === 'placeholder');
112
+
113
+ /**
114
+ * The backward reference in a block, if any.
115
+ *
116
+ * Lowercased on both sides because a prompt written in title case is still a
117
+ * prompt. Word-boundary matched for languages written with spaces, so
118
+ * "aboveboard" does not pin a block — and *not* for Japanese and Chinese, where
119
+ * the neighbouring character is always a letter and a boundary-matched phrase
120
+ * could never fire.
121
+ *
122
+ * Every language's list runs against every prompt. Detecting the language first
123
+ * would be one more thing to get wrong, and the cost of testing a French prompt
124
+ * against German phrases is a saving not taken, which is the direction this
125
+ * module errs in on purpose.
126
+ */
127
+ function backwardReference(text: string): string | undefined {
128
+ const haystack = text.toLowerCase();
129
+ const boundary = (c: string | undefined): boolean =>
130
+ c === undefined || !/[\p{L}\p{N}]/u.test(c);
131
+
132
+ for (const set of Object.values(BACKWARD_REFERENCES_BY_LANGUAGE)) {
133
+ for (const phrase of set.phrases) {
134
+ const index = haystack.indexOf(phrase);
135
+ if (index === -1) continue;
136
+ if (!set.wordBoundaries) return phrase;
137
+ if (boundary(haystack[index - 1]) && boundary(haystack[index + phrase.length])) {
138
+ return phrase;
139
+ }
140
+ }
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ /**
146
+ * A script the phrase lists do not cover, if the prompt is written in one.
147
+ *
148
+ * The fourth refusal, and the one that exists because of what the other three
149
+ * were quietly not doing. Every safety argument in this module rests on
150
+ * recognising a backward reference, and for a Russian, Arabic or Korean prompt
151
+ * there was nothing to recognise it with — so `--reorder` rearranged freely and
152
+ * called it a saving. Refusing is the honest answer until the list exists.
153
+ *
154
+ * Deliberately triggered by a *single* character rather than by the prompt being
155
+ * mostly that script. A prompt in English with one Cyrillic instruction in it is
156
+ * exactly the case where a missed reference does damage, and the cost of being
157
+ * wrong here is a saving the author can still take by hand.
158
+ */
159
+ function uncoveredScript(text: string): string | undefined {
160
+ return UNCOVERED_SCRIPTS.find((script) => script.pattern.test(text))?.name;
161
+ }
162
+
163
+ export interface ReorderOptions {
164
+ count?: TokenCounter;
165
+ /**
166
+ * Do not rearrange unless the prefix ends up at least this long. Defaults to
167
+ * 0 — the caller knows the model's cacheable minimum and this module does not.
168
+ *
169
+ * The bar is on the **resulting prefix**, not on the amount moved. Those are
170
+ * different questions, and asking the second one refuses a real saving: a
171
+ * prompt whose head already clears the minimum gains from any block that joins
172
+ * it, however small. Asking "did 200 tokens move?" answers "no" and reports
173
+ * that nothing could move, which is not what happened.
174
+ */
175
+ minPrefixTokens?: number;
176
+ }
177
+
178
+ /**
179
+ * Rearranges a prompt so its stable instructions sit in the cacheable prefix.
180
+ *
181
+ * Returns the original text unchanged when nothing can safely move, and always
182
+ * reports what it declined and why — a saving Trazum silently chose not to take
183
+ * is one the author cannot evaluate.
184
+ */
185
+ export function reorderForCache(prompt: string, options: ReorderOptions = {}): ReorderResult {
186
+ const count = options.count ?? estimateTokens;
187
+ const minPrefixTokens = options.minPrefixTokens ?? 0;
188
+
189
+ const unchanged = (): ReorderResult => {
190
+ const prefix = firstPlaceholderOffset(prompt);
191
+ const prefixTokens = prefix === null ? count(prompt) : count(prompt.slice(0, prefix));
192
+ return {
193
+ text: prompt,
194
+ moved: [],
195
+ declined: [],
196
+ tokensMoved: 0,
197
+ prefixTokensBefore: prefixTokens,
198
+ prefixTokensAfter: prefixTokens,
199
+ };
200
+ };
201
+
202
+ const offset = firstPlaceholderOffset(prompt);
203
+ if (offset === null) return unchanged();
204
+
205
+ // Before anything else: if the prompt is written in a script whose backward
206
+ // references this module cannot recognise, none of the refusals below mean
207
+ // anything. Nothing moves, and the report says which script and why.
208
+ const script = uncoveredScript(prompt);
209
+ if (script !== undefined) {
210
+ return {
211
+ ...unchanged(),
212
+ declined: [{ text: prompt.slice(offset), reason: 'uncovered-script', script }],
213
+ };
214
+ }
215
+
216
+ // The placeholder's own line stays with the content after it: "Customer
217
+ // message: {{message}}" is one unit, and splitting it would strand the label.
218
+ const lineStart = prompt.lastIndexOf('\n', offset) + 1;
219
+ const head = prompt.slice(0, lineStart);
220
+ const rest = prompt.slice(lineStart);
221
+
222
+ const blocks = toBlocks(rest);
223
+ const prefixTokensBefore = count(head);
224
+
225
+ const moved: ReorderedBlock[] = [];
226
+ const declined: DeclinedBlock[] = [];
227
+ const stay: string[] = [];
228
+ let pinned = false;
229
+
230
+ for (const [index, block] of blocks.entries()) {
231
+ // The first block holds the placeholder itself; it can never move.
232
+ if (index === 0 || hasPlaceholder(block)) {
233
+ stay.push(block);
234
+ continue;
235
+ }
236
+ if (pinned) {
237
+ // Everything after a pinned block stays, because moving it would change
238
+ // its order relative to the block that had to stay.
239
+ stay.push(block);
240
+ if (block.trim() !== '') declined.push({ text: block, reason: 'after-pinned' });
241
+ continue;
242
+ }
243
+
244
+ const phrase = backwardReference(block);
245
+ if (phrase !== undefined) {
246
+ pinned = true;
247
+ stay.push(block);
248
+ declined.push({ text: block, reason: 'backward-reference', phrase });
249
+ continue;
250
+ }
251
+ if (block.trim() === '') {
252
+ stay.push(block);
253
+ continue;
254
+ }
255
+
256
+ moved.push({ text: block, tokens: count(block) });
257
+ }
258
+
259
+ const tokensMoved = moved.reduce((sum, b) => sum + b.tokens, 0);
260
+ // Against the prefix this would produce, not against the amount moved. A head
261
+ // that already clears the minimum gains from any block that joins it.
262
+ if (moved.length === 0 || prefixTokensBefore + tokensMoved < minPrefixTokens) {
263
+ // Report the refusals even when nothing moved: "no saving here" and "there
264
+ // was a saving and it was not safe to take" are different answers.
265
+ return { ...unchanged(), declined };
266
+ }
267
+
268
+ // Normalise the seams rather than concatenating raw slices: a block carries the
269
+ // blank line that preceded it, so joining head + block + rest naively leaves a
270
+ // three-newline gap where two belong.
271
+ //
272
+ // In the author's own line ending, though. Rejoining a CRLF prompt with bare
273
+ // newlines would rewrite every seam in a file nobody asked to reformat — and
274
+ // where the whole point is a byte-for-byte cache prefix, a changed byte is a
275
+ // changed price.
276
+ const gap = prompt.includes('\r\n') ? '\r\n\r\n' : '\n\n';
277
+ const movedText = moved
278
+ // `^\s*\n+` drops the blank-line separator the block carries without taking
279
+ // the first line's own indentation with it, which `trimStart` would. It is
280
+ // anchored, so it is tried from one position and stays linear.
281
+ .map((b) => b.text.replace(/^\s*\n+/, '').trimEnd())
282
+ .join(gap);
283
+ const before = head.trimEnd();
284
+ const after = stay.join('').replace(/^\s*\n+/, '').trimEnd();
285
+ // No leading gap when the placeholder was on the very first line: there is no
286
+ // head for the moved blocks to sit after, and emitting one would open the
287
+ // prompt with a blank line.
288
+ const body = before === '' ? `${movedText}${gap}${after}` : `${before}${gap}${movedText}${gap}${after}`;
289
+ // However the prompt ended, it still ends that way. A block carries the blank
290
+ // line that followed it, so trimming the seams without restoring the original
291
+ // ending either strands a newline at the end or drops the one that was there.
292
+ // Collapsing runs of blank lines is the whitespace rule's job, not this one's.
293
+ //
294
+ // Via `trimEnd` rather than `/\s*$/`, which is quadratic on a prompt that
295
+ // holds a long whitespace run and does not end in one: 31 seconds on 200 KB,
296
+ // under the 400 KB the HTTP API accepts.
297
+ const text = `${body}${prompt.slice(prompt.trimEnd().length)}`;
298
+
299
+ return {
300
+ text,
301
+ moved,
302
+ declined,
303
+ tokensMoved,
304
+ prefixTokensBefore,
305
+ prefixTokensAfter: count(text.slice(0, firstPlaceholderOffset(text) ?? text.length)),
306
+ };
307
+ }
package/src/review.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { findExamples } from './structure.js';
2
+ import { estimateTokens } from './tokenizer.js';
3
+ import type { LlmProvider, TokenCounter } from './types.js';
4
+
5
+ /**
6
+ * Semantic review of few-shot examples.
7
+ *
8
+ * The deterministic detector in `structure.ts` finds near-copies and stops
9
+ * there, on purpose: a paraphrase — the same lesson in different words —
10
+ * scores around 0.54 on word overlap, close enough to two genuinely distinct
11
+ * examples (~0.20) that catching it would mean flagging examples that teach
12
+ * different things.
13
+ *
14
+ * Deciding that "arrived quickly" and "arrived fast" demonstrate the same
15
+ * pattern needs a model. So this lives behind the optional LLM layer, costs a
16
+ * call, and is never on the path of an ordinary `optimize()`.
17
+ *
18
+ * It only ever reports. Nothing here edits a prompt, which is what lets it be
19
+ * relaxed about a model that answers badly: the worst outcome is a suggestion
20
+ * you ignore.
21
+ */
22
+
23
+ export const EXAMPLE_REVIEW_SYSTEM_PROMPT = `You judge whether few-shot examples in a prompt teach the same thing.
24
+
25
+ You are given numbered examples. Group together any that demonstrate the same pattern — the same kind of input mapping to the same kind of output — even when they are worded completely differently. Examples that differ only in surface detail (names, numbers, dates) teach the same thing.
26
+
27
+ Do NOT group examples that look similar but demonstrate different behaviour, and do NOT group an example that exists to show a boundary or edge case. When in doubt, leave an example on its own: a false grouping costs the reader more than a missed one.
28
+
29
+ Return ONLY a JSON array, no prose and no code fences. Each element is a group of two or more examples that teach the same thing:
30
+
31
+ [{"keep": 0, "redundant": [2], "reason": "both classify a missing delivery as shipping"}]
32
+
33
+ "keep" is the example worth keeping (usually the earliest), "redundant" lists the others, and "reason" is one short clause. Return [] when every example teaches something distinct.`;
34
+
35
+ export interface ExampleRedundancy {
36
+ /** Index of the example worth keeping. */
37
+ keep: number;
38
+ /** Indices the model considers redundant with it. */
39
+ redundant: number[];
40
+ /** The model's one-clause justification, for the reader to judge. */
41
+ reason: string;
42
+ /** Tokens held by the redundant examples. */
43
+ tokens: number;
44
+ }
45
+
46
+ export interface ExampleReview {
47
+ provider: string;
48
+ model: string;
49
+ /** Examples found in the prompt, in order. */
50
+ exampleCount: number;
51
+ groups: ExampleRedundancy[];
52
+ /** Tokens across every example marked redundant. */
53
+ redundantTokens: number;
54
+ /** Set when the model answered but nothing usable came back. */
55
+ unusableResponse?: string;
56
+ }
57
+
58
+ export interface ReviewExamplesOptions {
59
+ tokenCounter?: TokenCounter;
60
+ }
61
+
62
+ /** Pulls the first JSON array out of a response, fences and prose included. */
63
+ function extractJsonArray(raw: string): unknown {
64
+ const trimmed = raw.trim();
65
+ const fenced = /^(?:```|~~~)[a-zA-Z]*\n([\s\S]*?)\n?(?:```|~~~)$/.exec(trimmed);
66
+ const body = fenced?.[1] ?? trimmed;
67
+
68
+ const start = body.indexOf('[');
69
+ const end = body.lastIndexOf(']');
70
+ if (start === -1 || end === -1 || end < start) return null;
71
+
72
+ try {
73
+ return JSON.parse(body.slice(start, end + 1));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Validates one group against the examples that actually exist.
81
+ *
82
+ * Everything the model returns is treated as a suggestion from an untrusted
83
+ * source: indices are range-checked, self-references dropped, duplicates
84
+ * collapsed, and the reason truncated. A model that answers with nonsense
85
+ * produces an empty review, not a crash and not a bogus saving.
86
+ */
87
+ function parseGroup(value: unknown, exampleCount: number): Omit<ExampleRedundancy, 'tokens'> | null {
88
+ if (typeof value !== 'object' || value === null) return null;
89
+ const record = value as Record<string, unknown>;
90
+
91
+ const keep = record.keep;
92
+ if (typeof keep !== 'number' || !Number.isInteger(keep) || keep < 0 || keep >= exampleCount) {
93
+ return null;
94
+ }
95
+
96
+ const rawRedundant = Array.isArray(record.redundant) ? record.redundant : [];
97
+ const redundant = [
98
+ ...new Set(
99
+ rawRedundant.filter(
100
+ (index): index is number =>
101
+ typeof index === 'number' &&
102
+ Number.isInteger(index) &&
103
+ index >= 0 &&
104
+ index < exampleCount &&
105
+ index !== keep,
106
+ ),
107
+ ),
108
+ ].sort((a, b) => a - b);
109
+
110
+ if (redundant.length === 0) return null;
111
+
112
+ const reason = typeof record.reason === 'string' ? record.reason.trim().slice(0, 160) : '';
113
+ return { keep, redundant, reason };
114
+ }
115
+
116
+ /**
117
+ * Asks the configured model which examples teach the same thing.
118
+ *
119
+ * Returns `null` when there is nothing to review — fewer than two examples —
120
+ * so the caller can skip the call rather than pay for a certain answer.
121
+ *
122
+ * Never throws on a bad answer. A provider that errors will still throw,
123
+ * because that is a configuration problem the caller should see.
124
+ */
125
+ export async function reviewExamples(
126
+ prompt: string,
127
+ provider: LlmProvider,
128
+ options: ReviewExamplesOptions = {},
129
+ ): Promise<ExampleReview | null> {
130
+ const count = options.tokenCounter ?? estimateTokens;
131
+ const examples = findExamples(prompt, count);
132
+ if (examples.length < 2) return null;
133
+
134
+ const numbered = examples
135
+ .map((example, index) => `--- Example ${index} ---\n${example.text}`)
136
+ .join('\n\n');
137
+
138
+ const raw = await provider.complete({
139
+ system: EXAMPLE_REVIEW_SYSTEM_PROMPT,
140
+ user: numbered,
141
+ });
142
+
143
+ const base = {
144
+ provider: provider.name,
145
+ model: provider.model,
146
+ exampleCount: examples.length,
147
+ };
148
+
149
+ const parsed = extractJsonArray(raw);
150
+ if (!Array.isArray(parsed)) {
151
+ return { ...base, groups: [], redundantTokens: 0, unusableResponse: raw.trim().slice(0, 200) };
152
+ }
153
+
154
+ // An example can only be claimed once. Without this a model that returns
155
+ // overlapping groups would have the same tokens counted twice, and the
156
+ // saving would read higher than the prompt could possibly deliver.
157
+ const claimed = new Set<number>();
158
+ const groups: ExampleRedundancy[] = [];
159
+
160
+ for (const entry of parsed) {
161
+ const group = parseGroup(entry, examples.length);
162
+ if (!group) continue;
163
+
164
+ const fresh = group.redundant.filter((index) => !claimed.has(index) && index !== group.keep);
165
+ if (fresh.length === 0) continue;
166
+ fresh.forEach((index) => claimed.add(index));
167
+
168
+ groups.push({
169
+ ...group,
170
+ redundant: fresh,
171
+ tokens: fresh.reduce((sum, index) => sum + examples[index]!.tokens, 0),
172
+ });
173
+ }
174
+
175
+ return {
176
+ ...base,
177
+ groups,
178
+ redundantTokens: groups.reduce((sum, group) => sum + group.tokens, 0),
179
+ };
180
+ }