ocpipe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/parsing.ts ADDED
@@ -0,0 +1,865 @@
1
+ /**
2
+ * DSTS SDK response parsing.
3
+ *
4
+ * Extracts and validates LLM responses using JSON or field marker formats.
5
+ */
6
+
7
+ import { z } from 'zod'
8
+ import type { FieldConfig, FieldError, TryParseResult } from './types.js'
9
+
10
+ /** JSON Patch operation (RFC 6902). */
11
+ export interface JsonPatchOperation {
12
+ op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'
13
+ path: string
14
+ value?: unknown
15
+ from?: string
16
+ }
17
+
18
+ /** JsonParseError is thrown when JSON parsing fails. */
19
+ export class JsonParseError extends Error {
20
+ constructor(
21
+ message: string,
22
+ public readonly raw: string,
23
+ ) {
24
+ super(message)
25
+ this.name = 'JsonParseError'
26
+ }
27
+ }
28
+
29
+ /** ValidationError is thrown when Zod validation fails. */
30
+ export class ValidationError extends Error {
31
+ constructor(
32
+ message: string,
33
+ public readonly raw: string,
34
+ public readonly zodError: z.ZodError,
35
+ public readonly fieldErrors: FieldError[],
36
+ ) {
37
+ super(message)
38
+ this.name = 'ValidationError'
39
+ }
40
+ }
41
+
42
+ /** SchemaValidationError is thrown after schema correction attempts have been exhausted. */
43
+ export class SchemaValidationError extends Error {
44
+ constructor(
45
+ message: string,
46
+ public readonly fieldErrors: FieldError[],
47
+ public readonly correctionAttempts: number,
48
+ ) {
49
+ super(message)
50
+ this.name = 'SchemaValidationError'
51
+ }
52
+ }
53
+
54
+ /** parseResponse parses and validates an LLM response. */
55
+ export function parseResponse<T>(
56
+ response: string,
57
+ outputSchema: Record<string, FieldConfig>,
58
+ ): T {
59
+ return parseJson(response, outputSchema)
60
+ }
61
+
62
+ /** tryParseResponse attempts to parse and returns detailed errors on failure. */
63
+ export function tryParseResponse<T>(
64
+ response: string,
65
+ outputSchema: Record<string, FieldConfig>,
66
+ ): TryParseResult<T> {
67
+ return tryParseJson(response, outputSchema)
68
+ }
69
+
70
+ /** tryParseJson attempts JSON parsing with detailed field error detection. */
71
+ export function tryParseJson<T>(
72
+ response: string,
73
+ schema: Record<string, FieldConfig>,
74
+ ): TryParseResult<T> {
75
+ // Extract JSON from response
76
+ const jsonStr = extractJsonString(response)
77
+ if (!jsonStr) {
78
+ return {
79
+ ok: false,
80
+ errors: [{ path: '', message: 'No JSON found in response', expectedType: 'object' }],
81
+ }
82
+ }
83
+
84
+ let parsed: Record<string, unknown>
85
+ try {
86
+ parsed = JSON.parse(jsonStr) as Record<string, unknown>
87
+ } catch (e) {
88
+ const parseErr = e as SyntaxError
89
+ return {
90
+ ok: false,
91
+ errors: [{ path: '', message: `JSON parse failed: ${parseErr.message}`, expectedType: 'object' }],
92
+ }
93
+ }
94
+
95
+ // Convert null to undefined for optional fields (common LLM behavior)
96
+ parsed = convertNullToUndefined(parsed)
97
+
98
+ // Build Zod schema and validate
99
+ const shape: Record<string, z.ZodType> = {}
100
+ for (const [name, config] of Object.entries(schema)) {
101
+ shape[name] = config.type
102
+ }
103
+ const zodSchema = z.object(shape)
104
+ const result = zodSchema.safeParse(parsed)
105
+
106
+ if (result.success) {
107
+ return { ok: true, data: result.data as T, json: parsed }
108
+ }
109
+
110
+ // Convert Zod errors to FieldErrors with similar field detection
111
+ const errors = zodErrorsToFieldErrors(result.error, schema, parsed)
112
+ return { ok: false, errors, json: parsed }
113
+ }
114
+
115
+ /** zodErrorsToFieldErrors converts Zod errors to FieldErrors with similar field detection. */
116
+ function zodErrorsToFieldErrors(
117
+ zodError: z.ZodError,
118
+ schema: Record<string, FieldConfig>,
119
+ parsed: Record<string, unknown>,
120
+ ): FieldError[] {
121
+ const errors: FieldError[] = []
122
+ const schemaKeys = Object.keys(schema)
123
+
124
+ for (const issue of zodError.issues) {
125
+ const path = issue.path.join('.')
126
+ const expectedType = getExpectedType(issue, schema)
127
+
128
+ // Check for similar field names (typos, different casing, etc.)
129
+ const fieldName = issue.path[0] as string
130
+ let foundField: string | undefined
131
+ let foundValue: unknown
132
+
133
+ // Check if field is missing (received undefined)
134
+ const isMissing = issue.code === 'invalid_type' &&
135
+ (issue as { received?: string }).received === 'undefined'
136
+
137
+ if (isMissing) {
138
+ // Field is missing - look for similar field names in parsed data
139
+ const similar = findSimilarField(fieldName, parsed, schemaKeys)
140
+ if (similar) {
141
+ foundField = similar
142
+ foundValue = parsed[similar]
143
+ }
144
+ }
145
+
146
+ errors.push({
147
+ path,
148
+ message: issue.message,
149
+ expectedType,
150
+ foundField,
151
+ foundValue,
152
+ })
153
+ }
154
+
155
+ return errors
156
+ }
157
+
158
+ /** findSimilarField looks for fields that might be typos or alternatives. */
159
+ function findSimilarField(
160
+ expectedField: string,
161
+ parsed: Record<string, unknown>,
162
+ schemaKeys: string[],
163
+ ): string | undefined {
164
+ const parsedKeys = Object.keys(parsed)
165
+ const extraKeys = parsedKeys.filter((k) => !schemaKeys.includes(k))
166
+
167
+ // Common field name variations
168
+ const variations: Record<string, string[]> = {
169
+ issue_type: ['type', 'issueType', 'issue', 'kind', 'category'],
170
+ segment_index: ['index', 'segmentIndex', 'segment_idx', 'idx'],
171
+ timestamp_sec: ['timestamp', 'time', 'time_sec', 'seconds', 'timestampSec'],
172
+ why_awkward: ['description', 'reason', 'explanation', 'why'],
173
+ ideal_state: ['suggestion', 'suggested', 'fix', 'recommendation'],
174
+ severity: ['priority', 'level', 'importance'],
175
+ }
176
+
177
+ // Check known variations
178
+ const knownVariations = variations[expectedField]
179
+ if (knownVariations) {
180
+ for (const v of knownVariations) {
181
+ if (extraKeys.includes(v)) return v
182
+ }
183
+ }
184
+
185
+ // Check for similar names (simple edit distance heuristic)
186
+ const normalized = expectedField.toLowerCase().replace(/_/g, '')
187
+ for (const key of extraKeys) {
188
+ const normalizedKey = key.toLowerCase().replace(/_/g, '')
189
+ if (normalizedKey === normalized) return key
190
+ if (normalizedKey.includes(normalized) || normalized.includes(normalizedKey)) return key
191
+ }
192
+
193
+ return undefined
194
+ }
195
+
196
+ /** getExpectedType extracts a human-readable type description from a Zod issue. */
197
+ function getExpectedType(issue: z.ZodIssue, schema: Record<string, FieldConfig>): string {
198
+ const fieldName = issue.path[0] as string
199
+ const fieldConfig = schema[fieldName]
200
+
201
+ if (fieldConfig) {
202
+ return zodTypeToString(fieldConfig.type)
203
+ }
204
+
205
+ if (issue.code === 'invalid_type') {
206
+ return issue.expected
207
+ }
208
+
209
+ return 'unknown'
210
+ }
211
+
212
+ /** zodTypeToString converts a Zod type to a readable string for prompts. */
213
+ export function zodTypeToString(zodType: z.ZodType): string {
214
+ // Use instanceof checks with proper Zod class methods where available
215
+ if (zodType instanceof z.ZodString) {
216
+ const desc = zodType.description
217
+ return desc ? `string (${desc})` : 'string'
218
+ }
219
+ if (zodType instanceof z.ZodNumber) return 'number'
220
+ if (zodType instanceof z.ZodBoolean) return 'boolean'
221
+ if (zodType instanceof z.ZodEnum) {
222
+ // ZodEnum has .options property in v3/v4
223
+ const opts = (zodType as unknown as { options?: readonly string[] }).options
224
+ if (opts && opts.length > 0) {
225
+ return `enum[${opts.map((v) => `"${v}"`).join(', ')}]`
226
+ }
227
+ // Fallback to _def
228
+ const def = (zodType as unknown as { _def?: { values?: readonly string[] } })._def
229
+ const values = def?.values ?? []
230
+ if (values.length > 0) {
231
+ return `enum[${values.map((v) => `"${v}"`).join(', ')}]`
232
+ }
233
+ return 'enum'
234
+ }
235
+ if (zodType instanceof z.ZodArray) {
236
+ // ZodArray has .element property
237
+ const elem = (zodType as unknown as { element?: z.ZodType }).element
238
+ if (elem) {
239
+ return `array<${zodTypeToString(elem)}>`
240
+ }
241
+ return 'array'
242
+ }
243
+ if (zodType instanceof z.ZodObject) {
244
+ // ZodObject has .shape property
245
+ const shapeObj = (zodType as unknown as { shape?: Record<string, z.ZodType> }).shape
246
+ if (shapeObj) {
247
+ const fields = Object.keys(shapeObj).slice(0, 3).join(', ')
248
+ return `object{${fields}${Object.keys(shapeObj).length > 3 ? ', ...' : ''}}`
249
+ }
250
+ return 'object'
251
+ }
252
+ if (zodType instanceof z.ZodOptional) {
253
+ // ZodOptional has .unwrap() method
254
+ const unwrapped = (zodType as unknown as { unwrap?: () => z.ZodType }).unwrap?.()
255
+ if (unwrapped) {
256
+ return `optional<${zodTypeToString(unwrapped)}>`
257
+ }
258
+ return 'optional'
259
+ }
260
+ if (zodType instanceof z.ZodNullable) {
261
+ const unwrapped = (zodType as unknown as { unwrap?: () => z.ZodType }).unwrap?.()
262
+ if (unwrapped) {
263
+ return `nullable<${zodTypeToString(unwrapped)}>`
264
+ }
265
+ return 'nullable'
266
+ }
267
+ if (zodType instanceof z.ZodDefault) {
268
+ // ZodDefault wraps inner type
269
+ const inner = (zodType as unknown as { _def?: { innerType?: z.ZodType } })._def?.innerType
270
+ if (inner) {
271
+ return `default<${zodTypeToString(inner)}>`
272
+ }
273
+ return 'default'
274
+ }
275
+ return 'unknown'
276
+ }
277
+
278
+ /** extractJsonString finds and extracts JSON from a response string. */
279
+ export function extractJsonString(response: string): string | null {
280
+ // Try to find JSON in code blocks first
281
+ const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?)```/)
282
+ if (codeBlockMatch?.[1]) {
283
+ return codeBlockMatch[1].trim()
284
+ }
285
+
286
+ // Try to find raw JSON by counting braces
287
+ const startIdx = response.indexOf('{')
288
+ if (startIdx !== -1) {
289
+ let braceCount = 0
290
+ let endIdx = startIdx
291
+ for (let i = startIdx; i < response.length; i++) {
292
+ if (response[i] === '{') braceCount++
293
+ else if (response[i] === '}') {
294
+ braceCount--
295
+ if (braceCount === 0) {
296
+ endIdx = i + 1
297
+ break
298
+ }
299
+ }
300
+ }
301
+
302
+ if (endIdx > startIdx) {
303
+ return response.slice(startIdx, endIdx)
304
+ }
305
+ }
306
+
307
+ return null
308
+ }
309
+
310
+ /** buildPatchPrompt creates a prompt asking for a jq-style patch for a field error. */
311
+ export function buildPatchPrompt(
312
+ error: FieldError,
313
+ currentJson: Record<string, unknown>,
314
+ _schema: Record<string, FieldConfig>,
315
+ ): string {
316
+ const lines: string[] = []
317
+
318
+ lines.push('Your JSON output has a schema error that needs correction.')
319
+ lines.push('')
320
+ lines.push(`Field: "${error.path}"`)
321
+ lines.push(`Issue: ${error.message}`)
322
+ lines.push(`Expected type: ${error.expectedType}`)
323
+
324
+ if (error.foundField) {
325
+ lines.push(`Found similar field: "${error.foundField}" with value: ${JSON.stringify(error.foundValue)}`)
326
+ }
327
+
328
+ lines.push('')
329
+ lines.push('Current JSON (abbreviated):')
330
+ lines.push('```json')
331
+ lines.push(JSON.stringify(abbreviateJson(currentJson), null, 2))
332
+ lines.push('```')
333
+ lines.push('')
334
+ lines.push('Respond with ONLY a jq-style patch to fix this field. Examples:')
335
+ lines.push('- .field_name = "value"')
336
+ lines.push('- .field_name = 123')
337
+ lines.push('- .field_name = .other_field')
338
+ lines.push('- del(.wrong_field) | .correct_field = "value"')
339
+ lines.push('')
340
+ lines.push('Your patch:')
341
+
342
+ return lines.join('\n')
343
+ }
344
+
345
+ /** buildBatchPatchPrompt creates a prompt asking for jq-style patches for multiple errors at once. */
346
+ export function buildBatchPatchPrompt(
347
+ errors: FieldError[],
348
+ currentJson: Record<string, unknown>,
349
+ ): string {
350
+ const lines: string[] = []
351
+
352
+ lines.push('Your JSON output has schema errors that need correction.')
353
+ lines.push('')
354
+ lines.push('ERRORS:')
355
+ for (let i = 0; i < errors.length; i++) {
356
+ const error = errors[i]!
357
+ lines.push(`${i + 1}. Field "${error.path}": ${error.message} (expected: ${error.expectedType})`)
358
+ if (error.foundField) {
359
+ lines.push(` Found similar: "${error.foundField}" = ${JSON.stringify(error.foundValue)}`)
360
+ }
361
+ }
362
+
363
+ lines.push('')
364
+ lines.push('Current JSON (abbreviated):')
365
+ lines.push('```json')
366
+ lines.push(JSON.stringify(abbreviateJson(currentJson), null, 2))
367
+ lines.push('```')
368
+ lines.push('')
369
+ lines.push('Respond with jq-style patches to fix ALL errors. Use | to chain multiple patches.')
370
+ lines.push('Examples:')
371
+ lines.push('- .field1 = "value" | .field2 = 123')
372
+ lines.push('- .items[0].name = .items[0].title | del(.items[0].title)')
373
+ lines.push('- .changes[2].rationale = .changes[2].reason')
374
+ lines.push('')
375
+ lines.push('Your patches (one line, pipe-separated):')
376
+
377
+ return lines.join('\n')
378
+ }
379
+
380
+ /** abbreviateJson truncates large values for display in prompts. */
381
+ function abbreviateJson(obj: Record<string, unknown>, maxLength = 100): Record<string, unknown> {
382
+ const result: Record<string, unknown> = {}
383
+
384
+ for (const [key, value] of Object.entries(obj)) {
385
+ if (Array.isArray(value)) {
386
+ if (value.length <= 2) {
387
+ result[key] = value
388
+ } else {
389
+ result[key] = [...value.slice(0, 2), `... (${value.length - 2} more)`]
390
+ }
391
+ } else if (typeof value === 'string' && value.length > maxLength) {
392
+ result[key] = value.slice(0, maxLength) + '...'
393
+ } else if (typeof value === 'object' && value !== null) {
394
+ result[key] = abbreviateJson(value as Record<string, unknown>, maxLength)
395
+ } else {
396
+ result[key] = value
397
+ }
398
+ }
399
+
400
+ return result
401
+ }
402
+
403
+ /** convertNullToUndefined recursively converts null values to undefined (for optional fields). */
404
+ function convertNullToUndefined(obj: Record<string, unknown>): Record<string, unknown> {
405
+ const result: Record<string, unknown> = {}
406
+
407
+ for (const [key, value] of Object.entries(obj)) {
408
+ if (value === null) {
409
+ // Skip null values (converts to undefined/missing)
410
+ continue
411
+ }
412
+ if (Array.isArray(value)) {
413
+ // For arrays, recursively process objects but keep nulls as-is to preserve indices
414
+ result[key] = value.map((item) => {
415
+ if (typeof item === 'object' && item !== null) {
416
+ return convertNullToUndefined(item as Record<string, unknown>)
417
+ }
418
+ return item
419
+ })
420
+ } else if (typeof value === 'object') {
421
+ result[key] = convertNullToUndefined(value as Record<string, unknown>)
422
+ } else {
423
+ result[key] = value
424
+ }
425
+ }
426
+
427
+ return result
428
+ }
429
+
430
+ /** extractPatch extracts a jq-style patch from an LLM response. */
431
+ export function extractPatch(response: string): string {
432
+ // Look for a line that starts with a dot (jq field reference)
433
+ const lines = response.split('\n')
434
+ for (const line of lines) {
435
+ const trimmed = line.trim()
436
+ if (trimmed.startsWith('.') || trimmed.startsWith('del(')) {
437
+ return trimmed
438
+ }
439
+ }
440
+
441
+ // If no clear patch found, try the whole response (minus markdown)
442
+ const cleaned = response.replace(/```[^`]*```/g, '').trim()
443
+ const firstLine = cleaned.split('\n')[0]?.trim()
444
+ if (firstLine && (firstLine.startsWith('.') || firstLine.startsWith('del('))) {
445
+ return firstLine
446
+ }
447
+
448
+ return response.trim()
449
+ }
450
+
451
+ /** applyJqPatch applies a jq-style patch to a JSON object using the actual jq tool. */
452
+ export function applyJqPatch(
453
+ obj: Record<string, unknown>,
454
+ patch: string,
455
+ ): Record<string, unknown> {
456
+ // Validate patch format - only allow safe jq operations
457
+ // Allow: field access (.foo), array indexing ([0]), assignment (=), deletion (del()), pipes (|)
458
+ // Disallow: shell commands, $ENV, input/inputs, @base64d, system, etc.
459
+ const unsafePatterns = [
460
+ /\$ENV/i,
461
+ /\$__loc__/i,
462
+ /\binput\b/,
463
+ /\binputs\b/,
464
+ /\bsystem\b/,
465
+ /\@base64d/,
466
+ /\@uri/,
467
+ /\@csv/,
468
+ /\@tsv/,
469
+ /\@json/,
470
+ /\@text/,
471
+ /\@sh/,
472
+ /`[^`]*`/, // Backtick string interpolation
473
+ /\bimport\b/,
474
+ /\binclude\b/,
475
+ /\bdebug\b/,
476
+ /\berror\b/,
477
+ /\bhalt\b/,
478
+ /\$/, // Any variable reference (safest to disallow all)
479
+ ]
480
+
481
+ for (const pattern of unsafePatterns) {
482
+ if (pattern.test(patch)) {
483
+ console.error(` Unsafe jq pattern detected, skipping patch: ${patch}`)
484
+ return obj
485
+ }
486
+ }
487
+
488
+ // Only allow patches that look like field operations
489
+ // Valid: .foo = "bar", .items[0].name = .items[0].title, del(.foo) | .bar = 1
490
+ const safePattern = /^[\s\w\[\]."'=|,:\-{}]*$/
491
+ if (!safePattern.test(patch)) {
492
+ console.error(` Invalid characters in patch, skipping: ${patch}`)
493
+ return obj
494
+ }
495
+
496
+ try {
497
+ const input = JSON.stringify(obj)
498
+ // Pass patch as argument (not shell-interpolated) - Bun.spawnSync uses execve directly
499
+ const result = Bun.spawnSync(['jq', '--', patch], {
500
+ stdin: Buffer.from(input),
501
+ stdout: 'pipe',
502
+ stderr: 'pipe',
503
+ })
504
+
505
+ if (result.exitCode !== 0) {
506
+ const stderr = result.stderr.toString().trim()
507
+ console.error(` jq error: ${stderr}`)
508
+ return obj
509
+ }
510
+
511
+ const output = result.stdout.toString().trim()
512
+ return JSON.parse(output) as Record<string, unknown>
513
+ } catch (e) {
514
+ console.error(` jq execution failed: ${e}`)
515
+ return obj
516
+ }
517
+ }
518
+
519
+ // ============================================================================
520
+ // JSON Patch (RFC 6902) Support
521
+ // ============================================================================
522
+
523
+ /** buildJsonPatchPrompt creates a prompt asking for RFC 6902 JSON Patch operations. */
524
+ export function buildJsonPatchPrompt(
525
+ error: FieldError,
526
+ currentJson: Record<string, unknown>,
527
+ _schema: Record<string, FieldConfig>,
528
+ ): string {
529
+ const lines: string[] = []
530
+
531
+ lines.push('Your JSON output has a schema error that needs correction.')
532
+ lines.push('')
533
+ lines.push(`Field: "${error.path}"`)
534
+ lines.push(`Issue: ${error.message}`)
535
+ lines.push(`Expected type: ${error.expectedType}`)
536
+
537
+ if (error.foundField) {
538
+ lines.push(`Found similar field: "${error.foundField}" with value: ${JSON.stringify(error.foundValue)}`)
539
+ }
540
+
541
+ lines.push('')
542
+ lines.push('Current JSON (abbreviated):')
543
+ lines.push('```json')
544
+ lines.push(JSON.stringify(abbreviateJson(currentJson), null, 2))
545
+ lines.push('```')
546
+ lines.push('')
547
+ lines.push('Respond with ONLY a JSON Patch array (RFC 6902) to fix this field. Examples:')
548
+ lines.push('- [{"op": "add", "path": "/field_name", "value": "new_value"}]')
549
+ lines.push('- [{"op": "replace", "path": "/field_name", "value": 123}]')
550
+ lines.push('- [{"op": "move", "from": "/wrong_field", "path": "/correct_field"}]')
551
+ lines.push('- [{"op": "remove", "path": "/wrong_field"}, {"op": "add", "path": "/correct_field", "value": "..."}]')
552
+ lines.push('')
553
+ lines.push('Your JSON Patch:')
554
+
555
+ return lines.join('\n')
556
+ }
557
+
558
+ /** buildBatchJsonPatchPrompt creates a prompt asking for JSON Patch operations for multiple errors. */
559
+ export function buildBatchJsonPatchPrompt(
560
+ errors: FieldError[],
561
+ currentJson: Record<string, unknown>,
562
+ ): string {
563
+ const lines: string[] = []
564
+
565
+ lines.push('Your JSON output has schema errors that need correction.')
566
+ lines.push('')
567
+ lines.push('ERRORS:')
568
+ for (let i = 0; i < errors.length; i++) {
569
+ const error = errors[i]!
570
+ lines.push(`${i + 1}. Field "${error.path}": ${error.message} (expected: ${error.expectedType})`)
571
+ if (error.foundField) {
572
+ lines.push(` Found similar: "${error.foundField}" = ${JSON.stringify(error.foundValue)}`)
573
+ }
574
+ }
575
+
576
+ lines.push('')
577
+ lines.push('Current JSON (abbreviated):')
578
+ lines.push('```json')
579
+ lines.push(JSON.stringify(abbreviateJson(currentJson), null, 2))
580
+ lines.push('```')
581
+ lines.push('')
582
+ lines.push('Respond with a JSON Patch array (RFC 6902) to fix ALL errors. Examples:')
583
+ lines.push('- [{"op": "move", "from": "/type", "path": "/issue_type"}]')
584
+ lines.push('- [{"op": "replace", "path": "/items/0/name", "value": "fixed"}]')
585
+ lines.push('- [{"op": "add", "path": "/missing_field", "value": "default"}]')
586
+ lines.push('')
587
+ lines.push('Your JSON Patch array:')
588
+
589
+ return lines.join('\n')
590
+ }
591
+
592
+ /** extractJsonPatch extracts a JSON Patch array from an LLM response. */
593
+ export function extractJsonPatch(response: string): JsonPatchOperation[] {
594
+ // Try to find JSON array in code blocks first
595
+ const codeBlockMatch = response.match(/```(?:json)?\s*(\[[\s\S]*?\])[\s\S]*?```/)
596
+ if (codeBlockMatch?.[1]) {
597
+ try {
598
+ return JSON.parse(codeBlockMatch[1]) as JsonPatchOperation[]
599
+ } catch {
600
+ // Continue to try other methods
601
+ }
602
+ }
603
+
604
+ // Try to find raw JSON array by counting brackets
605
+ const startIdx = response.indexOf('[')
606
+ if (startIdx !== -1) {
607
+ let bracketCount = 0
608
+ let endIdx = startIdx
609
+ for (let i = startIdx; i < response.length; i++) {
610
+ if (response[i] === '[') bracketCount++
611
+ else if (response[i] === ']') {
612
+ bracketCount--
613
+ if (bracketCount === 0) {
614
+ endIdx = i + 1
615
+ break
616
+ }
617
+ }
618
+ }
619
+
620
+ if (endIdx > startIdx) {
621
+ try {
622
+ return JSON.parse(response.slice(startIdx, endIdx)) as JsonPatchOperation[]
623
+ } catch {
624
+ // Fall through to empty array
625
+ }
626
+ }
627
+ }
628
+
629
+ console.error(` Could not extract JSON Patch from response: ${response.slice(0, 100)}...`)
630
+ return []
631
+ }
632
+
633
+ /** toJsonPointer converts a dot-notation path to JSON Pointer format. */
634
+ function toJsonPointer(path: string): string {
635
+ if (path.startsWith('/')) return path
636
+ if (path === '') return ''
637
+ // Convert dot notation to JSON Pointer
638
+ // e.g., "items.0.name" -> "/items/0/name"
639
+ return '/' + path.replace(/\./g, '/').replace(/\[(\d+)\]/g, '/$1')
640
+ }
641
+
642
+ /** applyJsonPatch applies RFC 6902 JSON Patch operations to an object. */
643
+ export function applyJsonPatch(
644
+ obj: Record<string, unknown>,
645
+ operations: JsonPatchOperation[],
646
+ ): Record<string, unknown> {
647
+ let result = JSON.parse(JSON.stringify(obj)) as Record<string, unknown>
648
+
649
+ for (const op of operations) {
650
+ const path = toJsonPointer(op.path)
651
+ const pathParts = path.split('/').filter(Boolean)
652
+
653
+ try {
654
+ switch (op.op) {
655
+ case 'add':
656
+ case 'replace': {
657
+ if (pathParts.length === 0) {
658
+ // Replace entire document
659
+ if (typeof op.value === 'object' && op.value !== null) {
660
+ result = op.value as Record<string, unknown>
661
+ }
662
+ } else {
663
+ setValueAtPath(result, pathParts, op.value)
664
+ }
665
+ break
666
+ }
667
+ case 'remove': {
668
+ removeValueAtPath(result, pathParts)
669
+ break
670
+ }
671
+ case 'move': {
672
+ if (!op.from) break
673
+ const fromPath = toJsonPointer(op.from)
674
+ const fromParts = fromPath.split('/').filter(Boolean)
675
+ const value = getValueAtPath(result, fromParts)
676
+ removeValueAtPath(result, fromParts)
677
+ setValueAtPath(result, pathParts, value)
678
+ break
679
+ }
680
+ case 'copy': {
681
+ if (!op.from) break
682
+ const srcPath = toJsonPointer(op.from)
683
+ const srcParts = srcPath.split('/').filter(Boolean)
684
+ const srcValue = getValueAtPath(result, srcParts)
685
+ setValueAtPath(result, pathParts, JSON.parse(JSON.stringify(srcValue)))
686
+ break
687
+ }
688
+ case 'test': {
689
+ // Test operation - verify value matches, throw if not
690
+ const actualValue = getValueAtPath(result, pathParts)
691
+ if (JSON.stringify(actualValue) !== JSON.stringify(op.value)) {
692
+ console.error(` JSON Patch test failed: ${path} expected ${JSON.stringify(op.value)}, got ${JSON.stringify(actualValue)}`)
693
+ }
694
+ break
695
+ }
696
+ }
697
+ } catch (e) {
698
+ console.error(` JSON Patch operation failed: ${JSON.stringify(op)} - ${e}`)
699
+ }
700
+ }
701
+
702
+ return result
703
+ }
704
+
705
+ /** getValueAtPath retrieves a value at a JSON Pointer path. */
706
+ function getValueAtPath(obj: Record<string, unknown>, parts: string[]): unknown {
707
+ let current: unknown = obj
708
+ for (const part of parts) {
709
+ if (current === null || current === undefined) return undefined
710
+ if (Array.isArray(current)) {
711
+ const idx = parseInt(part, 10)
712
+ current = current[idx]
713
+ } else if (typeof current === 'object') {
714
+ current = (current as Record<string, unknown>)[part]
715
+ } else {
716
+ return undefined
717
+ }
718
+ }
719
+ return current
720
+ }
721
+
722
+ /** setValueAtPath sets a value at a JSON Pointer path. */
723
+ function setValueAtPath(obj: Record<string, unknown>, parts: string[], value: unknown): void {
724
+ if (parts.length === 0) return
725
+
726
+ let current: unknown = obj
727
+ for (let i = 0; i < parts.length - 1; i++) {
728
+ const part = parts[i]!
729
+ if (Array.isArray(current)) {
730
+ const idx = parseInt(part, 10)
731
+ if (current[idx] === undefined) {
732
+ // Create intermediate object or array
733
+ const nextPart = parts[i + 1]!
734
+ current[idx] = /^\d+$/.test(nextPart) ? [] : {}
735
+ }
736
+ current = current[idx]
737
+ } else if (typeof current === 'object' && current !== null) {
738
+ const rec = current as Record<string, unknown>
739
+ if (rec[part] === undefined) {
740
+ const nextPart = parts[i + 1]!
741
+ rec[part] = /^\d+$/.test(nextPart) ? [] : {}
742
+ }
743
+ current = rec[part]
744
+ }
745
+ }
746
+
747
+ const lastPart = parts[parts.length - 1]!
748
+ if (Array.isArray(current)) {
749
+ const idx = parseInt(lastPart, 10)
750
+ current[idx] = value
751
+ } else if (typeof current === 'object' && current !== null) {
752
+ (current as Record<string, unknown>)[lastPart] = value
753
+ }
754
+ }
755
+
756
+ /** removeValueAtPath removes a value at a JSON Pointer path. */
757
+ function removeValueAtPath(obj: Record<string, unknown>, parts: string[]): void {
758
+ if (parts.length === 0) return
759
+
760
+ let current: unknown = obj
761
+ for (let i = 0; i < parts.length - 1; i++) {
762
+ const part = parts[i]!
763
+ if (Array.isArray(current)) {
764
+ current = current[parseInt(part, 10)]
765
+ } else if (typeof current === 'object' && current !== null) {
766
+ current = (current as Record<string, unknown>)[part]
767
+ } else {
768
+ return
769
+ }
770
+ }
771
+
772
+ const lastPart = parts[parts.length - 1]!
773
+ if (Array.isArray(current)) {
774
+ const idx = parseInt(lastPart, 10)
775
+ current.splice(idx, 1)
776
+ } else if (typeof current === 'object' && current !== null) {
777
+ delete (current as Record<string, unknown>)[lastPart]
778
+ }
779
+ }
780
+
781
+ /** parseJson extracts and validates JSON from an LLM response. */
782
+ export function parseJson<T>(
783
+ response: string,
784
+ schema: Record<string, FieldConfig>,
785
+ ): T {
786
+ const result = tryParseJson<T>(response, schema)
787
+ if (result.ok && result.data) {
788
+ return result.data
789
+ }
790
+
791
+ const errors = result.errors ?? []
792
+ if (errors.length > 0 && errors[0]?.message.includes('JSON parse failed')) {
793
+ throw new JsonParseError(errors[0].message, response)
794
+ }
795
+ if (errors.length > 0 && errors[0]?.message.includes('No JSON found')) {
796
+ throw new JsonParseError(errors[0].message, response)
797
+ }
798
+
799
+ // Validation error
800
+ const shape: Record<string, z.ZodType> = {}
801
+ for (const [name, config] of Object.entries(schema)) {
802
+ shape[name] = config.type
803
+ }
804
+ const zodSchema = z.object(shape)
805
+ const zodResult = zodSchema.safeParse(result.json)
806
+
807
+ if (!zodResult.success) {
808
+ const issues = zodResult.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')
809
+ throw new ValidationError(`Output validation failed: ${issues}`, response, zodResult.error, errors)
810
+ }
811
+
812
+ // Shouldn't reach here
813
+ throw new JsonParseError('Unknown parse error', response)
814
+ }
815
+
816
+ /** parseJsonFromResponse is a simpler JSON parser without schema validation. */
817
+ export function parseJsonFromResponse<T = Record<string, unknown>>(
818
+ response: string,
819
+ ): T {
820
+ // Try to find JSON in code blocks first
821
+ const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?)```/)
822
+ if (codeBlockMatch?.[1]) {
823
+ const blockContent = codeBlockMatch[1].trim()
824
+ try {
825
+ return JSON.parse(blockContent) as T
826
+ } catch {
827
+ // Continue to try other methods
828
+ }
829
+ }
830
+
831
+ // Try to find raw JSON object by counting braces
832
+ const startIdx = response.indexOf('{')
833
+ if (startIdx !== -1) {
834
+ let braceCount = 0
835
+ let endIdx = startIdx
836
+ for (let i = startIdx; i < response.length; i++) {
837
+ if (response[i] === '{') braceCount++
838
+ else if (response[i] === '}') {
839
+ braceCount--
840
+ if (braceCount === 0) {
841
+ endIdx = i + 1
842
+ break
843
+ }
844
+ }
845
+ }
846
+
847
+ if (endIdx > startIdx) {
848
+ const jsonStr = response.slice(startIdx, endIdx)
849
+ try {
850
+ return JSON.parse(jsonStr) as T
851
+ } catch (e) {
852
+ const parseErr = e as SyntaxError
853
+ throw new JsonParseError(
854
+ `JSON parse failed: ${parseErr.message}. Extracted: ${jsonStr.slice(0, 200)}...`,
855
+ response,
856
+ )
857
+ }
858
+ }
859
+ }
860
+
861
+ throw new JsonParseError(
862
+ `No valid JSON found in response (${response.length} chars). Preview: ${response.slice(0, 300)}...`,
863
+ response,
864
+ )
865
+ }