shape.ts
1 /** 2 * JSON shape inference for browser network response previews. 3 * 4 * Produces a flat path → type descriptor map so agents can understand 5 * response structure without paying the token cost of the full body. 6 * 7 * Descriptors: 8 * string | number | boolean | null primitives 9 * string(len=N) strings longer than sampleStringLen 10 * array(0) | array(N) array at depth cap or summarized 11 * object | object(empty) objects at depth cap or summarized 12 * (truncated) output size budget exceeded 13 */ 14 15 export interface InferShapeOptions { 16 /** Max path depth to descend into (default 6) */ 17 maxDepth?: number; 18 /** Byte budget for the serialized output; truncates when exceeded (default 2048) */ 19 maxBytes?: number; 20 /** Strings longer than this get summarized as `string(len=N)` (default 80) */ 21 sampleStringLen?: number; 22 } 23 24 export type Shape = Record<string, string>; 25 26 const ROOT = '$'; 27 28 export function inferShape(value: unknown, opts: InferShapeOptions = {}): Shape { 29 const maxDepth = opts.maxDepth ?? 6; 30 const maxBytes = opts.maxBytes ?? 2048; 31 const sampleStringLen = opts.sampleStringLen ?? 80; 32 33 const out: Shape = {}; 34 let bytes = 2; // account for `{}` braces when serialized 35 let truncated = false; 36 37 const add = (path: string, desc: string): boolean => { 38 if (truncated) return false; 39 const entryBytes = JSON.stringify(path).length + JSON.stringify(desc).length + 2; // ":" + "," 40 if (bytes + entryBytes > maxBytes) { 41 out['(truncated)'] = `reached ${maxBytes}B budget`; 42 truncated = true; 43 return false; 44 } 45 out[path] = desc; 46 bytes += entryBytes; 47 return true; 48 }; 49 50 const walk = (node: unknown, path: string, depth: number): void => { 51 if (truncated) return; 52 53 if (node === null) { add(path, 'null'); return; } 54 const t = typeof node; 55 if (t === 'string') { 56 const s = node as string; 57 add(path, s.length > sampleStringLen ? `string(len=${s.length})` : 'string'); 58 return; 59 } 60 if (t === 'number' || t === 'boolean') { add(path, t); return; } 61 if (t === 'undefined' || t === 'function' || t === 'symbol' || t === 'bigint') { 62 add(path, t); 63 return; 64 } 65 66 if (Array.isArray(node)) { 67 if (node.length === 0) { add(path, 'array(0)'); return; } 68 if (depth >= maxDepth) { add(path, `array(${node.length})`); return; } 69 if (!add(path, `array(${node.length})`)) return; 70 walk(node[0], `${path}[0]`, depth + 1); 71 return; 72 } 73 74 // plain object 75 const obj = node as Record<string, unknown>; 76 const keys = Object.keys(obj); 77 if (keys.length === 0) { add(path, 'object(empty)'); return; } 78 if (depth >= maxDepth) { add(path, `object(keys=${keys.length})`); return; } 79 if (!add(path, 'object')) return; 80 for (const k of keys) { 81 if (truncated) return; 82 const childPath = isSafeIdent(k) ? `${path}.${k}` : `${path}[${JSON.stringify(k)}]`; 83 walk(obj[k], childPath, depth + 1); 84 } 85 }; 86 87 walk(value, ROOT, 0); 88 return out; 89 } 90 91 function isSafeIdent(key: string): boolean { 92 return /^[A-Za-z_$][\w$]*$/.test(key); 93 }