tune-basic-toolset 0.1.11 → 0.1.13

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,6 +1,6 @@
1
1
  {
2
2
  "name": "tune-basic-toolset",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Basic toolset for tune",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -25,12 +25,12 @@
25
25
  },
26
26
  "license": "MIT",
27
27
  "peerDependencies": {
28
- "tune-sdk": "latest",
29
- "tune-fs": "latest"
28
+ "tune-fs": "latest",
29
+ "tune-sdk": "latest"
30
30
  },
31
31
  "devDependencies": {
32
- "tune-sdk": "latest",
33
- "tune-fs": "latest"
32
+ "tune-fs": "latest",
33
+ "tune-sdk": "latest"
34
34
  },
35
35
  "dependencies": {
36
36
  "escodegen": "^2.1.0",
@@ -17,6 +17,720 @@ module.exports = async function json_format(node, args, ctx) {
17
17
  }
18
18
  return {
19
19
  ...node,
20
- exec: async (payload, ctx) => node.exec({ ...payload, response_format }, ctx)
20
+ exec: async (payload, ctx) => node.exec({ ...payload, response_format }, ctx),
21
+ hookMsg: (msg) => {
22
+ if (msg.content) {
23
+ msg.content = JSON.stringify(text2json(msg.content), null, " ")
24
+ }
25
+ return msg
26
+ }
21
27
  };
22
- };
28
+ };
29
+
30
+ // text2json.js (CommonJS)
31
+ // Lightweight JSON extraction from LLM output.
32
+ //
33
+ // Exports: function text2json(text)
34
+ //
35
+ // Strategy:
36
+ // 1) Extract candidates from:
37
+ // - Markdown code blocks (```json and others)
38
+ // - Balanced { ... } or [ ... ] segments in text (even if incomplete)
39
+ // - Whole text as fallback
40
+ // 2) For each candidate, sanitize JSON-ish into valid JSON:
41
+ // - Strip comments (//, /* */) respecting strings
42
+ // - Convert single-quoted strings to double-quoted
43
+ // - Quote unquoted object keys
44
+ // - Quote unquoted values with spaces or path-like tokens
45
+ // - Remove trailing commas
46
+ // - Auto-close unbalanced braces/brackets
47
+ // 3) Try JSON.parse. Collect all successful parses.
48
+ // - If multiple parses succeed, return array of results
49
+ // - If one succeeds, return it
50
+ // - Else return null
51
+
52
+ function text2json(text) {
53
+ if (typeof text !== 'string') return null;
54
+
55
+ // Quick path
56
+ const direct = tryParseJSON(text);
57
+ if (direct.ok) return direct.value;
58
+
59
+ const candidates = [
60
+ ...extractMarkdownBlocks(text),
61
+ ...findBalancedJsonSegments(text),
62
+ ];
63
+
64
+ // Add entire text as last resort
65
+ candidates.push({ snippet: text, reason: 'entire_text_fallback', complete: false });
66
+
67
+ const results = [];
68
+ const seen = new Set();
69
+
70
+ for (const c of prioritizeCandidates(candidates)) {
71
+ const attempts = generateSanitizedAttempts(c.snippet);
72
+
73
+ for (const attempt of attempts) {
74
+ const parsed = tryParseJSON(attempt);
75
+ if (parsed.ok) {
76
+ const key = stableStringify(parsed.value);
77
+ if (!seen.has(key)) {
78
+ seen.add(key);
79
+ results.push(parsed.value);
80
+ }
81
+ } else {
82
+ // Try with auto-closing unbalanced braces/brackets and trailing comma cleanup
83
+ const closed = autoCloseAndClean(attempt);
84
+ const parsed2 = tryParseJSON(closed);
85
+ if (parsed2.ok) {
86
+ const key = stableStringify(parsed2.value);
87
+ if (!seen.has(key)) {
88
+ seen.add(key);
89
+ results.push(parsed2.value);
90
+ }
91
+ }
92
+ }
93
+ if (results.length > 0) break; // Prefer first success per candidate
94
+ }
95
+ if (results.length > 0) break; // Prefer first successful candidate
96
+ }
97
+
98
+ if (results.length === 0) return null;
99
+ if (results.length === 1) return results[0];
100
+ return results;
101
+ }
102
+
103
+ // --------------------------- Candidate extraction ---------------------------
104
+
105
+ function extractMarkdownBlocks(text) {
106
+ const results = [];
107
+
108
+ // ```lang\n...``` blocks (handles unclosed too)
109
+ const blockRe = /```([a-zA-Z0-9 _-]+)?\n([\s\S]*?)```/g;
110
+ let match;
111
+ while ((match = blockRe.exec(text)) !== null) {
112
+ const lang = (match[1] || '').trim().toLowerCase();
113
+ const content = match[2] || '';
114
+ results.push({
115
+ snippet: content,
116
+ reason: `codeblock:${lang || 'unknown'}`,
117
+ complete: true,
118
+ lang,
119
+ });
120
+ }
121
+
122
+ // Handle unclosed block at end: ```json\n...EOF
123
+ const openRe = /```([a-zA-Z0-9 _-]+)?\n([\s\S]*)$/;
124
+ const openMatch = text.match(openRe);
125
+ if (openMatch && !/```/.test(openMatch[2])) {
126
+ const lang = (openMatch[1] || '').trim().toLowerCase();
127
+ const content = openMatch[2] || '';
128
+ results.push({
129
+ snippet: content,
130
+ reason: `codeblock_unclosed:${lang || 'unknown'}`,
131
+ complete: false,
132
+ lang,
133
+ });
134
+ }
135
+
136
+ return results;
137
+ }
138
+
139
+ function findBalancedJsonSegments(text) {
140
+ const results = [];
141
+
142
+ let stack = [];
143
+ let start = -1;
144
+ let inDouble = false;
145
+ let inSingle = false;
146
+ let inLineComment = false;
147
+ let inBlockComment = false;
148
+ let escape = false;
149
+
150
+ for (let i = 0; i < text.length; i++) {
151
+ const c = text[i];
152
+ const next = i + 1 < text.length ? text[i + 1] : '';
153
+
154
+ if (inLineComment) {
155
+ if (c === '\n') {
156
+ inLineComment = false;
157
+ }
158
+ continue;
159
+ }
160
+ if (inBlockComment) {
161
+ if (c === '*' && next === '/') {
162
+ inBlockComment = false;
163
+ i++;
164
+ }
165
+ continue;
166
+ }
167
+
168
+ if (!inSingle && !inDouble) {
169
+ if (c === '/' && next === '/') {
170
+ inLineComment = true;
171
+ i++;
172
+ continue;
173
+ }
174
+ if (c === '/' && next === '*') {
175
+ inBlockComment = true;
176
+ i++;
177
+ continue;
178
+ }
179
+ }
180
+
181
+ if (inDouble) {
182
+ if (!escape && c === '"') inDouble = false;
183
+ escape = c === '\\' ? !escape : false;
184
+ continue;
185
+ }
186
+ if (inSingle) {
187
+ if (!escape && c === "'") inSingle = false;
188
+ escape = c === '\\' ? !escape : false;
189
+ continue;
190
+ }
191
+
192
+ if (c === '"') {
193
+ inDouble = true;
194
+ escape = false;
195
+ continue;
196
+ }
197
+ if (c === "'") {
198
+ inSingle = true;
199
+ escape = false;
200
+ continue;
201
+ }
202
+
203
+ if (c === '{' || c === '[') {
204
+ if (stack.length === 0) start = i;
205
+ stack.push(c);
206
+ } else if (c === '}' || c === ']') {
207
+ if (stack.length > 0) {
208
+ const last = stack[stack.length - 1];
209
+ const expectedOpen = c === '}' ? '{' : '[';
210
+ if (last === expectedOpen) stack.pop();
211
+ }
212
+ if (stack.length === 0 && start !== -1) {
213
+ results.push({
214
+ snippet: text.slice(start, i + 1),
215
+ reason: 'balanced_segment',
216
+ complete: true,
217
+ });
218
+ start = -1;
219
+ }
220
+ }
221
+ }
222
+
223
+ if (stack.length > 0 && start !== -1) {
224
+ results.push({
225
+ snippet: text.slice(start),
226
+ reason: 'balanced_segment_incomplete',
227
+ complete: false,
228
+ });
229
+ }
230
+
231
+ return results;
232
+ }
233
+
234
+ function prioritizeCandidates(candidates) {
235
+ // Prefer json-tagged code blocks, then any code blocks, then balanced segments, then fallback
236
+ return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
237
+ }
238
+
239
+ function scoreCandidate(c) {
240
+ let score = 0;
241
+ if (c.reason.startsWith('codeblock')) score += 5;
242
+ if (c.lang === 'json') score += 5;
243
+ if (c.reason.includes('balanced_segment')) score += 3;
244
+ if (c.complete) score += 2;
245
+ return score;
246
+ }
247
+
248
+ // ----------------------------- Sanitization --------------------------------
249
+
250
+ function generateSanitizedAttempts(snippet) {
251
+ const trimmed = snippet.trim().replace(/^\uFEFF/, '');
252
+ const attempts = [];
253
+
254
+ // Attempt 1: minimal cleanup (comments + trailing commas)
255
+ {
256
+ let s = stripComments(trimmed);
257
+ s = removeTrailingCommas(s);
258
+ attempts.push(s);
259
+ }
260
+
261
+ // Attempt 2: full jsonish fixing
262
+ {
263
+ let s = jsonishFix(trimmed);
264
+ s = removeTrailingCommas(s);
265
+ attempts.push(s);
266
+ }
267
+
268
+ // Attempt 3: full jsonish + autoclose
269
+ {
270
+ let s = jsonishFix(trimmed);
271
+ s = autoCloseAndClean(s);
272
+ attempts.push(s);
273
+ }
274
+
275
+ return attempts;
276
+ }
277
+
278
+ function stripComments(input) {
279
+ let out = '';
280
+ let inDouble = false;
281
+ let inSingle = false;
282
+ let inLineComment = false;
283
+ let inBlockComment = false;
284
+ let escape = false;
285
+
286
+ for (let i = 0; i < input.length; i++) {
287
+ const c = input[i];
288
+ const next = input[i + 1];
289
+
290
+ if (inLineComment) {
291
+ if (c === '\n') {
292
+ inLineComment = false;
293
+ out += c;
294
+ }
295
+ continue;
296
+ }
297
+ if (inBlockComment) {
298
+ if (c === '*' && next === '/') {
299
+ inBlockComment = false;
300
+ i++;
301
+ }
302
+ continue;
303
+ }
304
+
305
+ if (!inSingle && !inDouble) {
306
+ if (c === '/' && next === '/') {
307
+ inLineComment = true;
308
+ i++;
309
+ continue;
310
+ }
311
+ if (c === '/' && next === '*') {
312
+ inBlockComment = true;
313
+ i++;
314
+ continue;
315
+ }
316
+ }
317
+
318
+ out += c;
319
+
320
+ if (inDouble) {
321
+ if (!escape && c === '"') inDouble = false;
322
+ escape = c === '\\' ? !escape : false;
323
+ } else if (inSingle) {
324
+ if (!escape && c === "'") inSingle = false;
325
+ escape = c === '\\' ? !escape : false;
326
+ } else {
327
+ if (c === '"') {
328
+ inDouble = true;
329
+ escape = false;
330
+ } else if (c === "'") {
331
+ inSingle = true;
332
+ escape = false;
333
+ }
334
+ }
335
+ }
336
+ return out;
337
+ }
338
+
339
+ function removeTrailingCommas(s) {
340
+ // Remove trailing comma before } or ]
341
+ return s.replace(/,(\s*[}\]])/g, '$1');
342
+ }
343
+
344
+ function jsonishFix(input) {
345
+ // Full pass: convert single quotes, quote unquoted keys, quote unquoted values, strip comments.
346
+ const noComments = stripComments(input);
347
+ // Convert single-quoted strings to double-quoted strings
348
+ const singlesFixed = convertSingleQuotedStrings(noComments);
349
+
350
+ // One pass state machine to quote keys and values where needed
351
+ const normalized = quoteKeysAndValues(singlesFixed);
352
+
353
+ return normalized;
354
+ }
355
+
356
+ function convertSingleQuotedStrings(s) {
357
+ let out = '';
358
+ let inDouble = false;
359
+ let inSingle = false;
360
+ let escape = false;
361
+
362
+ for (let i = 0; i < s.length; i++) {
363
+ const c = s[i];
364
+
365
+ if (inDouble) {
366
+ out += c;
367
+ if (!escape && c === '"') inDouble = false;
368
+ escape = c === '\\' ? !escape : false;
369
+ continue;
370
+ }
371
+ if (inSingle) {
372
+ if (!escape && c === "'") {
373
+ inSingle = false;
374
+ out += '"';
375
+ } else if (!escape && c === '"') {
376
+ out += '\\"';
377
+ } else if (c === '\\') {
378
+ // Keep escapes inside single quotes; next char is escaped
379
+ out += '\\';
380
+ } else {
381
+ out += c;
382
+ }
383
+ escape = c === '\\' ? !escape : false;
384
+ continue;
385
+ }
386
+
387
+ if (c === '"') {
388
+ inDouble = true;
389
+ out += c;
390
+ escape = false;
391
+ } else if (c === "'") {
392
+ inSingle = true;
393
+ out += '"';
394
+ escape = false;
395
+ } else {
396
+ out += c;
397
+ }
398
+ }
399
+
400
+ // If dangling single-quoted string (unlikely), close it
401
+ if (inSingle) out += '"';
402
+ return out;
403
+ }
404
+
405
+ function quoteKeysAndValues(s) {
406
+ // State machine through objects/arrays to:
407
+ // - Quote unquoted keys in objects
408
+ // - Quote unquoted values with spaces or path-like tokens
409
+ let out = '';
410
+ const ctxStack = []; // 'object' | 'array'
411
+ let inString = false;
412
+ let escape = false;
413
+ let expectingKey = false; // valid only when top ctx is object
414
+ let expectingValue = false;
415
+ let i = 0;
416
+
417
+ function top() {
418
+ return ctxStack.length ? ctxStack[ctxStack.length - 1] : null;
419
+ }
420
+
421
+ function skipWhitespace(idx) {
422
+ while (idx < s.length && /\s/.test(s[idx])) idx++;
423
+ return idx;
424
+ }
425
+
426
+ function readUntilColon(idx) {
427
+ // Read raw key until first colon at this nesting level (ignores quotes)
428
+ let buf = '';
429
+ let inD = false, esc = false;
430
+ for (; idx < s.length; idx++) {
431
+ const ch = s[idx];
432
+ if (inD) {
433
+ buf += ch;
434
+ if (!esc && ch === '"') inD = false;
435
+ esc = ch === '\\' ? !esc : false;
436
+ continue;
437
+ }
438
+ if (ch === '"') {
439
+ inD = true;
440
+ buf += ch;
441
+ esc = false;
442
+ continue;
443
+ }
444
+ if (ch === ':') {
445
+ return { keyRaw: buf, nextIdx: idx + 1 };
446
+ }
447
+ // guard: if we hit { or [ or } or ] or comma/newline before colon, abort
448
+ if (ch === '{' || ch === '[' || ch === '}' || ch === ']' || ch === ','
449
+ || ch === '\n') {
450
+ return null;
451
+ }
452
+ buf += ch;
453
+ }
454
+ return null;
455
+ }
456
+
457
+ function emitQuotedString(str) {
458
+ return JSON.stringify(str);
459
+ }
460
+
461
+ function quoteKeyIfNeeded(idx) {
462
+ // Assumes s[idx] at start of key position
463
+ let j = skipWhitespace(idx);
464
+ const ch = s[j];
465
+
466
+ if (ch === '"') {
467
+ // Already quoted
468
+ // Copy through quoted string
469
+ let buf = '';
470
+ let inD = true, esc = false;
471
+ for (; j < s.length; j++) {
472
+ const c = s[j];
473
+ buf += c;
474
+ if (inD) {
475
+ if (!esc && c === '"') { inD = false; j++; break; }
476
+ esc = c === '\\' ? !esc : false;
477
+ }
478
+ }
479
+ out += buf;
480
+ // Expect colon next (copy it and move on)
481
+ let k = skipWhitespace(j);
482
+ if (s[k] === ':') {
483
+ out += s.slice(j, k + 1);
484
+ return k + 1;
485
+ } else {
486
+ // If colon missing, just return next
487
+ return j;
488
+ }
489
+ } else if (ch === '}' || ch === undefined) {
490
+ // Empty object or invalid
491
+ out += s[idx];
492
+ return idx + 1;
493
+ } else {
494
+ // Unquoted key: read until colon
495
+ const res = readUntilColon(j);
496
+ if (!res) {
497
+ // Fallback: pass-through char and move on
498
+ out += s[idx];
499
+ return idx + 1;
500
+ }
501
+ const keyRaw = res.keyRaw;
502
+ // Trim whitespace
503
+ const key = keyRaw.trim();
504
+ // If key already looks like "something", retain inner; else quote raw key text
505
+ let quoted = '';
506
+ if (key.startsWith('"') && key.endsWith('"')) {
507
+ quoted = key;
508
+ } else {
509
+ // Strip any trailing commas/spaces in raw accumulation
510
+ const cleaned = key.replace(/\s+$/g, '');
511
+ quoted = emitQuotedString(unquoteIfQuoted(cleaned));
512
+ }
513
+ out += quoted + ':';
514
+ return res.nextIdx;
515
+ }
516
+ }
517
+
518
+ function unquoteIfQuoted(k) {
519
+ const t = k.trim();
520
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
521
+ return t.slice(1, -1);
522
+ }
523
+ return t;
524
+ }
525
+
526
+ function quoteValueIfNeeded(idx) {
527
+ let j = skipWhitespace(idx);
528
+ const ch = s[j];
529
+
530
+ if (ch === '"') {
531
+ // Already a string
532
+ // copy until end of string
533
+ let inD = true, esc = false;
534
+ for (; j < s.length; j++) {
535
+ const c = s[j];
536
+ out += c;
537
+ if (inD) {
538
+ if (!esc && c === '"') { inD = false; j++; break; }
539
+ esc = c === '\\' ? !esc : false;
540
+ }
541
+ }
542
+ return j;
543
+ }
544
+ if (ch === '{' || ch === '[') {
545
+ // Nested structure, let main loop handle
546
+ out += ch;
547
+ return j + 1;
548
+ }
549
+ if (ch === 't' && s.slice(j, j + 4) === 'true') { out += 'true'; return j + 4; }
550
+ if (ch === 'f' && s.slice(j, j + 5) === 'false') { out += 'false'; return j + 5; }
551
+ if (ch === 'n' && s.slice(j, j + 4) === 'null') { out += 'null'; return j + 4; }
552
+
553
+ // Number?
554
+ const numMatch = s.slice(j).match(/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/);
555
+ if (numMatch) {
556
+ out += numMatch[0];
557
+ return j + numMatch[0].length;
558
+ }
559
+
560
+ // Bareword/path-like or with spaces: read until comma or } or ]
561
+ // Respect basic string quoting inside by stopping at quotes (we'll leave them for main loop)
562
+ let k = j;
563
+ let buf = '';
564
+ while (k < s.length) {
565
+ const c = s[k];
566
+ if (c === ',' || c === '}' || c === ']' || c === '\n') break;
567
+ if (c === '"' || c === "'") break;
568
+ buf += c;
569
+ k++;
570
+ }
571
+ const val = buf.trim();
572
+ if (val.length > 0) {
573
+ out += emitQuotedString(val);
574
+ return k;
575
+ }
576
+
577
+ // Fallback: output the character and advance
578
+ out += s[j] || '';
579
+ return j + 1;
580
+ }
581
+
582
+ while (i < s.length) {
583
+ const c = s[i];
584
+
585
+ if (inString) {
586
+ out += c;
587
+ if (!escape && c === '"') {
588
+ inString = false;
589
+ }
590
+ escape = c === '\\' ? !escape : false;
591
+ i++;
592
+ continue;
593
+ }
594
+
595
+ if (c === '"') {
596
+ inString = true;
597
+ escape = false;
598
+ out += c;
599
+ i++;
600
+ continue;
601
+ }
602
+
603
+ if (c === '{') {
604
+ ctxStack.push('object');
605
+ expectingKey = true;
606
+ out += c;
607
+ i++;
608
+ continue;
609
+ }
610
+ if (c === '[') {
611
+ ctxStack.push('array');
612
+ expectingValue = true;
613
+ out += c;
614
+ i++;
615
+ continue;
616
+ }
617
+ if (c === '}') {
618
+ ctxStack.pop();
619
+ expectingKey = (top() === 'object'); // next in outer object we expect key after comma
620
+ out += c;
621
+ i++;
622
+ continue;
623
+ }
624
+ if (c === ']') {
625
+ ctxStack.pop();
626
+ expectingValue = (top() === 'array');
627
+ out += c;
628
+ i++;
629
+ continue;
630
+ }
631
+ if (c === ':') {
632
+ expectingKey = false;
633
+ expectingValue = true;
634
+ out += c;
635
+ i++;
636
+ continue;
637
+ }
638
+ if (c === ',') {
639
+ if (top() === 'object') {
640
+ expectingKey = true;
641
+ expectingValue = false;
642
+ } else if (top() === 'array') {
643
+ expectingValue = true;
644
+ }
645
+ out += c;
646
+ i++;
647
+ continue;
648
+ }
649
+
650
+ if (top() === 'object' && expectingKey) {
651
+ i = quoteKeyIfNeeded(i);
652
+ // After quoteKeyIfNeeded, we are positioned after colon or advanced minimally
653
+ // expectingValue should be true if colon was handled
654
+ // Heuristic: if last char written was ':', set expectingValue
655
+ if (out.length > 0 && out[out.length - 1] === ':') expectingValue = true;
656
+ continue;
657
+ }
658
+
659
+ if ((top() === 'object' && expectingValue) || (top() === 'array' && expectingValue)) {
660
+ i = quoteValueIfNeeded(i);
661
+ // After value, we wait for comma or close
662
+ expectingValue = false;
663
+ continue;
664
+ }
665
+
666
+ // Default: copy char
667
+ out += c;
668
+ i++;
669
+ }
670
+
671
+ return out;
672
+ }
673
+
674
+ function autoCloseAndClean(s) {
675
+ // Remove trailing commas before attempting to close
676
+ s = removeTrailingCommas(s);
677
+
678
+ // Auto-close brackets/braces
679
+ const closers = [];
680
+ let inString = false;
681
+ let escape = false;
682
+
683
+ for (let i = 0; i < s.length; i++) {
684
+ const c = s[i];
685
+ if (inString) {
686
+ if (!escape && c === '"') inString = false;
687
+ escape = c === '\\' ? !escape : false;
688
+ continue;
689
+ }
690
+ if (c === '"') {
691
+ inString = true;
692
+ escape = false;
693
+ continue;
694
+ }
695
+ if (c === '{') closers.push('}');
696
+ else if (c === '[') closers.push(']');
697
+ else if (c === '}' || c === ']') {
698
+ const last = closers[closers.length - 1];
699
+ if ((c === '}' && last === '}') || (c === ']' && last === ']')) {
700
+ closers.pop();
701
+ } else {
702
+ // Mismatch; ignore
703
+ }
704
+ }
705
+ }
706
+
707
+ // Remove trailing comma before closing we will append
708
+ s = s.replace(/,\s*$/, '');
709
+
710
+ return s + closers.reverse().join('');
711
+ }
712
+
713
+ // ------------------------------ Utilities ----------------------------------
714
+
715
+ function tryParseJSON(s) {
716
+ try {
717
+ return { ok: true, value: JSON.parse(s) };
718
+ } catch {
719
+ return { ok: false, value: null };
720
+ }
721
+ }
722
+
723
+ function stableStringify(v) {
724
+ // Basic stable stringify for dedupe
725
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
726
+ const keys = Object.keys(v).sort();
727
+ const obj = {};
728
+ for (const k of keys) obj[k] = v[k];
729
+ return JSON.stringify(obj, (_, val) =>
730
+ val && typeof val === 'object' && !Array.isArray(val)
731
+ ? Object.keys(val).sort().reduce((acc, kk) => (acc[kk] = val[kk], acc), {})
732
+ : val
733
+ );
734
+ }
735
+ return JSON.stringify(v);
736
+ }
@@ -16,6 +16,6 @@
16
16
  "description": "User message to send"
