@@ -10,6 +10,16 @@ import {
1010} from '../queries/tree' ;
1111import { formatCount , formatMs , formatTable } from './format' ;
1212import { projectLogMessage } from './logMessage' ;
13+ import {
14+ MCP_RETURN_VALUE_BUDGET ,
15+ truncate ,
16+ truncateStructValue ,
17+ } from '../../../lib/truncateStructValue' ;
18+ import type { StructBudget } from '../../../lib/truncateStructValue' ;
19+
20+ // Re-exported so existing importers (e.g. treeRender.spec.ts) keep working.
21+ export { MCP_RETURN_VALUE_BUDGET , truncateStructValue } ;
22+ export type { StructBudget } ;
1323
1424const INDENT = ' ' ;
1525
@@ -76,10 +86,6 @@ function bracket(ms: number | null): string {
7686 return ms == null ? '' : `[${ formatMs ( ms ) } ]` ;
7787}
7888
79- function truncate ( s : string , n : number ) : string {
80- return s . length <= n ? s : s . slice ( 0 , n - 1 ) + '…' ;
81- }
82-
8389// Dense one-line-per-event rendering for MCP responses. Each line starts
8490// with the event_id (so the agent can drill via find_calls/find_queries)
8591// and includes file:line for source-reading without a follow-up call.
@@ -110,127 +116,6 @@ function renderTreeLineForMcp(node: TreeNode): string {
110116 }
111117}
112118
113- // Return values in the dense MCP tree are truncated value-aware, not by a
114- // flat prefix cut. A flat cut wastes the budget on identity fields — a
115- // record like `ApprovalRequest[requestId=<uuid>, loanId=<uuid>,
116- // status=APPROVED, …]` spends ~80 chars on two UUIDs before reaching the
117- // state field that actually distinguishes the call. truncateStructValue
118- // parses the struct shape and budgets *per field value*, clipping
119- // id/hash-shaped values hard so semantic fields downstream survive.
120- export interface StructBudget {
121- perValueCap : number ; // max chars for an ordinary field value
122- idCap : number ; // max chars for a uuid/hash-shaped value (identity, low signal)
123- maxFields : number ; // fields rendered before the tail is elided
124- flatCap : number ; // flat cap applied to non-struct strings
125- }
126-
127- export const MCP_RETURN_VALUE_BUDGET : StructBudget = {
128- perValueCap : 48 ,
129- idCap : 12 ,
130- maxFields : 16 ,
131- flatCap : 120 ,
132- } ;
133-
134- // A uuid, a hex object hash (`@1a2b3c4`, `0x00007f…`), or either prefixed
135- // by a short tag (`LOAN-<uuid>`, `PID-<uuid>`). These are identity, not
136- // state — low root-cause signal — so they get the tight idCap.
137- const ID_VALUE_RE =
138- / (?: [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } | (?: @ | 0 x ) [ 0 - 9 a - f ] { 4 , } ) / i;
139-
140- const CLOSE_FOR : Record < string , string > = { '[' : ']' , '{' : '}' , '(' : ')' } ;
141-
142- interface Struct {
143- prefix : string ; // leading type name (may be empty for a bare collection)
144- open : string ; // opening delimiter
145- body : string ; // comma-separated field list
146- close : string ; // closing delimiter
147- }
148-
149- // Recognize the toString()/repr shapes AppMap captures across the four
150- // supported languages:
151- // Java record / Python repr Type[…] / Type(…)
152- // Java bean / JS-ish object Type{…} / {…}
153- // Ruby #<Type …>
154- // Returns null for primitives, `[object Object]`-style opaque values, and
155- // `Type@1a2b3c` hashes — those fall back to a flat truncation.
156- function parseStruct ( s : string ) : Struct | null {
157- const ruby = / ^ # < ( [ ^ \s > ] + ) \s + ( .+ ) > $ / . exec ( s ) ;
158- if ( ruby ) return { prefix : `#<${ ruby [ 1 ] } ` , open : '' , body : ruby [ 2 ] , close : '>' } ;
159- const m = / ^ ( [ A - Z a - z _ ] [ \w . $ ] * ) ? ( [ [ { ( ] ) ( [ \s \S ] * ) ( [ \] } ) ] ) $ / . exec ( s ) ;
160- if ( m && CLOSE_FOR [ m [ 2 ] ] === m [ 4 ] ) {
161- return { prefix : m [ 1 ] ?? '' , open : m [ 2 ] , body : m [ 3 ] , close : m [ 4 ] } ;
162- }
163- return null ;
164- }
165-
166- // Split a struct body on top-level ", ". Commas nested inside [], {}, (),
167- // or <> belong to a field's own value and must not split it.
168- function splitTopLevelFields ( body : string ) : string [ ] {
169- if ( body . length === 0 ) return [ ] ;
170- const out : string [ ] = [ ] ;
171- let depth = 0 ;
172- let start = 0 ;
173- for ( let i = 0 ; i < body . length ; i ++ ) {
174- const c = body [ i ] ;
175- if ( c === '[' || c === '{' || c === '(' || c === '<' ) depth ++ ;
176- else if ( c === ']' || c === '}' || c === ')' || c === '>' ) depth = Math . max ( 0 , depth - 1 ) ;
177- else if ( c === ',' && depth === 0 && body [ i + 1 ] === ' ' ) {
178- out . push ( body . slice ( start , i ) ) ;
179- start = i + 2 ;
180- i ++ ;
181- }
182- }
183- out . push ( body . slice ( start ) ) ;
184- return out ;
185- }
186-
187- // A field value that is itself a struct (a nested record, or an element
188- // of a collection-of-records like `getParticipants`) recurses so its own
189- // fields get the per-field budget too — flat-clipping it would collapse
190- // `[ParticipantState[…status=PENDING], …]` down to an opaque stub. Depth
191- // is bounded so a pathologically deep value can't recurse without end.
192- const MAX_STRUCT_DEPTH = 4 ;
193-
194- // Truncate one field value: recurse if it is itself a struct, else
195- // id/hash-shaped values get the tight idCap and all others the perValueCap.
196- function truncateFieldValue ( value : string , budget : StructBudget , depth : number ) : string {
197- if ( depth < MAX_STRUCT_DEPTH ) {
198- const nested = parseStruct ( value ) ;
199- if ( nested ) return renderStruct ( nested , budget , depth + 1 ) ;
200- }
201- const cap = ID_VALUE_RE . test ( value ) ? budget . idCap : budget . perValueCap ;
202- return truncate ( value , cap ) ;
203- }
204-
205- // Truncate one `name=value` / `name: value` field, keeping the name whole
206- // and budgeting only the value. A field with no recognizable name
207- // separator (a bare collection element) is budgeted as a whole value.
208- function truncateField ( field : string , budget : StructBudget , depth : number ) : string {
209- const m = / ^ ( \s * [ \w $ ] + \s * ) ( [: = ] ) ( \s * ) ( [ \s \S ] * ) $ / . exec ( field ) ;
210- if ( ! m ) return truncateFieldValue ( field , budget , depth ) ;
211- return `${ m [ 1 ] } ${ m [ 2 ] } ${ m [ 3 ] } ${ truncateFieldValue ( m [ 4 ] , budget , depth ) } ` ;
212- }
213-
214- function renderStruct ( st : Struct , budget : StructBudget , depth : number ) : string {
215- const fields = splitTopLevelFields ( st . body ) ;
216- const kept = fields . slice ( 0 , budget . maxFields ) . map ( ( f ) => truncateField ( f , budget , depth ) ) ;
217- const elided = fields . length - kept . length ;
218- const tail = elided > 0 ? `${ kept . length > 0 ? ', ' : '' } …+${ elided } more` : '' ;
219- return `${ st . prefix } ${ st . open } ${ kept . join ( ', ' ) } ${ tail } ${ st . close } ` ;
220- }
221-
222- // Value-aware truncation for a captured return value / parameter. Parses
223- // the struct shape and budgets per field value, recursing into nested
224- // structs; non-struct strings fall back to a flat cap. See StructBudget.
225- export function truncateStructValue (
226- s : string ,
227- budget : StructBudget = MCP_RETURN_VALUE_BUDGET
228- ) : string {
229- const st = parseStruct ( s ) ;
230- if ( ! st ) return truncate ( s , budget . flatCap ) ;
231- return renderStruct ( st , budget , 1 ) ;
232- }
233-
234119function renderFunctionForMcp ( n : FunctionNode ) : string {
235120 const id = n . fqid ?? `${ n . defined_class } ${ n . is_static ? '.' : '#' } ${ n . method_id } ` ;
236121 const where =
0 commit comments