77from pathlib import Path
88from typing import Any
99
10- from sag .web .models import ActiveBranchSummary , ContextMap , ContextTask , TrunkSummary
10+ from sag .web .models import (
11+ ActiveBranchSummary ,
12+ ContextMap ,
13+ ContextReference ,
14+ ContextTask ,
15+ TrunkSummary ,
16+ )
1117
1218
1319class ContextMapBuilder :
1420 def __init__ (self , contexts_dir : Path ):
1521 self .contexts_dir = contexts_dir
22+ self ._output_records : dict [str , dict [str , Any ]] | None = None
1623
1724 def build (self ) -> ContextMap | None :
1825 trunk_path = self ._find_trunk ()
@@ -88,10 +95,12 @@ def _task(self, item: dict[str, Any], index: int) -> ContextTask:
8895 ),
8996 status = str (item .get ("status" ) or "pending" ),
9097 summary = str (item .get ("summary" ) or self ._branch_summary (branch_data ) or "" ),
91- refs = [
92- * [str (ref ) for ref in item .get ("refs" , [])],
93- * self ._branch_refs (branch_data ),
94- ],
98+ refs = self ._dedupe_refs (
99+ [
100+ * [self ._context_ref (ref ) for ref in item .get ("refs" , [])],
101+ * self ._branch_refs (branch_data ),
102+ ]
103+ ),
95104 recovered = bool (item .get ("recovered" , False )),
96105 )
97106
@@ -112,9 +121,10 @@ def _branch_summary(self, data: dict[str, Any]) -> str:
112121 if action is not None :
113122 tool_name = str (action .get ("tool_name" ) or "action" )
114123 outcome = "succeeded" if action .get ("success" ) is True else "failed"
115- output = self ._compact_text (str (action .get ("output" ) or "" ))
124+ output = self ._full_output_for_history_item (action ) or str (action .get ("output" ) or "" )
125+ output = self ._compact_text (output )
116126 parts .append (
117- f"{ tool_name } { outcome } : { output } " if output else f"{ tool_name } { outcome } ."
127+ f"{ tool_name } { outcome } :\n { output } " if output else f"{ tool_name } { outcome } ."
118128 )
119129
120130 if parts :
@@ -156,25 +166,105 @@ def _latest_history_entry(
156166 None ,
157167 )
158168
159- def _branch_refs (self , data : dict [str , Any ]) -> list [str ]:
169+ def _branch_refs (self , data : dict [str , Any ]) -> list [ContextReference ]:
160170 history = data .get ("history" )
161171 if not isinstance (history , list ):
162172 return []
163173
164- refs : list [str ] = []
174+ refs : list [ContextReference ] = []
165175 for item in history :
166176 if not isinstance (item , dict ):
167177 continue
168- for match in re .findall (
169- r"Full output ref:\s*([A-Za-z0-9_-]+)" , str (item .get ("output" ) or "" )
170- ):
171- if match not in refs :
172- refs .append (match )
178+ refs .extend (
179+ self ._context_ref (match )
180+ for match in self ._output_refs_from_text (str (item .get ("output" ) or "" ))
181+ )
173182 return refs
174183
175184 def _compact_text (self , value : str ) -> str :
176- text = " " .join (value .split ())
177- return text
185+ return "\n " .join (" " .join (line .split ()) for line in value .splitlines ()).strip ()
186+
187+ def _output_refs_from_text (self , value : str ) -> list [str ]:
188+ return re .findall (r"Full output ref:\s*([A-Za-z0-9_-]+)" , value )
189+
190+ def _full_output_for_history_item (self , item : dict [str , Any ]) -> str | None :
191+ for ref in self ._output_refs_from_text (str (item .get ("output" ) or "" )):
192+ record = self ._output_record (ref )
193+ content = record .get ("output" )
194+ if isinstance (content , str ) and content :
195+ return content
196+ return None
197+
198+ def _context_ref (self , value : Any ) -> ContextReference :
199+ if isinstance (value , dict ):
200+ ref = str (value .get ("ref" ) or value .get ("id" ) or value .get ("path" ) or "" )
201+ label = str (value .get ("label" ) or ref )
202+ return ContextReference (
203+ ref = ref ,
204+ label = label ,
205+ kind = str (value .get ("kind" ) or "reference" ),
206+ tool = str (value .get ("tool" )) if value .get ("tool" ) is not None else None ,
207+ task_id = str (value .get ("task_id" ) or value .get ("taskId" ))
208+ if value .get ("task_id" ) or value .get ("taskId" )
209+ else None ,
210+ timestamp = str (value .get ("timestamp" )) if value .get ("timestamp" ) is not None else None ,
211+ content = str (value .get ("content" )) if value .get ("content" ) is not None else None ,
212+ content_length = self ._int_or_none (value .get ("content_length" ) or value .get ("contentLength" )),
213+ )
214+
215+ ref = str (value )
216+ record = self ._output_record (ref )
217+ content = record .get ("output" ) if record else None
218+ return ContextReference (
219+ ref = ref ,
220+ label = ref ,
221+ kind = "output" if ref .startswith ("output_" ) else "reference" ,
222+ tool = str (record .get ("tool_name" )) if record .get ("tool_name" ) is not None else None ,
223+ task_id = str (record .get ("task_id" )) if record .get ("task_id" ) is not None else None ,
224+ timestamp = str (record .get ("timestamp" )) if record .get ("timestamp" ) is not None else None ,
225+ content = content if isinstance (content , str ) else None ,
226+ content_length = self ._int_or_none (record .get ("output_length" ))
227+ or (len (content ) if isinstance (content , str ) else None ),
228+ )
229+
230+ def _dedupe_refs (self , refs : list [ContextReference ]) -> list [ContextReference ]:
231+ deduped : list [ContextReference ] = []
232+ seen : set [str ] = set ()
233+ for ref in refs :
234+ key = ref .ref
235+ if not key or key in seen :
236+ continue
237+ seen .add (key )
238+ deduped .append (ref )
239+ return deduped
240+
241+ def _output_record (self , ref : str ) -> dict [str , Any ]:
242+ return self ._output_records_by_ref ().get (ref , {})
243+
244+ def _output_records_by_ref (self ) -> dict [str , dict [str , Any ]]:
245+ if self ._output_records is not None :
246+ return self ._output_records
247+
248+ records : dict [str , dict [str , Any ]] = {}
249+ path = self .contexts_dir / "full_outputs.jsonl"
250+ try :
251+ for line in path .read_text (encoding = "utf-8" ).splitlines ():
252+ if not line .strip ():
253+ continue
254+ record = json .loads (line )
255+ if isinstance (record , dict ) and record .get ("ref_id" ):
256+ records [str (record ["ref_id" ])] = record
257+ except (OSError , json .JSONDecodeError ):
258+ pass
259+
260+ self ._output_records = records
261+ return records
262+
263+ def _int_or_none (self , value : Any ) -> int | None :
264+ try :
265+ return int (value )
266+ except (TypeError , ValueError ):
267+ return None
178268
179269 def _is_active_status (self , status : str ) -> bool :
180270 return status .strip ().lower () in {"active" , "running" , "in_progress" }
0 commit comments