oc_chat
Loading...
Searching...
No Matches
persistence.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/persistence.py
4
5"""
6Session persistence layer.
7
8Purpose
9-------
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.
13
14Storage layout (per-session directory, with timestamped filenames)::
15
16 oc_chat/sessions/
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
21 chat_log_*.json
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
26
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.
31
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).
36
37Responsibilities
38----------------
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.
51
52Diagnostics
53-----------
54Domain: OC_CHAT.CTX
55Levels:
56 L2 — skip-unreadable warnings; archive transitions
57 L3 — semantic details
58 L4 — deep tracing
59
60Contracts
61---------
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.
74
75Environment variables
76---------------------
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.
83
84Dependencies
85------------
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)
91
92See Also
93--------
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).
97"""
98
99from __future__ import annotations
100
101import hashlib
102import json
103import os
104import shutil
105import sys
106import time
107from dataclasses import dataclass
108from datetime import datetime
109from pathlib import Path
110from typing import TYPE_CHECKING, Literal
111from uuid import uuid4
112
113from oc_diagnostics import _dbg
114from pydantic import ValidationError
115
116from oc_chat.core.session import TokenStats, VARIABLE_CONTEXT_SENTINEL
117from oc_chat.core.session_models import ChatLog, SessionMetadata
118
119if TYPE_CHECKING:
120 from oc_chat.core.session import ChatSession
121
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"
126
127# ─── Filesystem-safe session names (plan 225 / LANES-04) ─────────────────────
128
129#: What a name that maps to nothing usable degrades to. Chosen to be
130#: recognisable in a session list rather than cryptic.
131FS_SAFE_PLACEHOLDER = "unnamed-session"
132
133#: Path separators. Mapped to a DOUBLE hyphen rather than a single one so the
134#: vendor boundary of a model id stays readable (``upstage/solar-pro4`` reads as
135#: ``upstage--solar-pro4``) and so the mapping is unlikely to collide with a
136#: hand-typed name that already contains a hyphen.
137_FS_SEPARATORS = ("/", "\\")
138
139#: Characters Windows rejects outright in a path component.
140_FS_RESERVED_CHARS = ':*?"<>|'
141
142#: Windows reserved device names. A directory called ``CON`` cannot be created
143#: at all, which is the same failure class as the embedded separator.
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)}
148)
149
150
151def fs_safe_name(name: str) -> str:
152 """Map *name* to a string that is always usable as ONE path component.
153
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).
161
162 The mapping is:
163
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`.
173
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).
179 """
180 text = name or ""
181 for separator in _FS_SEPARATORS:
182 text = text.replace(separator, "--")
183 text = "".join(
184 "_" if (ch in _FS_RESERVED_CHARS or ord(ch) < 32) else ch
185 for ch in text
186 )
187 # Order matters: strip whitespace both ends first, then trailing dots, so
188 # "name. " and " ..name" both settle in one pass (keeping the function
189 # idempotent — a second call finds nothing left to strip).
190 text = text.strip().rstrip(". ")
191 if not text:
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}"
196 return text
197
198
199# ─── Path / time / hash helpers ──────────────────────────────────────────────
200
201def _ensure_dir(path: Path) -> Path:
202 path.mkdir(parents=True, exist_ok=True)
203 return path
204
205
206def _sessions_root(base: str = DEFAULT_SESSIONS_DIR) -> Path:
207 return _ensure_dir(Path(base))
208
209
210def _named_session_dir(name: str, base: str = DEFAULT_SESSIONS_DIR) -> Path:
211 """Where *name*'s session directory lives — WITHOUT creating anything.
212
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.
216 """
217 return Path(base) / fs_safe_name(name)
218
219
220def _name_lookup_dirs(name: str, base: str = DEFAULT_SESSIONS_DIR) -> list[Path]:
221 """Candidate directories for *name*, most-faithful first.
222
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.
229 """
230 verbatim = Path(base) / name
231 sanitised = _named_session_dir(name, base)
232 if verbatim == sanitised:
233 return [sanitised]
234 return [verbatim, sanitised]
235
236
237def _session_dir(name: str, base: str = DEFAULT_SESSIONS_DIR) -> Path:
238 return _ensure_dir(_named_session_dir(name, base))
239
240
241def _archive_dir(name: str, base: str = DEFAULT_SESSIONS_DIR) -> Path:
242 return _ensure_dir(Path(base) / ARCHIVE_DIR_NAME / fs_safe_name(name))
243
244
245def _legacy_dir(base: str = DEFAULT_SESSIONS_DIR) -> Path:
246 return _ensure_dir(Path(base) / LEGACY_DIR_NAME)
247
248
249def _now_stamp() -> str:
250 """Filename-safe timestamp: YYYY-MM-DD--HH-MM-SS."""
251 return datetime.now().strftime("%Y-%m-%d--%H-%M-%S")
252
253
255 """Compact timestamp used inside JSON metadata fields: YYYYMMDD_HHMMSS."""
256 return datetime.now().strftime("%Y%m%d_%H%M%S")
257
258
259def _stamp_from_mtime(p: Path) -> str:
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")
262
263
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.
266
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).
270 """
271 return f"{fs_safe_name(session_name)}_{stamp or _now_stamp()}_{kind}.{ext}"
272
273
274def _hash_file(path: Path) -> str:
275 h = hashlib.sha256()
276 with open(path, "rb") as f:
277 for chunk in iter(lambda: f.read(65536), b""):
278 h.update(chunk)
279 return h.hexdigest()
280
281
282def _strip_bom(data: bytes) -> bytes:
283 if data.startswith(b"\xef\xbb\xbf"):
284 return data[3:]
285 return data
286
287
288def _read_json_strip_bom(path: Path) -> dict:
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"))
292
293
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."""
296 matches = sorted(
297 session_dir.glob(f"*_{kind}.{ext}"),
298 key=lambda p: p.stat().st_mtime,
299 )
300 if not matches:
301 raise FileNotFoundError(
302 f"No {kind} file found in {session_dir}"
303 )
304 return matches[-1]
305
306
307# ─── Archive-before-overwrite ────────────────────────────────────────────────
308
309def _archive_kind(session_dir: Path, session_name: str, kind: str,
310 sessions_dir: str = DEFAULT_SESSIONS_DIR) -> None:
311 """Move every existing ``*_{kind}.*`` in *session_dir* to ``archive/{name}/``.
312
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.
317 """
318 existing = list(session_dir.glob(f"*_{kind}.*"))
319 if not existing:
320 return
321 archive = _archive_dir(session_name, sessions_dir)
322 for old in existing:
323 target = archive / old.name
324 if target.exists() and _hash_file(target) == _hash_file(old):
325 old.unlink()
326 continue
327 # If a non-identical file already lives at target (rare: same name,
328 # different content — e.g. clock skew), avoid clobber by appending
329 # a counter.
330 final_target = target
331 counter = 1
332 while final_target.exists():
333 final_target = archive / f"{old.stem}__{counter}{old.suffix}"
334 counter += 1
335 shutil.move(str(old), str(final_target))
336
337
338# ─── Session metadata (lightweight) ───────────────────────────────────────────
339
340def _canonical_session_name(session: ChatSession) -> str:
341 """Return *session*'s filesystem-safe name, normalising the live object.
342
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
350 as the one on disk.
351
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.
355 """
356 func = "_canonical_session_name"
357 raw = getattr(session, "name", "") or ""
358 safe = fs_safe_name(raw)
359 if safe != raw:
360 session.name = safe
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)
364 return safe
365
366
367def save_session_metadata(session: ChatSession, sessions_dir: str = DEFAULT_SESSIONS_DIR) -> str:
368 """Save session metadata. Returns the absolute path written."""
369 name = _canonical_session_name(session)
370 sdir = _session_dir(name, sessions_dir)
371
372 # Archive any existing meta file before writing the new one.
373 _archive_kind(sdir, name, "meta", sessions_dir)
374
375 fname = _stamped_name(name, "meta")
376 path = sdir / fname
377
378 metadata = {
379 "session_name": name,
380 # M2.5 — persist the stable per-session identity used for TurnEvent.session_id.
381 "session_id": getattr(session, "session_id", ""),
382 "created": getattr(session, "created", _now_stamp_compact()),
383 "last_modified": _now_stamp_compact(),
384 "model": session.model,
385 "reasoning_effort": session.reasoning_effort,
386 "system_prompt": session.system_prompt,
387 "stats": session.stats.to_dict(),
388 # chat_logs is informational only — actual file is found via glob.
389 "chat_logs": list(session.chat_logs),
390 # UI preferences set via /ui <subcommand>. Phase 0 just stores them;
391 # Phase 1+ presenters honour them (e.g. show_reasoning).
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),
402 # Plan 152 D8 — the two additive rename-continuity keys. Read tolerantly:
403 # an absent key means "no aliases" / "not superseded".
404 "legacy_event_slugs": list(getattr(session, "legacy_event_slugs", None) or []),
405 "superseded_by": getattr(session, "superseded_by", None),
406 }
407
408 with open(path, "w", encoding="utf-8") as f:
409 json.dump(metadata, f, indent=2, ensure_ascii=False)
410
411 session.filepath = str(path)
412 session.mark_clean()
413 return str(path)
414
415
416def _resolve_record_path(name_or_path: str, kind: str,
417 sessions_dir: str = DEFAULT_SESSIONS_DIR) -> Path:
418 """Resolve a name-or-path argument to a concrete ``*_{kind}.*`` file.
419
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.
423
424 Resolution order:
425
426 1. absolute, or ending in ``.json`` — honoured verbatim (unchanged);
427 2. separator-bearing — tried as a filesystem path, then relative to the
428 store (unchanged);
429 3. otherwise, and as the LAST resort for 2, treated as a session NAME and
430 resolved through :func:`_name_lookup_dirs`.
431
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.
439
440 Raises :class:`FileNotFoundError` when no candidate holds a *kind* file.
441 """
442 arg = name_or_path.strip()
443 p = Path(arg)
444 if p.is_absolute() or arg.endswith(".json"):
445 return p
446 if "/" in arg or "\\" in arg:
447 if p.exists():
448 return p
449 direct = Path(sessions_dir) / arg
450 if direct.is_file():
451 return direct
452 for candidate in _name_lookup_dirs(arg, sessions_dir):
453 try:
454 return _glob_latest(candidate, kind)
455 except FileNotFoundError:
456 continue
457 raise FileNotFoundError(
458 f"No {kind} file found for session {arg!r} under {sessions_dir}"
459 )
460
461
462@dataclass(frozen=True)
464 """A loaded metadata dict PLUS the canonical record it came from (plan 152 R11-10).
465
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.
469
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.
475 """
476
477 metadata: dict
478 canonical_record_locator: str
479
480 def __iter__(self):
481 """Allow ``meta, locator = load_session_metadata_located(...)`` unpacking."""
482 yield self.metadata
483 yield self.canonical_record_locator
484
485
486def canonical_record_locator(path: Path) -> str:
487 """Normalise *path*'s owning session DIRECTORY into a comparable locator.
488
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.
491 """
492 try:
493 directory = path if path.is_dir() else path.parent
494 return str(directory.resolve(strict=False)).casefold()
495 except Exception:
496 return str(path).casefold()
497
498
500 name_or_path: str, sessions_dir: str = DEFAULT_SESSIONS_DIR
501) -> LoadedSessionMetadata:
502 """Load session metadata AND report which record it came from.
503
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.
508 """
509 func = "load_session_metadata_located"
510 path = _resolve_record_path(name_or_path, "meta", sessions_dir)
511 locator = canonical_record_locator(path)
512 raw = _read_json_strip_bom(path)
513 try:
515 metadata=SessionMetadata.model_validate(raw).model_dump(),
516 canonical_record_locator=locator,
517 )
518 except ValidationError as exc:
519 _dbg(
520 "OC_CHAT.CTX",
521 func,
522 f"SessionMetadata validation failed for {path}: {exc.error_count()} errors; returning raw dict",
523 2,
524 override=True,
525 )
526 return LoadedSessionMetadata(metadata=raw, canonical_record_locator=locator)
527
528
530 old_name: str, new_name: str, sessions_dir: str = DEFAULT_SESSIONS_DIR
531) -> bool:
532 """Write the additive ``superseded_by`` mark into *old_name*'s record.
533
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
539 stale snapshot.
540
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
543 fully loadable.
544
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.
548 """
549 func = "mark_record_superseded"
550 try:
551 try:
552 path = _resolve_record_path(old_name, "meta", sessions_dir)
553 except FileNotFoundError:
554 # No such record — the ordinary "renamed a never-saved session"
555 # case. Quiet and False, exactly as before plan 225.
556 return False
557 raw = _read_json_strip_bom(path)
558 # The mark POINTS AT a record, so it stores the canonical (on-disk)
559 # name — a raw name here would name a record that does not exist.
560 raw["superseded_by"] = fs_safe_name(new_name)
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)
564 return True
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",
569 2, override=True)
570 return False
571
572
573def load_session_metadata(name_or_path: str, sessions_dir: str = DEFAULT_SESSIONS_DIR) -> dict:
574 """Load a session metadata file.
575
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.
582
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.
586 """
587 return load_session_metadata_located(name_or_path, sessions_dir).metadata
588
589
590# ─── Chat logs (full conversations) ───────────────────────────────────────────
591
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.
594
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.
598 """
599 name = _canonical_session_name(session)
600
601 # Defensive guard: never overwrite a chat log we failed to load.
602 if getattr(session, "load_failed", False) and len(session.messages) <= 2:
603 raise RuntimeError(
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."
607 )
608
609 sdir = _session_dir(name, sessions_dir)
610 _archive_kind(sdir, name, "chat_log", sessions_dir)
611
612 fname = _stamped_name(name, "chat_log")
613 path = sdir / fname
614
615 log_data = {
616 "session_name": name,
617 "timestamp": _now_stamp_compact(),
618 "model": session.model,
619 "reasoning_effort": session.reasoning_effort,
620 "messages": session.messages,
621 "usage": {
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,
627 },
628 }
629
630 with open(path, "w", encoding="utf-8") as f:
631 json.dump(log_data, f, indent=2, ensure_ascii=False)
632
633 # Update the (informational) chat_logs list to point at this current file.
634 session.chat_logs = [fname]
635 session.dirty = False
636 return str(path)
637
638
639def load_chat_log(name_or_path: str, sessions_dir: str = DEFAULT_SESSIONS_DIR) -> dict:
640 """Load a chat log.
641
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.
648 """
649 func = "load_chat_log"
650 path = _resolve_record_path(name_or_path, "chat_log", sessions_dir)
651 raw = _read_json_strip_bom(path)
652 try:
653 return ChatLog.model_validate(raw).model_dump()
654 except ValidationError as exc:
655 _dbg(
656 "OC_CHAT.CTX",
657 func,
658 f"ChatLog validation failed for {path}: {exc.error_count()} errors; returning raw dict",
659 2,
660 override=True,
661 )
662 return raw
663
664
665def save_all(session: ChatSession) -> None:
666 """Save both session metadata (lightweight) and chat log (full conversation).
667
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``.
674 """
675 func = "save_all"
676 log_path = None
677 try:
678 log_path = save_chat_log(session)
679 except RuntimeError as exc:
680 # VALIDATION_ERROR — load_failed guard tripped; preserve on-disk log
681 _dbg("OC_CHAT.CTX", func, f"VALIDATION_ERROR: load_failed: {exc}", 3)
682 print(f"\n[Chat log NOT saved: {exc}]")
683 meta_path = save_session_metadata(session)
684 if log_path:
685 print(f"\n[Chat log saved to {log_path}]")
686 print(f"[Session metadata saved to {meta_path}]")
687
688
689# ─── Markdown export ──────────────────────────────────────────────────────────
690
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."""
693 name = session.name
694 sdir = _session_dir(name, sessions_dir)
695
696 if filepath is None:
697 # _archive_kind uses ``*_{kind}.*`` so it matches *_export.md too.
698 _archive_kind(sdir, name, "export", sessions_dir)
699 fname = _stamped_name(name, "export", ext="md")
700 filepath = str(sdir / fname)
701
702 timestamp = _now_stamp_compact()
703 lines = [
704 f"# Chat Export — {name}",
705 "",
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})",
712 "",
713 ]
714 for msg in session.messages:
715 role = msg.get("role", "")
716 content = msg.get("content") or ""
717 if role == "system":
718 lines += ["---", "**System**", "", content, ""]
719 elif role == "user":
720 lines += ["---", "**You**", "", content, ""]
721 elif role == "assistant" and content:
722 usage = msg.get("usage", {})
723 usage_str = ""
724 if 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))
729 return filepath
730
731
732# ─── HTML export (PR 1.D) ─────────────────────────────────────────────────────
733
734
735def export_html(session: ChatSession, filepath: str | None = None,
736 sessions_dir: str | None = None,
737 code_theme: str = "monokai") -> str:
738 """Export the conversation as a styled, single-file HTML document.
739
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).
744
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]``.
749 """
750 func = "export_html"
751 # Lazy-import so the plain console doesn't require 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
757
758 if sessions_dir is None:
759 sessions_dir = DEFAULT_SESSIONS_DIR
760
761 name = session.name
762 sdir = _session_dir(name, sessions_dir)
763 if filepath is None:
764 # Archive any existing *_export.html before writing a fresh one,
765 # mirroring the Markdown export path's behaviour.
766 for old_html in list(sdir.glob("*_export.html")):
767 archive = _archive_dir(name, sessions_dir)
768 tgt = archive / old_html.name
769 if tgt.exists() and _hash_file(tgt) == _hash_file(old_html):
770 old_html.unlink()
771 else:
772 shutil.move(str(old_html), str(tgt))
773 fname = _stamped_name(name, "export", ext="html")
774 filepath = str(sdir / fname)
775
776 # ``record=True`` makes Console accumulate every styled segment so we
777 # can export them as HTML at the end. ``width`` is fixed at 100 for
778 # consistent rendering regardless of terminal size.
779 console = Console(record=True, width=100, file=open(os.devnull, "w"))
780
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"),
790 ))
791 console.print()
792
793 for msg in session.messages:
794 role = msg.get("role", "")
795 content = (msg.get("content") or "").strip()
796 if not content:
797 continue
798 if role == "system":
799 console.print(Panel(content, title="System", border_style="dim",
800 title_align="left"))
801 elif role == "user":
802 console.print(Panel(content, title="You", border_style="blue",
803 title_align="left"))
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"
808 try:
809 console.print(Panel(
810 Markdown(content, code_theme=code_theme),
811 title=title, border_style="green", title_align="left",
812 ))
813 except Exception as e:
814 # SYSTEM_ERROR — Markdown render failed; fall back to plain panel
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"))
818
819 Path(filepath).parent.mkdir(parents=True, exist_ok=True)
820 # ``inline_styles=True`` makes the output a self-contained file —
821 # no external CSS, suitable for emailing or pasting into a wiki.
822 console.save_html(filepath, inline_styles=True)
823 return filepath
824
825
826# ─── Tool artifacts (PR 1.C) ──────────────────────────────────────────────────
827
828
829def save_tool_artifact(session_name: str, tool_name: str, text: str,
830 sessions_dir: str | None = None) -> Path:
831 """Spill a long tool-result payload to ``sessions/{name}/artifacts/``.
832
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.
837
838 *sessions_dir* defaults to the live module-level
839 :data:`DEFAULT_SESSIONS_DIR` so tests can monkeypatch it.
840
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``
844 one-liner instead.
845 """
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)
851 # Plan 134 B1: the second-precision stamp alone collides when two same-tool
852 # spills land in the same second (the captured "two read_file → same file"
853 # overwrite). A STATELESS uniquifier — nanosecond time + a random uuid4
854 # fragment — keeps filenames distinct without a shared counter, so it is
855 # concurrency-safe under parallel agents. Only this filename build changes;
856 # other _stamped_name callers are untouched.
857 uniq = f"{time.time_ns()}_{uuid4().hex[:6]}"
858 fname = _stamped_name(session_name, f"tool_{safe_tool}_{uniq}", ext="txt")
859 path = sdir / fname
860 path.write_text(text, encoding="utf-8")
861 return path
862
863
864def find_latest_artifact(session_name: str,
865 sessions_dir: str | None = None) -> Path | None:
866 """Return the most-recently-written artifact in this session, or None.
867
868 *sessions_dir* defaults to the live module-level
869 :data:`DEFAULT_SESSIONS_DIR`.
870 """
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():
875 return None
876 files = list(sdir.glob("*.txt"))
877 if not files:
878 return None
879 return max(files, key=lambda p: p.stat().st_mtime)
880
881
882# ─── Listing sessions ─────────────────────────────────────────────────────────
883
884def list_sessions(sessions_dir: str = DEFAULT_SESSIONS_DIR) -> list[dict]:
885 """Scan ``sessions_dir`` for per-session subdirectories. Returns one entry per session.
886
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.
891 """
892 func = "list_sessions"
893 base = Path(sessions_dir)
894 if not base.is_dir():
895 return []
896
897 results: list[dict] = []
898 for child in sorted(base.iterdir()):
899 if not child.is_dir():
900 continue
901 # Skip reserved dirs.
902 if child.name in (ARCHIVE_DIR_NAME, LEGACY_DIR_NAME):
903 continue
904 try:
905 meta_path = _glob_latest(child, "meta")
906 except FileNotFoundError:
907 continue # session dir without a meta file — skip silently
908 try:
909 data = _read_json_strip_bom(meta_path)
910 except (json.JSONDecodeError, OSError) as exc:
911 _dbg("OC_CHAT.CTX", func, f"skipping unreadable session file: {meta_path} ({exc})", 2)
912 continue
913
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(
918 "%Y-%m-%d %H:%M"
919 )
920 # Look up the chat log size — useful when token totals are 0
921 # (e.g. legacy sessions where usage was never recorded).
922 chat_log_size = 0
923 try:
924 chat_log_size = _glob_latest(child, "chat_log").stat().st_size
925 except FileNotFoundError:
926 pass
927 results.append({
928 "name": name,
929 "model": model,
930 "stats": stats,
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 {},
937 })
938 return results
939
940
941def session_exists(name: str, sessions_dir: str = DEFAULT_SESSIONS_DIR) -> bool:
942 """Return True if *name*'s session directory holds at least one ``*_meta.json``.
943
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.
949 """
950 for sdir in _name_lookup_dirs(name, sessions_dir):
951 if sdir.is_dir() and any(sdir.glob("*_meta.json")):
952 return True
953 return False
954
955
956# ─── One-shot migration: flat layout → per-session-directory ──────────────────
957
958# Crash recovery helpers.
959
960def _is_variable_context_message(message: dict) -> bool:
961 return (
962 message.get("role") == "user"
963 and isinstance(message.get("content"), str)
964 and message["content"].startswith(VARIABLE_CONTEXT_SENTINEL)
965 )
966
967
968def _conversation_indexes(messages: list[dict]) -> list[int]:
969 return [
970 index
971 for index, message in enumerate(messages)
972 if not _is_variable_context_message(message)
973 ]
974
975
976def _tool_call_ids(message: dict) -> list[str]:
977 ids: list[str] = []
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"])
981 return ids
982
983
984def _message_has_content(message: dict) -> bool:
985 content = message.get("content")
986 return isinstance(content, str) and bool(content.strip())
987
988
989def _tail_report(kind: str, tail_indexes: list[int], summary: str) -> dict:
990 return {
991 "kind": kind,
992 "tail_indexes": tail_indexes,
993 "summary": summary,
994 }
995
996
997def _tool_tail_report(messages: list[dict], indexes: list[int]) -> dict | None:
998 last_position = len(indexes) - 1
999 tail_indexes: list[int] = []
1000 seen_tool_ids: set[str] = set()
1001
1002 while last_position >= 0:
1003 index = indexes[last_position]
1004 message = messages[index]
1005 if message.get("role") != "tool":
1006 break
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)
1011 last_position -= 1
1012
1013 if last_position < 0:
1014 return None
1015
1016 assistant_index = indexes[last_position]
1017 assistant = messages[assistant_index]
1018 if assistant.get("role") != "assistant":
1019 return None
1020
1021 required_ids = set(_tool_call_ids(assistant))
1022 if not required_ids:
1023 return None
1024
1025 missing_ids = required_ids - seen_tool_ids
1026 if not missing_ids:
1027 return None
1028
1029 ordered_tail = [assistant_index, *reversed(tail_indexes)]
1030 missing = ", ".join(sorted(missing_ids))
1031 return _tail_report(
1032 "orphaned_tool_calls",
1033 ordered_tail,
1034 f"Assistant tool call(s) missing tool response(s): {missing}.",
1035 )
1036
1037
1038def detect_incomplete_tail(messages: list[dict]) -> dict | None:
1039 """Return a structured crash-recovery report for an incomplete tail.
1040
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.
1044 """
1045 indexes = _conversation_indexes(messages)
1046 if not indexes:
1047 return None
1048
1049 last_index = indexes[-1]
1050 last_message = messages[last_index]
1051 role = last_message.get("role")
1052
1053 if role == "user":
1054 return _tail_report(
1055 "orphaned_user",
1056 [last_index],
1057 "Session ends with a user message that has no assistant response.",
1058 )
1059
1060 if role == "tool":
1061 return _tool_tail_report(messages, indexes)
1062
1063 if role != "assistant":
1064 return None
1065
1066 tool_call_ids = _tool_call_ids(last_message)
1067 if tool_call_ids:
1068 missing = ", ".join(sorted(tool_call_ids))
1069 return _tail_report(
1070 "orphaned_tool_calls",
1071 [last_index],
1072 f"Assistant tool call(s) missing tool response(s): {missing}.",
1073 )
1074
1075 if not _message_has_content(last_message):
1076 return _tail_report(
1077 "truncated_assistant",
1078 [last_index],
1079 "Session ends with an empty assistant message.",
1080 )
1081
1082 return None
1083
1084
1086 messages: list[dict],
1087 choice: Literal["drop", "keep", "fresh"],
1088) -> list[dict]:
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]]
1095 return []
1096 if choice != "drop":
1097 raise ValueError(f"Unsupported recovery choice: {choice}")
1098
1099 report = detect_incomplete_tail(messages)
1100 if report is None:
1101 return list(messages)
1102 tail_indexes = set(report["tail_indexes"])
1103 return [
1104 message
1105 for index, message in enumerate(messages)
1106 if index not in tail_indexes
1107 ]
1108
1109
1110def write_clean_exit_marker(sessions_dir: str = DEFAULT_SESSIONS_DIR) -> None:
1111 """Write the marker used to distinguish graceful exit from crash exit."""
1112 base = _sessions_root(sessions_dir)
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)
1119
1120
1121def read_crash_signal(sessions_dir: str = DEFAULT_SESSIONS_DIR) -> dict | None:
1122 """Return the latest-session crash signal, or None when startup is clean."""
1123 sessions = list_sessions(sessions_dir)
1124 if not sessions:
1125 return None
1126
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
1130
1131 if marker_path.exists() and marker_path.stat().st_mtime >= latest_mtime:
1132 return None
1133
1134 return {
1135 "last_session_name": latest["name"],
1136 "last_modified": latest["last_modified"],
1137 }
1138
1139
1140def migrate_legacy_layout(sessions_dir: str = DEFAULT_SESSIONS_DIR) -> dict:
1141 """Convert any flat-layout session files to the per-session-directory layout.
1142
1143 Idempotent: already-migrated sessions are skipped.
1144
1145 Returns a dict summarising the migration: ``migrated``, ``recovered``,
1146 ``orphans_moved``, ``errors``.
1147 """
1148 base = _sessions_root(sessions_dir)
1149 summary = {"migrated": 0, "recovered": 0, "orphans_moved": 0, "errors": []}
1150
1151 # Step 1: per-session migration of any flat ``{name}.json`` files.
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
1155 try:
1156 data = _read_json_strip_bom(flat_meta)
1157 except Exception as exc:
1158 summary["errors"].append(f"{flat_meta.name}: {exc}")
1159 continue
1160
1161 sdir = base / name
1162 sdir.mkdir(parents=True, exist_ok=True)
1163
1164 # Write meta file with mtime-based timestamp.
1165 meta_stamp = _stamp_from_mtime(flat_meta)
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)
1171
1172 # Migrate the matching chat log if one exists.
1173 old_log = base / "Chat_logs" / f"{name}_log.json"
1174 if old_log.exists():
1175 log_stamp = _stamp_from_mtime(old_log)
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():
1179 try:
1180 log_data = _read_json_strip_bom(old_log)
1181 except Exception as exc:
1182 summary["errors"].append(f"{old_log.name}: {exc}")
1183 else:
1184 with open(new_log_path, "w", encoding="utf-8") as f:
1185 json.dump(log_data, f, indent=2, ensure_ascii=False)
1186 old_log.unlink()
1187
1188 flat_meta.unlink()
1189 summary["migrated"] += 1
1190
1191 # Step 2: move any orphan chat_log_*.json (no matching session dir) to legacy/.
1192 old_chat_dir = base / "Chat_logs"
1193 if old_chat_dir.is_dir():
1194 legacy = _legacy_dir(sessions_dir)
1195 for orphan in list(old_chat_dir.iterdir()):
1196 if orphan.name == ".gitkeep":
1197 continue
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
1203 else:
1204 orphan.unlink() # already in legacy
1205 # Try to remove the (now-empty) Chat_logs dir.
1206 try:
1207 # Remove .gitkeep last so the dir can be removed.
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"):
1211 gitkeep.unlink()
1212 if not any(old_chat_dir.iterdir()):
1213 old_chat_dir.rmdir()
1214 except OSError:
1215 pass
1216
1217 return summary
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)