@@ -288,3 +288,100 @@ def _parse_metadata_value(value: str) -> str:
288288 return str (parsed )
289289 except (ValueError , SyntaxError ):
290290 return value .strip ("\" '" )
291+
292+
293+ def parse_saved_body (body : str ) -> list :
294+ """Parse a saved session body back into Message objects.
295+
296+ The save format is markdown with **role**: content blocks
297+ separated by blank lines.
298+
299+ ``tool`` and ``system`` blocks are dropped: the saved markdown
300+ does not keep ``tool_call_id``/``name`` (assistant tool calls are
301+ flattened to plain text), so a restored ``role="tool"`` message
302+ would form an API-invalid payload (a tool message with no
303+ preceding assistant ``tool_calls``). A restored ``system``
304+ message would duplicate the live system prompt the client
305+ prepends on every request. The following assistant reply
306+ already summarizes the results, so dropping them loses no
307+ essential context.
308+
309+ Body lines that merely look like a block header are escaped by
310+ the renderer (see `escape_role_headers`) and unescaped here, so
311+ a message quoting this format no longer splits into extra
312+ messages. Sessions saved before escaping existed can still
313+ split — that ambiguity is in the file, not in this parser.
314+ """
315+ from .attachments import reattach_images
316+ from .models import Message , TextPart
317+
318+ messages : list = []
319+ current_role : str | None = None
320+ current_lines : list [str ] = []
321+
322+ def _flush (role : str , lines : list [str ]) -> None :
323+ content = "\n " .join (lines ).strip ()
324+ if not content :
325+ return
326+ # A leading image-attachment placeholder (written on save)
327+ # is re-attached when the file still exists, so the restored
328+ # message is multimodal again; otherwise it stays as text.
329+ new_content , parts = reattach_images (content )
330+ if parts :
331+ remainder = new_content .split ("\n " , 1 )
332+ rest_text = remainder [1 ].strip () if len (remainder ) > 1 else ""
333+ content_parts : list = []
334+ if rest_text :
335+ content_parts .append (TextPart (text = rest_text ))
336+ content_parts .extend (parts )
337+ messages .append (Message (role = role , content = content_parts ))
338+ else :
339+ messages .append (Message (role = role , content = content ))
340+
341+ for line in body .splitlines ():
342+ # Check for a role header: **user**: ... or **assistant**: ...
343+ header = split_role_header (line )
344+ if header is not None :
345+ role , rest = header
346+ # Save the previous block (tool blocks lose their
347+ # tool_call_id/name; system blocks would duplicate the
348+ # live system prompt the client prepends per request)
349+ if current_role is not None and current_role not in ("tool" , "system" ):
350+ _flush (current_role , current_lines )
351+ current_role = role
352+ current_lines = [unescape_role_header (rest )]
353+ continue
354+ current_lines .append (unescape_role_header (line ))
355+
356+ # Don't forget the last block (tool/system blocks dropped, see above)
357+ if current_role is not None and current_role not in ("tool" , "system" ):
358+ _flush (current_role , current_lines )
359+
360+ return messages
361+
362+
363+ def find_session_by_title (query : str ) -> str | None :
364+ """Find a session file by title substring (case-insensitive).
365+
366+ Matches against the full filename, the filename without .md,
367+ and the derived title. Returns the most recent match, or None.
368+ """
369+ query_lower = query .lower ()
370+ # Strip .md from query if present, for cleaner substring matching
371+ query_stem = query_lower [:- 3 ] if query_lower .endswith (".md" ) else query_lower
372+ files = SessionPersistence .list_sessions () # already sorted by mtime desc
373+ for f in files :
374+ basename = os .path .basename (f )
375+ basename_lower = basename .lower ()
376+ # Exact basename match (with or without .md)
377+ if basename_lower == query_lower or basename_lower == query_lower + ".md" :
378+ return f
379+ # Substring match against filename (minus .md)
380+ name_part = basename [:- 3 ] if basename .endswith (".md" ) else basename
381+ if query_stem in name_part .lower ():
382+ return f
383+ # Match against derived title (dashes → spaces)
384+ title = title_from_filename (f )
385+ if title and query_stem in title .lower ():
386+ return f
387+ return None
0 commit comments