6Session persistence layer.
10Read and write session metadata, chat logs, exports, and tool
11artifacts to disk; preserve prior versions in an ``archive/``
12subdirectory so the user never silently loses history.
14Storage layout (per-session directory, with timestamped filenames)::
17 archive/{name}/ # archived (pre-overwrite) snapshots
18 {name}_{YYYY-MM-DD--HH-MM-SS}_meta.json
19 {name}_{YYYY-MM-DD--HH-MM-SS}_chat_log.json
20 legacy/ # pre-session-system orphan logs
22 {session_name}/ # one directory per live session
23 {name}_{YYYY-MM-DD--HH-MM-SS}_meta.json # exactly one current
24 {name}_{YYYY-MM-DD--HH-MM-SS}_chat_log.json # exactly one current
25 {name}_{YYYY-MM-DD--HH-MM-SS}_export.md # only if /session export run
27Every save first archives the existing same-kind file (preserving its
28original timestamped filename) before writing the new one with a
29fresh timestamp. Identical-content moves are deduplicated at the
30archive level to avoid pointless growth.
32Session names are filesystem-safe by construction: :func:`fs_safe_name`
33maps a name to a single legal path component at the persistence
34boundary, so ``{session_name}`` above is always exactly one directory
35level deep and always a legal filename prefix (plan 225 / LANES-04).
39- :func:`fs_safe_name` — the one name → path-component mapping, applied
40 wherever a session name reaches the filesystem.
41- :func:`save_session_metadata`, :func:`save_chat_log` — write the
42 two canonical session files with archive-on-overwrite semantics.
43- :func:`detect_incomplete_tail` — identify crash-shaped incomplete
44 message tails before a loaded session is resumed.
45- :func:`save_tool_artifact` — persist large tool outputs out-of-band
46 so the API context can keep a truncated stub.
47- :func:`list_sessions` — enumerate live session directories with
48 unreadable files logged at L2 and skipped (not raised).
49- :func:`migrate_legacy_layout` — one-shot migration from the pre-
50 session-system layout where chat logs lived at the package root.
56 L2 — skip-unreadable warnings; archive transitions
62- All public save_* functions are atomic w.r.t. partial writes (write
63 to a temp path then ``os.replace``).
64- Hashes are computed with ``hashlib.sha256`` over canonical JSON to
65 detect identical-content archive collisions.
66- Functions never raise on a single unreadable session — they log and
67 skip so ``/session list`` keeps working.
68- A stored record satisfies ``metadata["session_name"] == <directory
69 name> == <stamped filename prefix>``; the save boundary enforces it
70 via :func:`fs_safe_name` and normalises the live session to match.
71- :func:`fs_safe_name` is idempotent and is the identity on any name
72 that is already a legal path component, so it is a no-op for every
73 session saved before plan 225.
77- ``OC_CHAT_SESSIONS_DIR`` — override :data:`DEFAULT_SESSIONS_DIR` at
78 import time (default: ``"oc_chat/sessions"``). Lets users relocate
79 the sessions store outside the repo so plain filesystem archive
80 copies of the working tree don't drag the ~500MB session history
81 along. An explicit ``sessions_dir=`` argument to any persistence
82 function still wins over the env-var default.
86- oc_diagnostics (structured debug logging via _dbg)
87- oc_chat.core.session (ChatSession dataclass and TokenStats)
88- oc_chat.core.session_models (SessionMetadata and ChatLog validation of loaded sidecars)
89- pydantic (ValidationError when a loaded sidecar fails its model)
90- rich (lazy; Console, Markdown, Panel, Rule and Text for the styled HTML export)
94oc_chat/docs/conventions/CONTRACTS.md §7 Session message dict shape — owner of the
95JSON sidecar shapes for meta.json and chat_log.json (see also gap
96analysis §9 for the SessionMetadata / ChatLog Pydantic candidates).
99from __future__
import annotations
107from dataclasses
import dataclass
108from datetime
import datetime
109from pathlib
import Path
110from typing
import TYPE_CHECKING, Literal
111from uuid
import uuid4
113from oc_diagnostics
import _dbg
114from pydantic
import ValidationError
122DEFAULT_SESSIONS_DIR = os.environ.get(
"OC_CHAT_SESSIONS_DIR",
"oc_chat/sessions")
123ARCHIVE_DIR_NAME =
"archive"
124LEGACY_DIR_NAME =
"legacy"
125CLEAN_EXIT_MARKER_NAME =
".oc_chat_clean_exit"
131FS_SAFE_PLACEHOLDER =
"unnamed-session"
137_FS_SEPARATORS = (
"/",
"\\")
140_FS_RESERVED_CHARS =
':*?"<>|'
144_FS_RESERVED_DEVICE_NAMES = frozenset(
145 {
"con",
"prn",
"aux",
"nul"}
146 | {f
"com{i}" for i
in range(1, 10)}
147 | {f
"lpt{i}" for i
in range(1, 10)}
152 """Map *name* to a string that is always usable as ONE path component.
154 A session name reaches the filesystem three times — as the session
155 directory, as the archive directory, and as the prefix of every stamped
156 filename. Before plan 225 the name was passed through verbatim, so a
157 vendor-prefixed model id used as a session name (``upstage/solar-pro4``)
158 silently created TWO directory levels for the directory while embedding the
159 same separator in the FILENAME, producing a path one uncreated directory
160 deeper and an ``[Errno 2]`` on every auto-save (LANES-04).
164 - ``/`` and ``\\`` → ``--`` (see :data:`_FS_SEPARATORS`);
165 - :data:`_FS_RESERVED_CHARS` and ASCII control characters → ``_``;
166 - leading/trailing whitespace and trailing dots removed (Windows cannot
167 create a component ending in either, and this is what collapses ``.``
168 and ``..`` — a name that would otherwise escape the sessions root);
169 - a Windows reserved device name gains a ``_`` on its stem (``con.log`` →
170 ``con_.log``), appended to the STEM rather than the whole string so the
171 result is not itself reserved;
172 - an empty result degrades to :data:`FS_SAFE_PLACEHOLDER`.
174 The mapping is deterministic and idempotent — ``fs_safe_name`` of its own
175 output is that output — which is what lets it be applied at every boundary
176 without a "has this already been sanitised?" flag. It is the IDENTITY on
177 every name that is already a legal path component, so it is a strict no-op
178 for the existing saved sessions (proven by the plan-225 W3 witness).
181 for separator
in _FS_SEPARATORS:
182 text = text.replace(separator,
"--")
184 "_" if (ch
in _FS_RESERVED_CHARS
or ord(ch) < 32)
else ch
190 text = text.strip().rstrip(
". ")
192 return FS_SAFE_PLACEHOLDER
193 stem, dot, rest = text.partition(
".")
194 if stem.lower()
in _FS_RESERVED_DEVICE_NAMES:
195 text = f
"{stem}_{dot}{rest}"
202 path.mkdir(parents=
True, exist_ok=
True)
211 """Where *name*'s session directory lives — WITHOUT creating anything.
213 Split out from :func:`_session_dir` because the read paths (listing,
214 loading, existence checks) must never bring a directory into being as a
215 side effect of asking about it.
221 """Candidate directories for *name*, most-faithful first.
223 The VERBATIM directory is tried before the sanitised one so a record that
224 predates plan 225 always wins over a name that merely maps onto it. On
225 Windows the two are identical for every legacy name (the characters
226 :func:`fs_safe_name` maps cannot occur in a Windows path component), so
227 lookup is unchanged there; on POSIX, where ``:`` is a legal filename
228 character, the verbatim candidate keeps such a record reachable.
230 verbatim = Path(base) / name
232 if verbatim == sanitised:
234 return [verbatim, sanitised]
250 """Filename-safe timestamp: YYYY-MM-DD--HH-MM-SS."""
251 return datetime.now().strftime(
"%Y-%m-%d--%H-%M-%S")
255 """Compact timestamp used inside JSON metadata fields: YYYYMMDD_HHMMSS."""
256 return datetime.now().strftime(
"%Y%m%d_%H%M%S")
260 """Filename-safe timestamp derived from a file's modification time."""
261 return datetime.fromtimestamp(p.stat().st_mtime).strftime(
"%Y-%m-%d--%H-%M-%S")
264def _stamped_name(session_name: str, kind: str, ext: str =
"json", stamp: str |
None =
None) -> str:
265 """Build ``{name}_{stamp}_{kind}.{ext}`` for storage in the session dir.
267 The name goes through :func:`fs_safe_name` for the same reason the
268 directory does: this string IS a path component, and an embedded separator
269 here pointed the write at a directory nobody had created (LANES-04).
271 return f
"{fs_safe_name(session_name)}_{stamp or _now_stamp()}_{kind}.{ext}"
276 with open(path,
"rb")
as f:
277 for chunk
in iter(
lambda: f.read(65536), b
""):
283 if data.startswith(b
"\xef\xbb\xbf"):
289 """Read a JSON file tolerating an optional UTF-8 BOM."""
290 raw = path.read_bytes()
291 return json.loads(
_strip_bom(raw).decode(
"utf-8"))
294def _glob_latest(session_dir: Path, kind: str, ext: str =
"json") -> Path:
295 """Return the most-recently-modified ``*_{kind}.{ext}`` file in *session_dir*, or raise."""
297 session_dir.glob(f
"*_{kind}.{ext}"),
298 key=
lambda p: p.stat().st_mtime,
301 raise FileNotFoundError(
302 f
"No {kind} file found in {session_dir}"
310 sessions_dir: str = DEFAULT_SESSIONS_DIR) ->
None:
311 """Move every existing ``*_{kind}.*`` in *session_dir* to ``archive/{name}/``.
313 Preserves the original timestamped filename (so meta/chat_log pairs
314 can be matched by the timestamp in the name). If a byte-identical
315 copy already exists in the archive, the source file is just deleted
316 rather than moved, to avoid duplicate snapshots.
318 existing = list(session_dir.glob(f
"*_{kind}.*"))
323 target = archive / old.name
330 final_target = target
332 while final_target.exists():
333 final_target = archive / f
"{old.stem}__{counter}{old.suffix}"
335 shutil.move(str(old), str(final_target))
341 """Return *session*'s filesystem-safe name, normalising the live object.
343 Every record in the store satisfies ``metadata["session_name"] ==
344 <directory name> == <stamped filename prefix>``; that invariant is what
345 lets :func:`list_sessions` hand a name straight back to the loader and to
346 :func:`session_exists`. A name the filesystem cannot hold breaks the
347 invariant at the moment of writing, so the sanitised form is what gets
348 persisted — and the LIVE object is normalised to match, so the REPL header,
349 ``/session list`` highlighting, and a later rename all name the same record
352 Mutating the session here is consistent with what these save functions
353 already do (``filepath``, ``chat_logs``, ``dirty``/``mark_clean``), and is a
354 no-op for every name that is already a legal path component.
356 func =
"_canonical_session_name"
357 raw = getattr(session,
"name",
"")
or ""
361 _dbg(
"OC_CHAT.CTX", func,
362 f
"session name {raw!r} is not a legal path component; "
363 f
"normalised to {safe!r}", 2)
368 """Save session metadata. Returns the absolute path written."""
379 "session_name": name,
381 "session_id": getattr(session,
"session_id",
""),
384 "model": session.model,
385 "reasoning_effort": session.reasoning_effort,
386 "system_prompt": session.system_prompt,
387 "stats": session.stats.to_dict(),
389 "chat_logs": list(session.chat_logs),
392 "ui": dict(getattr(session,
"ui_prefs", {})
or {}),
393 "last_turn_max_prompt_tokens": int(getattr(session,
"last_turn_max_prompt_tokens", 0)
or 0),
394 "rollover_hint_shown": bool(getattr(session,
"rollover_hint_shown",
False)),
395 "token_profile": str(getattr(session,
"token_profile",
"balanced")
or "balanced"),
396 "max_react_iterations": getattr(session,
"max_react_iterations",
None),
397 "cache_turn_snapshots": list(getattr(session,
"cache_turn_snapshots", [])
or [])[-30:],
398 "active_plan_id": getattr(session,
"active_plan_id",
None),
399 "generation_options": dict(getattr(session,
"generation_options",
None)
or {}),
400 "endpoint_name": str(getattr(session,
"endpoint_name",
"")
or ""),
401 "sandbox_root_override": getattr(session,
"sandbox_root_override",
None),
404 "legacy_event_slugs": list(getattr(session,
"legacy_event_slugs",
None)
or []),
405 "superseded_by": getattr(session,
"superseded_by",
None),
408 with open(path,
"w", encoding=
"utf-8")
as f:
409 json.dump(metadata, f, indent=2, ensure_ascii=
False)
411 session.filepath = str(path)
417 sessions_dir: str = DEFAULT_SESSIONS_DIR) -> Path:
418 """Resolve a name-or-path argument to a concrete ``*_{kind}.*`` file.
420 The two loaders accept a UNION — an absolute path, a ``*.json`` path, a
421 relative path, or a bare session NAME — and this is where that union is
422 decided, in one place, for both.
426 1. absolute, or ending in ``.json`` — honoured verbatim (unchanged);
427 2. separator-bearing — tried as a filesystem path, then relative to the
429 3. otherwise, and as the LAST resort for 2, treated as a session NAME and
430 resolved through :func:`_name_lookup_dirs`.
432 Step 3 is the plan-225 addition, and it is strictly additive: it runs only
433 where the pre-225 code had already run out of candidates and was about to
434 raise :class:`FileNotFoundError`. No argument that used to resolve
435 successfully resolves differently — which is what makes the existing saved
436 sessions provably unaffected (the W3 witness). It matters because a
437 separator-bearing argument is BOTH a plausible relative path AND, since the
438 persistence boundary started mapping names, a plausible session name.
440 Raises :class:`FileNotFoundError` when no candidate holds a *kind* file.
442 arg = name_or_path.strip()
444 if p.is_absolute()
or arg.endswith(
".json"):
446 if "/" in arg
or "\\" in arg:
449 direct = Path(sessions_dir) / arg
455 except FileNotFoundError:
457 raise FileNotFoundError(
458 f
"No {kind} file found for session {arg!r} under {sessions_dir}"
462@dataclass(frozen=True)
464 """A loaded metadata dict PLUS the canonical record it came from (plan 152 R11-10).
466 The duplicate-``session_id`` rule needs to know WHICH record presented an id,
467 and the old return shape made that unimplementable: it returned a bare dict
468 and DISCARDED the resolved path.
470 ``canonical_record_locator`` is the session DIRECTORY under the store root,
471 never a timestamped sidecar filename — meta files are stamped and archived on
472 every save, so a filename would make a same-record re-save look like a
473 duplicate. Comparison is on ``resolve()``d real paths, case-folded, which
474 makes it correct on Windows and immune to symlink spoofing.
478 canonical_record_locator: str
481 """Allow ``meta, locator = load_session_metadata_located(...)`` unpacking."""
483 yield self.canonical_record_locator
487 """Normalise *path*'s owning session DIRECTORY into a comparable locator.
489 Case-folded and fully resolved so two spellings of the same directory — and a
490 symlinked alias — compare equal, while genuinely different records do not.
493 directory = path
if path.is_dir()
else path.parent
494 return str(directory.resolve(strict=
False)).casefold()
496 return str(path).casefold()
500 name_or_path: str, sessions_dir: str = DEFAULT_SESSIONS_DIR
501) -> LoadedSessionMetadata:
502 """Load session metadata AND report which record it came from.
504 Same semantics as :func:`load_session_metadata` (which delegates here and
505 drops the locator for its existing callers), plus the
506 ``canonical_record_locator`` the live load/switch boundary needs to detect a
507 duplicate ``session_id`` presented by a NON-canonical record.
509 func =
"load_session_metadata_located"
515 metadata=SessionMetadata.model_validate(raw).model_dump(),
516 canonical_record_locator=locator,
518 except ValidationError
as exc:
522 f
"SessionMetadata validation failed for {path}: {exc.error_count()} errors; returning raw dict",
530 old_name: str, new_name: str, sessions_dir: str = DEFAULT_SESSIONS_DIR
532 """Write the additive ``superseded_by`` mark into *old_name*'s record.
534 A rename writes both files under the NEW name while the OLD record stays on
535 disk carrying the SAME ``session_id`` — store directories key the RAW name
536 and nothing ever deletes the old one. Without a mark, which of the two
537 same-id records is canonical would be decided by LOAD ORDER, and the current
538 record could lose its identity (and its whole event history) to its own
541 Marking makes the doppelganger non-canonical BY CONSTRUCTION. The mark is
542 purely additive — the house never-delete rule stands, the old record remains
545 Returns ``True`` when the mark landed. Best-effort by design: a failure
546 degrades to the deterministic newest-meta-timestamp rule, which resolves to
547 the same record, so it is never fatal to the rename.
549 func =
"mark_record_superseded"
553 except FileNotFoundError:
561 with open(path,
"w", encoding=
"utf-8")
as fh:
562 json.dump(raw, fh, indent=2, ensure_ascii=
False)
563 _dbg(
"OC_CHAT.CTX", func, f
"marked {old_name!r} superseded_by {new_name!r}", 2)
565 except Exception
as exc:
566 _dbg(
"OC_CHAT.CTX", func,
567 f
"EXTERNAL_ERROR: could not mark {old_name!r} superseded: "
568 f
"{type(exc).__name__}: {exc}; falling back to newest-timestamp adjudication",
574 """Load a session metadata file.
576 Accepts a session name (resolved to ``{base}/{name}/*_meta.json``) or
577 a direct path. Returns the raw dict, validated against
578 :class:`~oc_chat.core.session_models.SessionMetadata`. On
579 :class:`pydantic.ValidationError` (e.g. a hard type mismatch from
580 an external edit) the raw on-disk dict is returned unchanged and
581 the failure is logged — no exception escapes this boundary.
583 Callers that need to know WHICH record the metadata came from (the live
584 load/switch duplicate-id check) should use
585 :func:`load_session_metadata_located` instead.
592def save_chat_log(session: ChatSession, sessions_dir: str = DEFAULT_SESSIONS_DIR) -> str:
593 """Save the current conversation as a JSON chat log. Returns the path written.
595 If ``session.load_failed`` is True and the messages list looks empty
596 (only a system prompt or one user message), the save is **refused**
597 to avoid overwriting a chat log we previously failed to read.
602 if getattr(session,
"load_failed",
False)
and len(session.messages) <= 2:
604 f
"Refusing to save chat log for '{name}': previous load failed and "
605 f
"the message buffer is empty/near-empty. The original chat log on "
606 f
"disk is being preserved."
616 "session_name": name,
618 "model": session.model,
619 "reasoning_effort": session.reasoning_effort,
620 "messages": session.messages,
622 "prompt_tokens": session.stats.total_prompt_tokens,
623 "completion_tokens": session.stats.total_completion_tokens,
624 "total_tokens": session.stats.total_tokens,
625 "prompt_cache_hit_tokens": session.stats.prompt_cache_hit_tokens,
626 "prompt_cache_miss_tokens": session.stats.prompt_cache_miss_tokens,
630 with open(path,
"w", encoding=
"utf-8")
as f:
631 json.dump(log_data, f, indent=2, ensure_ascii=
False)
634 session.chat_logs = [fname]
635 session.dirty =
False
639def load_chat_log(name_or_path: str, sessions_dir: str = DEFAULT_SESSIONS_DIR) -> dict:
642 Accepts a session name (resolved to the most-recent
643 ``*_chat_log.json`` inside ``{base}/{name}/``) or a direct path.
644 The on-disk dict is validated against
645 :class:`~oc_chat.core.session_models.ChatLog`. On
646 :class:`pydantic.ValidationError` the raw dict is returned
647 unchanged and the failure is logged.
649 func =
"load_chat_log"
653 return ChatLog.model_validate(raw).model_dump()
654 except ValidationError
as exc:
658 f
"ChatLog validation failed for {path}: {exc.error_count()} errors; returning raw dict",
666 """Save both session metadata (lightweight) and chat log (full conversation).
668 If ``session.load_failed`` is set, :func:`save_chat_log` will refuse
669 to overwrite the existing log on disk — we still save metadata so
670 stats are persisted. Caller-friendly wrapper used at REPL exit
671 (and historically by ``main._save_all``); per-turn auto-save
672 inside the ReAct loop calls the two saves directly so it can
673 react differently on a ``RuntimeError`` from ``save_chat_log``.
679 except RuntimeError
as exc:
681 _dbg(
"OC_CHAT.CTX", func, f
"VALIDATION_ERROR: load_failed: {exc}", 3)
682 print(f
"\n[Chat log NOT saved: {exc}]")
685 print(f
"\n[Chat log saved to {log_path}]")
686 print(f
"[Session metadata saved to {meta_path}]")
691def export_markdown(session: ChatSession, filepath: str |
None =
None, sessions_dir: str = DEFAULT_SESSIONS_DIR) -> str:
692 """Export the conversation as a Markdown file. Returns the path written."""
700 filepath = str(sdir / fname)
704 f
"# Chat Export — {name}",
706 f
"**Exported:** {timestamp} ",
707 f
"**Model:** {session.model} ",
708 f
"**Reasoning effort:** {session.reasoning_effort}",
709 f
"**Total tokens:** {session.stats.total_tokens} "
710 f
"(prompt: {session.stats.total_prompt_tokens}, "
711 f
"completion: {session.stats.total_completion_tokens})",
714 for msg
in session.messages:
715 role = msg.get(
"role",
"")
716 content = msg.get(
"content")
or ""
718 lines += [
"---",
"**System**",
"", content,
""]
720 lines += [
"---",
"**You**",
"", content,
""]
721 elif role ==
"assistant" and content:
722 usage = msg.get(
"usage", {})
725 usage_str = f
" *(tokens: {usage.get('total_tokens', '?')})*"
726 lines += [
"---", f
"**Assistant**{usage_str}",
"", content,
""]
727 with open(filepath,
"w", encoding=
"utf-8")
as f:
728 f.write(
"\n".join(lines))
736 sessions_dir: str |
None =
None,
737 code_theme: str =
"monokai") -> str:
738 """Export the conversation as a styled, single-file HTML document.
740 Uses ``rich.console.Console(record=True)`` to render the same
741 Markdown / styled output the user sees in the terminal, then writes
742 it via :meth:`rich.console.Console.save_html` to a self-contained
743 ``.html`` file (inline CSS, no external assets).
745 *filepath* defaults to ``oc_chat/sessions/<name>/<name>_<ts>_export.html``.
746 Raises :class:`ImportError` if ``rich`` is not installed — the
747 caller (``cmd_session``) is responsible for surfacing this as a
748 user-friendly message and pointing at ``pip install -e .[rich]``.
752 from rich.console
import Console
753 from rich.markdown
import Markdown
754 from rich.panel
import Panel
755 from rich.rule
import Rule
756 from rich.text
import Text
758 if sessions_dir
is None:
759 sessions_dir = DEFAULT_SESSIONS_DIR
766 for old_html
in list(sdir.glob(
"*_export.html")):
768 tgt = archive / old_html.name
772 shutil.move(str(old_html), str(tgt))
774 filepath = str(sdir / fname)
779 console = Console(record=
True, width=100, file=open(os.devnull,
"w"))
781 console.print(Rule(title=f
"OC Chat — {name}", style=
"cyan"))
782 console.print(Text.assemble(
783 (
"Model: ",
"bold"), (session.model,
"cyan"),
"\n",
784 (
"Reasoning effort: ",
"bold"),
785 (session.reasoning_effort,
"cyan"),
"\n",
786 (
"Tokens: ",
"bold"),
787 (f
"{session.stats.total_tokens:,}",
"cyan"),
788 (f
" (prompt: {session.stats.total_prompt_tokens:,}, "
789 f
"completion: {session.stats.total_completion_tokens:,})",
"dim"),
793 for msg
in session.messages:
794 role = msg.get(
"role",
"")
795 content = (msg.get(
"content")
or "").strip()
799 console.print(Panel(content, title=
"System", border_style=
"dim",
802 console.print(Panel(content, title=
"You", border_style=
"blue",
804 elif role ==
"assistant":
805 usage = msg.get(
"usage")
or {}
806 tok = usage.get(
"total_tokens")
807 title =
"Assistant" if tok
is None else f
"Assistant — {tok} tok"
810 Markdown(content, code_theme=code_theme),
811 title=title, border_style=
"green", title_align=
"left",
813 except Exception
as e:
815 _dbg(
"OC_CHAT.CTX", func, f
"SYSTEM_ERROR: {type(e).__name__}: {e}", 1, override=
True)
816 console.print(Panel(content, title=title,
817 border_style=
"green", title_align=
"left"))
819 Path(filepath).parent.mkdir(parents=
True, exist_ok=
True)
822 console.save_html(filepath, inline_styles=
True)
830 sessions_dir: str |
None =
None) -> Path:
831 """Spill a long tool-result payload to ``sessions/{name}/artifacts/``.
833 Returns the absolute path written. The filename follows the same
834 ``{name}_{timestamp}_{kind}.{ext}`` convention used elsewhere in the
835 storage layer; ``kind`` is ``tool_<tool_name>`` so artifacts from the
836 same tool are easy to find.
838 *sessions_dir* defaults to the live module-level
839 :data:`DEFAULT_SESSIONS_DIR` so tests can monkeypatch it.
841 Used by the Rich presenter (and in future the Textual presenter) to
842 keep long ``read_file`` / ``run_command`` outputs out of the
843 scrollback. The presenter prints a ``↳ saved (N KB) → /open last``
846 if sessions_dir
is None:
847 sessions_dir = DEFAULT_SESSIONS_DIR
848 sdir =
_session_dir(session_name, sessions_dir) /
"artifacts"
849 sdir.mkdir(parents=
True, exist_ok=
True)
850 safe_tool =
"".join(c
if c.isalnum()
or c
in "-_" else "_" for c
in tool_name)
857 uniq = f
"{time.time_ns()}_{uuid4().hex[:6]}"
858 fname =
_stamped_name(session_name, f
"tool_{safe_tool}_{uniq}", ext=
"txt")
860 path.write_text(text, encoding=
"utf-8")
865 sessions_dir: str |
None =
None) -> Path |
None:
866 """Return the most-recently-written artifact in this session, or None.
868 *sessions_dir* defaults to the live module-level
869 :data:`DEFAULT_SESSIONS_DIR`.
871 if sessions_dir
is None:
872 sessions_dir = DEFAULT_SESSIONS_DIR
873 sdir =
_session_dir(session_name, sessions_dir) /
"artifacts"
874 if not sdir.is_dir():
876 files = list(sdir.glob(
"*.txt"))
879 return max(files, key=
lambda p: p.stat().st_mtime)
885 """Scan ``sessions_dir`` for per-session subdirectories. Returns one entry per session.
887 Each entry: ``name``, ``model``, ``stats`` (TokenStats),
888 ``last_modified`` (display string), ``filepath`` (path of the
889 current ``*_meta.json``), ``chat_logs`` (informational list).
890 Corrupt or unreadable metadata files are skipped with a warning.
892 func =
"list_sessions"
893 base = Path(sessions_dir)
894 if not base.is_dir():
897 results: list[dict] = []
898 for child
in sorted(base.iterdir()):
899 if not child.is_dir():
902 if child.name
in (ARCHIVE_DIR_NAME, LEGACY_DIR_NAME):
906 except FileNotFoundError:
910 except (json.JSONDecodeError, OSError)
as exc:
911 _dbg(
"OC_CHAT.CTX", func, f
"skipping unreadable session file: {meta_path} ({exc})", 2)
914 name = data.get(
"session_name", child.name)
915 model = data.get(
"model",
"?")
916 stats = TokenStats.from_dict(data.get(
"stats", {}))
917 last_modified = datetime.fromtimestamp(meta_path.stat().st_mtime).strftime(
924 chat_log_size =
_glob_latest(child,
"chat_log").stat().st_size
925 except FileNotFoundError:
931 "last_modified": last_modified,
932 "filepath": str(meta_path),
933 "chat_logs": data.get(
"chat_logs", []),
934 "reasoning_effort": data.get(
"reasoning_effort",
"high"),
935 "chat_log_size_bytes": chat_log_size,
936 "ui": data.get(
"ui", {})
or {},
942 """Return True if *name*'s session directory holds at least one ``*_meta.json``.
944 Checks the same candidates the loader resolves (:func:`_name_lookup_dirs`),
945 so "exists" and "loadable" cannot disagree — a name the caller is told
946 exists is a name the caller can then load. A bug-era half-created
947 directory (``upstage/solar-pro4/``, holding nothing) does not answer True:
948 it carries no metadata file.
951 if sdir.is_dir()
and any(sdir.glob(
"*_meta.json")):
962 message.get(
"role") ==
"user"
963 and isinstance(message.get(
"content"), str)
964 and message[
"content"].startswith(VARIABLE_CONTEXT_SENTINEL)
971 for index, message
in enumerate(messages)
978 for call
in message.get(
"tool_calls")
or []:
979 if isinstance(call, dict)
and isinstance(call.get(
"id"), str):
980 ids.append(call[
"id"])
985 content = message.get(
"content")
986 return isinstance(content, str)
and bool(content.strip())
989def _tail_report(kind: str, tail_indexes: list[int], summary: str) -> dict:
992 "tail_indexes": tail_indexes,
998 last_position = len(indexes) - 1
999 tail_indexes: list[int] = []
1000 seen_tool_ids: set[str] = set()
1002 while last_position >= 0:
1003 index = indexes[last_position]
1004 message = messages[index]
1005 if message.get(
"role") !=
"tool":
1007 tail_indexes.append(index)
1008 tool_call_id = message.get(
"tool_call_id")
1009 if isinstance(tool_call_id, str):
1010 seen_tool_ids.add(tool_call_id)
1013 if last_position < 0:
1016 assistant_index = indexes[last_position]
1017 assistant = messages[assistant_index]
1018 if assistant.get(
"role") !=
"assistant":
1022 if not required_ids:
1025 missing_ids = required_ids - seen_tool_ids
1029 ordered_tail = [assistant_index, *reversed(tail_indexes)]
1030 missing =
", ".join(sorted(missing_ids))
1032 "orphaned_tool_calls",
1034 f
"Assistant tool call(s) missing tool response(s): {missing}.",
1039 """Return a structured crash-recovery report for an incomplete tail.
1041 The detector is deliberately conservative. It ignores OC Chat's
1042 synthetic variable-context user message and reports only the three
1043 tail shapes that can poison the next model request after a crash.
1049 last_index = indexes[-1]
1050 last_message = messages[last_index]
1051 role = last_message.get(
"role")
1057 "Session ends with a user message that has no assistant response.",
1063 if role !=
"assistant":
1068 missing =
", ".join(sorted(tool_call_ids))
1070 "orphaned_tool_calls",
1072 f
"Assistant tool call(s) missing tool response(s): {missing}.",
1077 "truncated_assistant",
1079 "Session ends with an empty assistant message.",
1086 messages: list[dict],
1087 choice: Literal[
"drop",
"keep",
"fresh"],
1089 """Return messages after applying an incomplete-tail recovery choice."""
1090 if choice ==
"keep":
1091 return list(messages)
1092 if choice ==
"fresh":
1093 if messages
and messages[0].get(
"role") ==
"system":
1094 return [messages[0]]
1096 if choice !=
"drop":
1097 raise ValueError(f
"Unsupported recovery choice: {choice}")
1101 return list(messages)
1102 tail_indexes = set(report[
"tail_indexes"])
1105 for index, message
in enumerate(messages)
1106 if index
not in tail_indexes
1111 """Write the marker used to distinguish graceful exit from crash exit."""
1113 marker_path = base / CLEAN_EXIT_MARKER_NAME
1114 temp_path = marker_path.with_suffix(
".tmp")
1115 payload = {
"timestamp": datetime.now().isoformat(timespec=
"seconds")}
1116 with open(temp_path,
"w", encoding=
"utf-8")
as f:
1117 json.dump(payload, f, indent=2)
1118 os.replace(temp_path, marker_path)
1122 """Return the latest-session crash signal, or None when startup is clean."""
1127 latest = max(sessions, key=
lambda session: os.path.getmtime(session[
"filepath"]))
1128 latest_mtime = Path(latest[
"filepath"]).stat().st_mtime
1129 marker_path = Path(sessions_dir) / CLEAN_EXIT_MARKER_NAME
1131 if marker_path.exists()
and marker_path.stat().st_mtime >= latest_mtime:
1135 "last_session_name": latest[
"name"],
1136 "last_modified": latest[
"last_modified"],
1141 """Convert any flat-layout session files to the per-session-directory layout.
1143 Idempotent: already-migrated sessions are skipped.
1145 Returns a dict summarising the migration: ``migrated``, ``recovered``,
1146 ``orphans_moved``, ``errors``.
1149 summary = {
"migrated": 0,
"recovered": 0,
"orphans_moved": 0,
"errors": []}
1152 flat_metas = sorted(p
for p
in base.glob(
"*.json")
if p.is_file())
1153 for flat_meta
in flat_metas:
1154 name = flat_meta.stem
1157 except Exception
as exc:
1158 summary[
"errors"].append(f
"{flat_meta.name}: {exc}")
1162 sdir.mkdir(parents=
True, exist_ok=
True)
1166 new_meta_name =
_stamped_name(name,
"meta", stamp=meta_stamp)
1167 new_meta_path = sdir / new_meta_name
1168 if not new_meta_path.exists():
1169 with open(new_meta_path,
"w", encoding=
"utf-8")
as f:
1170 json.dump(data, f, indent=2, ensure_ascii=
False)
1173 old_log = base /
"Chat_logs" / f
"{name}_log.json"
1174 if old_log.exists():
1176 new_log_name =
_stamped_name(name,
"chat_log", stamp=log_stamp)
1177 new_log_path = sdir / new_log_name
1178 if not new_log_path.exists():
1181 except Exception
as exc:
1182 summary[
"errors"].append(f
"{old_log.name}: {exc}")
1184 with open(new_log_path,
"w", encoding=
"utf-8")
as f:
1185 json.dump(log_data, f, indent=2, ensure_ascii=
False)
1189 summary[
"migrated"] += 1
1192 old_chat_dir = base /
"Chat_logs"
1193 if old_chat_dir.is_dir():
1195 for orphan
in list(old_chat_dir.iterdir()):
1196 if orphan.name ==
".gitkeep":
1198 if orphan.is_file():
1199 tgt = legacy / orphan.name
1200 if not tgt.exists():
1201 shutil.move(str(orphan), str(tgt))
1202 summary[
"orphans_moved"] += 1
1208 gitkeep = old_chat_dir /
".gitkeep"
1209 if gitkeep.exists()
and not any(p
for p
in old_chat_dir.iterdir()
1210 if p.name !=
".gitkeep"):
1212 if not any(old_chat_dir.iterdir()):
1213 old_chat_dir.rmdir()
dict load_chat_log(str name_or_path, str sessions_dir=DEFAULT_SESSIONS_DIR)
Path _session_dir(str name, str base=DEFAULT_SESSIONS_DIR)
None write_clean_exit_marker(str sessions_dir=DEFAULT_SESSIONS_DIR)
str _stamped_name(str session_name, str kind, str ext="json", str|None stamp=None)
dict|None _tool_tail_report(list[dict] messages, list[int] indexes)
str export_markdown(ChatSession session, str|None filepath=None, str sessions_dir=DEFAULT_SESSIONS_DIR)
dict _tail_report(str kind, list[int] tail_indexes, str summary)
str save_chat_log(ChatSession session, str sessions_dir=DEFAULT_SESSIONS_DIR)
Path _legacy_dir(str base=DEFAULT_SESSIONS_DIR)
list[str] _tool_call_ids(dict message)
None _archive_kind(Path session_dir, str session_name, str kind, str sessions_dir=DEFAULT_SESSIONS_DIR)
Path _glob_latest(Path session_dir, str kind, str ext="json")
bool _message_has_content(dict message)
str save_session_metadata(ChatSession session, str sessions_dir=DEFAULT_SESSIONS_DIR)
Path save_tool_artifact(str session_name, str tool_name, str text, str|None sessions_dir=None)
dict load_session_metadata(str name_or_path, str sessions_dir=DEFAULT_SESSIONS_DIR)
str export_html(ChatSession session, str|None filepath=None, str|None sessions_dir=None, str code_theme="monokai")
list[int] _conversation_indexes(list[dict] messages)
dict|None detect_incomplete_tail(list[dict] messages)
dict migrate_legacy_layout(str sessions_dir=DEFAULT_SESSIONS_DIR)
str fs_safe_name(str name)
dict|None read_crash_signal(str sessions_dir=DEFAULT_SESSIONS_DIR)
list[dict] list_sessions(str sessions_dir=DEFAULT_SESSIONS_DIR)
bool _is_variable_context_message(dict message)
Path _named_session_dir(str name, str base=DEFAULT_SESSIONS_DIR)
LoadedSessionMetadata load_session_metadata_located(str name_or_path, str sessions_dir=DEFAULT_SESSIONS_DIR)
str _canonical_session_name(ChatSession session)
str canonical_record_locator(Path path)
list[Path] _name_lookup_dirs(str name, str base=DEFAULT_SESSIONS_DIR)
str _stamp_from_mtime(Path p)
Path _resolve_record_path(str name_or_path, str kind, str sessions_dir=DEFAULT_SESSIONS_DIR)
dict _read_json_strip_bom(Path path)
Path _sessions_root(str base=DEFAULT_SESSIONS_DIR)
str _hash_file(Path path)
Path _ensure_dir(Path path)
bytes _strip_bom(bytes data)
None save_all(ChatSession session)
Path _archive_dir(str name, str base=DEFAULT_SESSIONS_DIR)
Path|None find_latest_artifact(str session_name, str|None sessions_dir=None)
bool mark_record_superseded(str old_name, str new_name, str sessions_dir=DEFAULT_SESSIONS_DIR)
list[dict] apply_tail_recovery(list[dict] messages, Literal["drop", "keep", "fresh"] choice)
bool session_exists(str name, str sessions_dir=DEFAULT_SESSIONS_DIR)