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/README.md +334 -0
- package/agent.ts +176 -0
- package/example/correction.ts +85 -0
- package/example/index.ts +31 -0
- package/example/module.ts +20 -0
- package/example/signature.ts +18 -0
- package/index.ts +127 -0
- package/module.ts +50 -0
- package/package.json +48 -0
- package/parsing.ts +865 -0
- package/pipeline.ts +213 -0
- package/predict.ts +271 -0
- package/signature.ts +97 -0
- package/state.ts +39 -0
- package/testing.ts +180 -0
- package/types.ts +260 -0
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
|
+
}
|