/ src / browser / shape.ts
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  }