17
17
  }
18
18
  },
19
- "required": ["filename", "text"]
19
+ "required": ["text"]
20
20
  }
21
21
  }
package/src/patch.tool.js CHANGED
@@ -1,20 +1,21 @@
1
1
  const fs = require('fs').promises;
2
2
 
3
3
  // Patch tool to apply custom diffs marked with <<<<<<< ORIGINAL and >>>>>>> UPDATED
4
- // Handles patches with context and applies only the segments between markers.
5
-
4
+ // More tolerant to whitespace differences on each line and reports per-block success.
6
5
  module.exports = async function patch({ text, filename }, ctx) {
7
- // Regex to match each patch block
8
- // Be lenient about the number of conflict marker characters because some
9
- // environments may trim one or more > or < characters.
10
- const patchRegex = /<{6,}\s*ORIGINAL[^\n]*\n([\s\S]*?)=+\n([\s\S]*?)>{6,}\s*UPDATED[^\n]*(?:\n|$)/g;
11
- const patches = [];
12
- let match;
6
+ if (!text || !filename) {
7
+ return "No patch text or filename provided";
8
+ }
13
9
 
14
- // Extract all old/new segments
15
- while ((match = patchRegex.exec(text)) !== null) {
16
- const oldPart = match[1].replace(/^\n+|\n+$/g, "");
17
- const newPart = match[2].replace(/^\n+|\n+$/g, "");
10
+ // Match: <<<<<<< ORIGINAL ... ======= ... >>>>>>> UPDATED
11
+ // Be tolerant to CRLF/LF and optional trailing text/spaces on the markers.
12
+ const patchRegex = /<{6,}\s*ORIGINAL[^\n]*\r?\n([\s\S]*?)=+[^\n]*\r?\n([\s\S]*?)>{6,}\s*UPDATED[^\n]*(?:\r?\n|$)/g;
13
+
14
+ const patches = [];
15
+ let m;
16
+ while ((m = patchRegex.exec(text)) !== null) {
17
+ const oldPart = String(m[1]).replace(/^\s*\r?\n+|\r?\n+\s*$/g, "");
18
+ const newPart = String(m[2]).replace(/^\s*\r?\n+|\r?\n+\s*$/g, "");
18
19
  patches.push({ oldPart, newPart });
19
20
  }
20
21
 
@@ -24,18 +25,41 @@ module.exports = async function patch({ text, filename }, ctx) {
24
25
 
25
26
  let fileContent = await ctx.read(filename);
26
27
 
27
- for (const { oldPart, newPart } of patches) {
28
- // Escape regex special chars in oldPart.
29
- // Do NOT relax all whitespace to \s+; that can swallow preceding newlines.
30
- // Only normalize line endings so CRLF in patches can match LF in files.
31
- let escaped = oldPart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ function buildPattern(oldStr) {
29
+ // Escape special regex chars
30
+ let escaped = oldStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ // Normalize line endings to \r?\n so CRLF/LF both match
32
32
  escaped = escaped.replace(/\r?\n/g, "\\r?\\n");
33
- const oldRegex = new RegExp(escaped, "g");
33
+ // Tolerate indentation/space differences (spaces or tabs), zero-or-more
34
+ // Keep newlines strict so structure must still match.
35
+ escaped = escaped.replace(/[ \t]+/g, "[ \\t]*");
36
+ return new RegExp(escaped, "g");
37
+ }
38
+
39
+ const totalSegments = patches.length;
40
+ let appliedSegments = 0;
41
+ let totalReplacements = 0;
34
42
 
35
- // Perform replacement using a function to avoid replacement string ambiguities
36
- fileContent = fileContent.replace(oldRegex, () => newPart);
43
+ for (const { oldPart, newPart } of patches) {
44
+ const re = buildPattern(oldPart);
45
+ let matches = 0;
46
+ fileContent = fileContent.replace(re, () => {
47
+ matches += 1;
48
+ return newPart;
49
+ });
50
+ if (matches > 0) {
51
+ appliedSegments += 1;
52
+ totalReplacements += matches;
53
+ }
37
54
  }
38
55
 
39
56
  await ctx.write(filename, fileContent);
40
- return "patched";
57
+
58
+ if (appliedSegments === 0) {
59
+ return `no matches applied (0/${totalSegments})`;
60
+ }
61
+ if (appliedSegments < totalSegments) {
62
+ return `patched partially (${appliedSegments}/${totalSegments}), replacements: ${totalReplacements}`;
63
+ }
64
+ return `patched (${appliedSegments}/${totalSegments}), replacements: ${totalReplacements}`;
41
65
  };
@@ -12,6 +12,7 @@
12
12
  "enum": [
13
13
  "perplexity/sonar",
14
14
  "perplexity/sonar-pro",
15
+ "gpt-5-search-api",
15
16
  "gpt-4o-search-preview",
16
17
  "gpt-4o-mini-search-preview"
17
18
  ],