@botpress/zai 2.4.2 → 2.5.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@botpress/zai",
3
3
  "description": "Zui AI (zai) – An LLM utility library written on top of Zui and the Botpress API",
4
- "version": "2.4.2",
4
+ "version": "2.5.1",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
7
  "exports": {
@@ -32,7 +32,7 @@
32
32
  "author": "",
33
33
  "license": "ISC",
34
34
  "dependencies": {
35
- "@botpress/cognitive": "0.2.0",
35
+ "@botpress/cognitive": "0.2.1",
36
36
  "json5": "^2.2.3",
37
37
  "jsonrepair": "^3.10.0",
38
38
  "lodash-es": "^4.17.21",
package/src/index.ts CHANGED
@@ -11,5 +11,6 @@ import './operations/group'
11
11
  import './operations/rate'
12
12
  import './operations/sort'
13
13
  import './operations/answer'
14
+ import './operations/patch'
14
15
 
15
16
  export { Zai }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Micropatch v0.3
3
+ *
4
+ * A tiny engine to parse and apply ultra-compact line-based patches designed for LLMs.
5
+ *
6
+ * Protocol recap:
7
+ * - Source lines are referenced by ORIGINAL 1-based numbers (pre-edit).
8
+ * - Ops start at the beginning of a line with the marker `◼︎`.
9
+ * - Allowed ops:
10
+ * ◼︎<NNN|text → insert single line BEFORE original NNN
11
+ * ◼︎>NNN|text → insert single line AFTER original NNN
12
+ * ◼︎=NNN|line → replace line NNN with one or more lines (multiline payload allowed)
13
+ * ◼︎=NNN-MMM|lines → replace inclusive range NNN..MMM with one or more lines
14
+ * ◼︎-NNN → delete line NNN
15
+ * ◼︎-NNN-MMM → delete inclusive range NNN..MMM
16
+ * - Multiline `=` payload: starts after `|` and continues **until the next line that begins with `◼︎`** or EOF.
17
+ * - Only escaping rule: `\◼︎` inside payload/text becomes a literal `◼︎`. No other escaping is recognized.
18
+ * - Ranges are allowed **only** for `-` (delete) and `=` (replace).
19
+ * - Deterministic apply order on ORIGINAL addresses:
20
+ * 1) Deletes `-` (desc by start)
21
+ * 2) Single-line replaces `=NNN` (asc)
22
+ * 3) Range replaces `=NNN-MMM` (asc by start)
23
+ * 4) Inserts `<` (asc)
24
+ * 5) Inserts `>` (asc)
25
+ *
26
+ * Notes:
27
+ * - The engine maintains a live index map so ORIGINAL references remain valid despite shifting.
28
+ * - Idempotency-friendly: if a target original line no longer maps into current text (e.g., already deleted), the op is skipped.
29
+ * - Whitespace is preserved; EOL style can be chosen or auto-detected.
30
+ */
31
+
32
+ export type EOL = 'lf' | 'crlf'
33
+
34
+ /** Parsed operations (internal normalized form). */
35
+ type Op =
36
+ | { k: '<'; n: number; s: string } // insert before (single-line)
37
+ | { k: '>'; n: number; s: string } // insert after (single-line)
38
+ | { k: '='; n: number; s: string[] } // replace single line with N lines
39
+ | { k: '=-'; a: number; b: number; s: string[] } // replace range with N lines
40
+ | { k: '-'; n: number } // delete single
41
+ | { k: '--'; a: number; b: number } // delete range
42
+
43
+ /**
44
+ * Micropatch: parse & apply patches to text.
45
+ */
46
+ export class Micropatch {
47
+ private _text: string
48
+ private _eol: EOL
49
+
50
+ /**
51
+ * Create a Micropatch instance.
52
+ * @param source The file contents.
53
+ * @param eol Line ending style. If omitted, it is auto-detected from `source` (CRLF if any CRLF is present; otherwise LF).
54
+ */
55
+ public constructor(source: string, eol?: EOL) {
56
+ this._text = source
57
+ this._eol = eol ?? Micropatch.detectEOL(source)
58
+ }
59
+
60
+ /** Get current text. */
61
+ public getText(): string {
62
+ return this._text
63
+ }
64
+
65
+ /**
66
+ * Replace current text.
67
+ * Useful if you want to "load" a new snapshot without reconstructing the class.
68
+ */
69
+ public setText(source: string, eol?: EOL): void {
70
+ this._text = source
71
+ this._eol = eol ?? Micropatch.detectEOL(source)
72
+ }
73
+
74
+ /**
75
+ * Apply ops text to current buffer.
76
+ * @param opsText One or more operations in the v0.3 syntax.
77
+ * @returns The updated text (also stored internally).
78
+ * @throws If the patch contains invalid syntax (e.g., range on insert).
79
+ */
80
+ public apply(opsText: string): string {
81
+ const ops = Micropatch.parseOps(opsText)
82
+ this._text = Micropatch._applyOps(this._text, ops, this._eol)
83
+ return this._text
84
+ }
85
+
86
+ /**
87
+ * Render a numbered view of the current buffer (token-cheap preview for models).
88
+ * Format: `NNN|<line>`, starting at 001.
89
+ */
90
+ public renderNumberedView(): string {
91
+ const NL = this._eol === 'lf' ? '\n' : '\r\n'
92
+ const lines = Micropatch._splitEOL(this._text)
93
+ return lines.map((l, i) => `${String(i + 1).padStart(3, '0')}|${l}`).join(NL)
94
+ }
95
+
96
+ // ---------------------- Static helpers ----------------------
97
+
98
+ /** Detect EOL style from content. */
99
+ public static detectEOL(source: string): EOL {
100
+ return /\r\n/.test(source) ? 'crlf' : 'lf'
101
+ }
102
+
103
+ /** Split text into lines, preserving empty last line if present. */
104
+ private static _splitEOL(text: string): string[] {
105
+ // Use universal split, but keep trailing empty line if final NL exists.
106
+ const parts = text.split(/\r?\n/)
107
+ // If text ends with NL, split leaves an extra empty string we want to keep.
108
+ return parts
109
+ }
110
+
111
+ /** Join lines with the chosen EOL. */
112
+ private static _joinEOL(lines: string[], eol: EOL): string {
113
+ const NL = eol === 'lf' ? '\n' : '\r\n'
114
+ return lines.join(NL)
115
+ }
116
+
117
+ /** Unescape payload text: `\◼︎` → `◼︎`. */
118
+ private static _unescapeMarker(s: string): string {
119
+ return s.replace(/\\◼︎/g, '◼︎')
120
+ }
121
+
122
+ /**
123
+ * Parse ops text (v0.3).
124
+ * - Ignores blank lines and lines not starting with `◼︎` (you can keep comments elsewhere).
125
+ * - Validates ranges for allowed ops.
126
+ */
127
+ public static parseOps(opsText: string): Op[] {
128
+ const lines = opsText.split(/\r?\n/)
129
+ const ops: Op[] = []
130
+
131
+ // Regex for op header line:
132
+ // ◼︎([<>=-])(\d+)(?:-(\d+))?(?:\|(.*))?$
133
+ const headerRe = /^◼︎([<>=-])(\d+)(?:-(\d+))?(?:\|(.*))?$/
134
+
135
+ let i = 0
136
+ while (i < lines.length) {
137
+ const line = lines[i]
138
+ if (!line) {
139
+ i++
140
+ continue
141
+ }
142
+
143
+ if (!line.startsWith('◼︎')) {
144
+ i++
145
+ continue // ignore non-op lines
146
+ }
147
+
148
+ const m = headerRe.exec(line)
149
+ if (!m || !m[1] || !m[2]) {
150
+ throw new Error(`Invalid op syntax at line ${i + 1}: ${line}`)
151
+ }
152
+
153
+ const op = m[1] as '<' | '>' | '=' | '-'
154
+ const aNum = parseInt(m[2], 10)
155
+ const bNum = m[3] ? parseInt(m[3], 10) : undefined
156
+ const firstPayload = m[4] ?? ''
157
+
158
+ if (aNum < 1 || (bNum !== undefined && bNum < aNum)) {
159
+ throw new Error(`Invalid line/range at line ${i + 1}: ${line}`)
160
+ }
161
+
162
+ // Inserts: single-line payload only; ranges are illegal.
163
+ if (op === '<' || op === '>') {
164
+ if (bNum !== undefined) {
165
+ throw new Error(`Insert cannot target a range (line ${i + 1})`)
166
+ }
167
+ const text = Micropatch._unescapeMarker(firstPayload)
168
+ ops.push({ k: op, n: aNum, s: text })
169
+ i++
170
+ continue
171
+ }
172
+
173
+ // Deletes: may be single or range; no payload allowed.
174
+ if (op === '-') {
175
+ if (firstPayload !== '') {
176
+ throw new Error(`Delete must not have a payload (line ${i + 1})`)
177
+ }
178
+ if (bNum === undefined) {
179
+ ops.push({ k: '-', n: aNum })
180
+ } else {
181
+ ops.push({ k: '--', a: aNum, b: bNum })
182
+ }
183
+ i++
184
+ continue
185
+ }
186
+
187
+ // Replaces: '=' supports single or range, and MULTILINE payload.
188
+ if (op === '=') {
189
+ // Collect multiline payload until next line starting with '◼︎' or EOF.
190
+ const payload: string[] = [Micropatch._unescapeMarker(firstPayload)]
191
+ let j = i + 1
192
+ while (j < lines.length) {
193
+ const nextLine = lines[j]
194
+ if (!nextLine || nextLine.startsWith('◼︎')) break
195
+ payload.push(Micropatch._unescapeMarker(nextLine))
196
+ j++
197
+ }
198
+ // Normalize potential trailing empty due to splitting; keep as-is (exact payload).
199
+ if (bNum === undefined) {
200
+ ops.push({ k: '=', n: aNum, s: payload })
201
+ } else {
202
+ ops.push({ k: '=-', a: aNum, b: bNum, s: payload })
203
+ }
204
+ i = j
205
+ continue
206
+ }
207
+
208
+ // Should be unreachable.
209
+ i++
210
+ }
211
+
212
+ return Micropatch._canonicalizeOrder(ops)
213
+ }
214
+
215
+ /** Order ops deterministically according to the spec. */
216
+ private static _canonicalizeOrder(ops: Op[]): Op[] {
217
+ const delS: Array<Extract<Op, { k: '-' }>> = []
218
+ const delR: Array<Extract<Op, { k: '--' }>> = []
219
+ const eqS: Array<Extract<Op, { k: '=' }>> = []
220
+ const eqR: Array<Extract<Op, { k: '=-' }>> = []
221
+ const insB: Array<Extract<Op, { k: '<' }>> = []
222
+ const insA: Array<Extract<Op, { k: '>' }>> = []
223
+
224
+ for (const o of ops) {
225
+ switch (o.k) {
226
+ case '-':
227
+ delS.push(o)
228
+ break
229
+ case '--':
230
+ delR.push(o)
231
+ break
232
+ case '=':
233
+ eqS.push(o)
234
+ break
235
+ case '=-':
236
+ eqR.push(o)
237
+ break
238
+ case '<':
239
+ insB.push(o)
240
+ break
241
+ case '>':
242
+ insA.push(o)
243
+ break
244
+ default:
245
+ break
246
+ }
247
+ }
248
+
249
+ delS.sort((a, b) => b.n - a.n)
250
+ delR.sort((a, b) => b.a - a.a)
251
+ eqS.sort((a, b) => a.n - b.n)
252
+ eqR.sort((a, b) => a.a - b.a)
253
+ insB.sort((a, b) => a.n - b.n)
254
+ insA.sort((a, b) => a.n - b.n)
255
+
256
+ return ([] as Op[]).concat(delS, delR, eqS, eqR, insB, insA)
257
+ }
258
+
259
+ /**
260
+ * Apply normalized ops to given source.
261
+ * - Uses a live index map from ORIGINAL 1-based addresses → current positions.
262
+ * - Skips ops whose targets can no longer be mapped (idempotency-friendly).
263
+ */
264
+ private static _applyOps(source: string, ops: Op[], eol: EOL): string {
265
+ const lines = Micropatch._splitEOL(source)
266
+
267
+ // idx[i] = current position (0-based) of original line (i+1).
268
+ const idx: number[] = Array.from({ length: lines.length }, (_, i) => i)
269
+
270
+ const map = (n: number): number => idx[n - 1] ?? -1
271
+ const bump = (from: number, delta: number) => {
272
+ // Shift all tracked indices at or after `from` by delta.
273
+ for (let i = 0; i < idx.length; i++) {
274
+ const current = idx[i]
275
+ if (current !== undefined && current >= from) {
276
+ idx[i] = current + delta
277
+ }
278
+ }
279
+ }
280
+
281
+ for (const o of ops) {
282
+ switch (o.k) {
283
+ case '-': {
284
+ const i = map(o.n)
285
+ if (i >= 0 && i < lines.length) {
286
+ lines.splice(i, 1)
287
+ bump(i, -1)
288
+ }
289
+ break
290
+ }
291
+ case '--': {
292
+ const a = map(o.a)
293
+ const b = map(o.b)
294
+ if (a >= 0 && b >= a && b < lines.length) {
295
+ lines.splice(a, b - a + 1)
296
+ bump(a, -(b - a + 1))
297
+ }
298
+ break
299
+ }
300
+ case '=': {
301
+ const i = map(o.n)
302
+ if (i >= 0 && i < lines.length) {
303
+ const rep = o.s
304
+ lines.splice(i, 1, ...rep)
305
+ bump(i + 1, rep.length - 1)
306
+ }
307
+ break
308
+ }
309
+ case '=-': {
310
+ const a = map(o.a)
311
+ const b = map(o.b)
312
+ if (a >= 0 && b >= a && b < lines.length) {
313
+ const rep = o.s
314
+ lines.splice(a, b - a + 1, ...rep)
315
+ bump(a + 1, rep.length - (b - a + 1))
316
+ }
317
+ break
318
+ }
319
+ case '<': {
320
+ const i = Math.max(0, Math.min(map(o.n), lines.length))
321
+ if (i >= 0) {
322
+ lines.splice(i, 0, o.s)
323
+ bump(i, +1)
324
+ }
325
+ break
326
+ }
327
+ case '>': {
328
+ const i = Math.max(0, Math.min(map(o.n) + 1, lines.length))
329
+ if (i >= 0) {
330
+ lines.splice(i, 0, o.s)
331
+ bump(i, +1)
332
+ }
333
+ break
334
+ }
335
+ default:
336
+ break
337
+ }
338
+ }
339
+
340
+ return Micropatch._joinEOL(lines, eol)
341
+ }
342
+
343
+ // ---------------------- Convenience APIs ----------------------
344
+
345
+ /**
346
+ * Convenience: one-shot apply.
347
+ * @param source Text to patch.
348
+ * @param opsText Operations text.
349
+ * @param eol EOL style (auto-detected if omitted).
350
+ */
351
+ public static applyText(source: string, opsText: string, eol?: EOL): string {
352
+ const inst = new Micropatch(source, eol)
353
+ return inst.apply(opsText)
354
+ }
355
+
356
+ /**
357
+ * Convenience: parse only.
358
+ * Useful for validation without applying.
359
+ */
360
+ public static validate(opsText: string): { ok: true; count: number } {
361
+ const ops = Micropatch.parseOps(opsText)
362
+ return { ok: true, count: ops.length }
363
+ }
364
+ }