6Session and token-stats data models.
10Defines :class:`ChatSession` (the mutable conversation state) and
11:class:`TokenStats` (accumulated per-session usage) as dataclasses,
12plus helpers for auto-generating session names and for building a
13system prompt that embeds tool schemas in a prefix-cache-friendly way.
17- :class:`ChatSession` — holds messages, model, reasoning effort,
18 token-profile, name, dirty flag, last-turn prompt-token count, and
19 per-session usage stats.
20- :class:`TokenStats` — running totals + cache-hit/miss derived via
21 :func:`oc_chat.core.cache_usage.usage_prompt_cache_hit_miss`.
22- :func:`build_system_prompt` — compose the base policy + a conditional
23 tool-use section + the compact tool map (plan 135 §3.3/§3.6); the full
24 JSON schema travels only via the API ``tools`` parameter, never here.
25- :func:`ensure_system_includes_tool_reference` — idempotent re-attach
26 of the compact tool reference when a session is loaded from disk
27 (recognises both the compact and the legacy sentinel).
28- :func:`apply_session_metadata_fields` — update mutable session
29 fields from a metadata dict (used by ``/session load``).
41- ``ChatSession`` is mutable and not thread-safe; one instance per
43- ``ensure_system_includes_tool_reference`` is idempotent via the
44 compact-tool-map sentinel.
45- ``record_usage`` accepts arbitrary dict shapes; missing keys do not
50- oc_diagnostics (structured debug logging via _dbg)
51- oc_chat.core.cache_usage (DeepSeek prompt-cache hit ratio reporting)
52- oc_chat.core.config (shared config constants and ANSI styles)
53- oc_chat.core.git_helpers (git_head_hash for the project-tree cache key)
54- oc_chat.core.policy (canonical policy text and the tools and no-tools preambles)
55- oc_chat.core.tool_prompt (compact tool map and its sentinel for the system prompt)
56- oc_chat.core.deployment_shape (lazy; check_deployment_shape on a resumed sandbox-root override)
57- oc_chat.core.event_bootstrap (lazy; _valid_alias to validate the event slugs)
58- oc_chat.core.external_content_gate (lazy; session-trust reset and the canonical session-id check)
59- oc_chat.core.persistence (lazy; list_sessions to avoid session-name collisions)
60- oc_chat.core.provider_plugin (lazy; provider registry for the default model)
61- oc_chat.core.risk_profile (lazy; resolve_risk_profile for the deployment-shape check)
62- oc_chat.core.session_checkpoints (lazy; reset of the cost-checkpoint latches)
63- oc_chat.core.session_mode (lazy; reset of the confirmation mode and remembered answers)
64- oc_chat.core.settings (lazy; DEFAULT_SETTINGS project-tree defaults)
65- oc_chat.core.startup (lazy; refresh_variable_context after a model switch)
66- oc_chat.core.tier2_session_grant (lazy; revoke the session Tier-2 grants)
67- oc_chat.core.wordgen (lazy; random_name for the default session name)
68- oc_chat.tools._sandbox (lazy; install the session sandbox-root override)
72oc_chat/docs/conventions/CONTRACTS.md §1.3 TokenStats, §1.4 ChatSession, §7 Session
73message dict shape — owners of the per-session state contracts.
78from dataclasses
import dataclass, field
79from pathlib
import Path
81from oc_diagnostics
import _dbg
85 DEFAULT_MAX_REACT_ITERATIONS,
86 DEFAULT_REASONING_EFFORT,
98 """Return the active provider's default model id, or empty if no
99 provider has registered yet (rare — only happens in tests that
100 construct a ``ChatSession`` without first importing a provider bundle).
102 func =
"_default_model"
105 name = os.environ.get(
"OC_CHAT_PROVIDER",
"deepseek")
107 return registry.get(name).default_model
108 except (KeyError, AttributeError)
as e:
110 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
117_DEFAULT_SYSTEM = CANONICAL_POLICY
120SESSION_TOOL_SCHEMA_SENTINEL =
"<!--dc:embedded-tool-schemas-->"
125VARIABLE_CONTEXT_SENTINEL =
"<!--oc:variable-context-->"
131_LEGACY_TOOL_BLOCK_HEADER = (
133 "\n\n--\nOC Chat tool definitions (JSON Schema; duplicated from the API "
134 "tools parameter for cache stability):"
139 base_instruction: str, tool_schemas: list |
None, *, reference: str =
"compact"
141 """Compose ``messages[0]``: base policy + a conditional tool-use section.
143 Plan 135 §3.3/§3.6 — emits the persona/policy, then either the tool-use
144 preamble + the compact tool map (when ≥1 tool is exposed) or the no-tools
145 variant (no table). The full JSON Schema travels ONLY via the API ``tools``
146 parameter; it is never embedded here. ``reference`` is reserved for future
147 map variants (``"compact"`` today). Byte-stable for a given
148 ``(base_instruction, tool_schemas)``.
150 text = (base_instruction
or "").rstrip()
152 return f
"{text}\n\n{TOOLS_POLICY_PREAMBLE}\n\n{compact_tool_map(tool_schemas)}"
153 return f
"{text}\n\n{NO_TOOLS_POLICY}"
157 """Idempotently ensure ``messages[0]`` carries the tool reference.
159 No-op when the system message already has the compact-map sentinel OR the
160 legacy sentinel (a legacy session is converted by
161 :func:`_migrate_legacy_system_prompt` on load, not re-dumped here). Only
162 ``messages[0]`` (the immutable system prompt) is touched — never
163 ``messages[1]`` (variable context).
165 if not tool_schemas
or not session.messages:
167 first = session.messages[0]
168 if first.get(
"role") !=
"system":
170 content = first.get(
"content")
or ""
171 if COMPACT_TOOL_MAP_SENTINEL
in content
or SESSION_TOOL_SCHEMA_SENTINEL
in content:
178 """Convert a legacy embedded-schema ``messages[0]`` to the compact reference.
180 Plan 135 §4.3: when the legacy sentinel is present, strip from the legacy
181 block **header** (the JSON dump precedes the sentinel) through the
182 quick-reference tail, keep everything before the header as the base prompt,
183 and rebuild with the compact reference. Fail conservatively — if the sentinel
184 is present but the header cannot be found (hand-edited/older layout), warn and
185 leave ``messages[0]`` untouched rather than guess a boundary. Returns ``True``
186 when a migration was applied.
188 func =
"_migrate_legacy_system_prompt"
189 if not session.messages:
191 first = session.messages[0]
192 if first.get(
"role") !=
"system":
194 content = first.get(
"content")
or ""
195 if SESSION_TOOL_SCHEMA_SENTINEL
not in content:
197 idx = content.rfind(_LEGACY_TOOL_BLOCK_HEADER)
199 _dbg(
"OC_CHAT.MAIN", func,
201 "WARNING: legacy schema sentinel present but block header not found; "
202 "leaving messages[0] untouched (conservative-fail)", 1, override=
True)
204 base = content[:idx].rstrip()
207 _dbg(
"OC_CHAT.MAIN", func,
"migrated legacy embedded-schema prompt to compact reference", 2)
212 """Generate a default session name from random words.
214 Returns kebab-case names like ``sunny-weaving-honey``.
215 Checks existing sessions to avoid collisions.
219 existing = {s[
"name"]
for s
in list_sessions()}
220 return random_name(exists_fn=
lambda n: n
in existing)
225 """Session-scoped USD spend, folded honestly (CAPS-01, plan 237 P2).
227 One fold over the SAME samples the ``provider_budget_consumed`` events
228 carry, priced by the one authoritative source (:mod:`oc_pricing`, through
229 ``react_loop._publish_provider_budget``). It is never re-derived from
230 tokens by a second formula: two formulas would disagree, and the number
231 that stops a session must not be the one nobody can reproduce.
233 The honesty rule (plan 188 T4, inherited): a sample that cannot be priced
234 is COUNTED, never treated as zero. ``priced_usd`` is therefore a
235 lower bound on real spend, and ``unpriced_samples`` is how far it may be
236 wrong — which is why both travel together into the status line, the mini
237 report, the alert events and the hard-cap refusal. The residual is stated
238 rather than hidden: unpriced spend cannot trip the hard cap.
240 ``thresholds_fired_usd`` records which checkpoints this session has already
241 announced, so a reload resumes rather than re-announcing them.
244 priced_usd: float = 0.0
245 unpriced_samples: int = 0
246 thresholds_fired_usd: list = field(default_factory=list)
249 """Fold ONE priced sample, or count one unpriceable sample."""
256 """Record that *threshold* has been announced for this session."""
261 """True when *threshold* was already announced this session."""
262 return any(abs(float(t) - float(threshold)) < 1e-9
266 """Return a JSON-serialisable dict of the session cost fold."""
275 """Reconstruct from a persisted dict; anything unusable yields a fresh zero.
277 Read tolerantly: a pre-237 ``meta.json`` carries no cost block at all,
278 and a corrupt one must start the session at zero rather than refuse to
279 load it. Starting at zero is the SAFE direction — the hard cap and the
280 checkpoints then fire sooner, never later."""
281 if isinstance(d, cls):
283 if not isinstance(d, dict):
286 fired = [float(t)
for t
in (d.get(
"thresholds_fired_usd")
or [])]
287 except (TypeError, ValueError):
290 priced = float(d.get(
"priced_usd", 0.0)
or 0.0)
291 except (TypeError, ValueError):
294 unpriced = int(d.get(
"unpriced_samples", 0)
or 0)
295 except (TypeError, ValueError):
297 return cls(priced_usd=max(0.0, priced),
298 unpriced_samples=max(0, unpriced),
299 thresholds_fired_usd=fired)
304 """Per-session aggregated token usage.
306 Tracks prompt, completion, and total tokens alongside message and
307 tool-call counts. Can be serialised via :meth:`to_dict` and
308 reconstructed via :meth:`from_dict`.
310 total_prompt_tokens: int = 0
311 total_completion_tokens: int = 0
312 total_tokens: int = 0
313 message_count: int = 0
314 tool_call_count: int = 0
315 prompt_cache_hit_tokens: int = 0
316 prompt_cache_miss_tokens: int = 0
323 cost: SessionCost = field(default_factory=SessionCost)
326 """Fold one provider usage sample's USD cost (``None`` = unpriceable)."""
327 self.
cost.add_sample(cost_usd)
330 """Accumulate token usage from a single API response."""
334 hit, miss = usage_prompt_cache_hit_miss(usage)
339 """Increment the user/assistant message counter."""
343 """Increment the tool-call counter."""
347 """Return a JSON-serialisable dict of the stats."""
363 """Reconstruct a :class:`TokenStats` from a dict (e.g. loaded JSON).
365 If *d* is already a ``TokenStats`` instance it is returned unchanged,
366 so callers that receive either a raw dict or a live object are safe.
368 if isinstance(d, cls):
371 total_prompt_tokens=d.get(
"total_prompt_tokens", 0),
372 total_completion_tokens=d.get(
"total_completion_tokens", 0),
373 total_tokens=d.get(
"total_tokens", 0),
374 message_count=d.get(
"message_count", 0),
375 tool_call_count=d.get(
"tool_call_count", 0),
376 prompt_cache_hit_tokens=d.get(
"prompt_cache_hit_tokens", 0),
377 prompt_cache_miss_tokens=d.get(
"prompt_cache_miss_tokens", 0),
378 cost=SessionCost.from_dict(d.get(
"session_cost")),
384 """Mutable state for a single chat conversation.
386 Tracks the message list, model selection, reasoning effort, token
387 stats, associated chat logs, and a dirty flag for auto-save logic.
390 name: str = field(default_factory=_auto_name)
395 session_id: str = field(default_factory=
lambda: str(uuid.uuid4()))
396 model: str = field(default_factory=_default_model)
397 reasoning_effort: str = field(default_factory=
lambda: DEFAULT_REASONING_EFFORT)
398 messages: list = field(default_factory=
lambda: [
399 {
"role":
"system",
"content": CANONICAL_POLICY}
402 filepath: str |
None =
None
403 stats: TokenStats = field(default_factory=TokenStats)
404 chat_logs: list = field(default_factory=list)
405 load_failed: bool =
False
406 ui_prefs: dict = field(default_factory=dict)
408 """Per-session UI preferences (theme, show_reasoning, show_stats, …).
410 Persisted under the ``ui`` key in ``meta.json``. See ``/ui`` command.
411 Phase 0 stores these but only :class:`RichPresenter` (Phase 1+) will
416 last_turn_max_prompt_tokens: int = 0
418 rollover_hint_shown: bool =
False
422 token_profile: str =
'balanced'
424 max_react_iterations: int |
None =
None
429 active_plan_id: str |
None =
None
433 generation_options: dict = field(default_factory=dict)
438 endpoint_name: str =
""
443 show_iteration_stats: bool =
True
444 iteration_stats_file: object = field(default=
None, repr=
False, compare=
False)
446 cache_turn_snapshots: list = field(default_factory=list)
449 sandbox_root_override: str |
None =
None
457 legacy_event_slugs: list = field(default_factory=list)
462 superseded_by: str |
None =
None
465 """Append a user message and mark the session dirty."""
467 _dbg(
"OC_CHAT.MAIN", func, f
"chars={len(text)} session={self.name}", 3)
468 self.
messages.append({
"role":
"user",
"content": text})
472 """Append an assistant message, mark dirty, and bump the message count."""
473 self.
messages.append({
"role":
"assistant",
"content": text})
475 self.
stats.add_message()
478 """Append an assistant message that contains ``tool_calls`` (added after streaming)."""
481 self.
stats.add_message()
484 """Append a tool-result message, mark dirty, and count the tool call."""
487 self.
stats.add_tool_call()
490 """Record token usage from an API response chunk."""
491 self.
stats.add_usage(usage)
494 """Reset the conversation, keeping only the system prompt."""
495 self.
messages = [{
"role":
"system",
"content": self.
messages[0][
"content"]}]
505 reset_session_trust()
509 revoke_session_grants()
517 reset_session_checkpoints()
526 """Replace the system prompt and mark the session dirty."""
527 self.
messages[0] = {
"role":
"system",
"content": prompt}
531 """Switch the active model through the single choke-point (plan 135 §3.5).
533 Contract: (1) no-op if ``new_model == self.model``. (2) Otherwise set the
534 model and mark the session dirty FIRST — a user-initiated switch must
535 persist even when :func:`refresh_variable_context` finds no sentinel
536 (e.g. an ``include_env:false`` / legacy / context-free session) — THEN
537 refresh the ``messages[1]`` environment block in place so ``Model:`` (and
538 the git/dirty facts) stay accurate. Only ever called outside the turn loop
539 (the inner loop holds ``session.model`` fixed per turn — inner-loop.md),
540 so it cannot perturb a live turn. Load paths call this AFTER ``messages``
541 hydration and may clear ``dirty`` afterwards (loading is not a user edit).
543 if new_model == self.
model:
545 self.
model = new_model
550 refresh_variable_context(self)
553 """Clear the dirty flag (e.g. after a successful save)."""
558 """Return the current system prompt text."""
563 """Return the number of user + assistant messages (not system/tool)."""
564 return sum(1
for m
in self.
messages if m[
"role"]
in (
"user",
"assistant"))
567 """Effective ReAct iteration cap (``/react`` override or default).
569 Returns ``self.max_react_iterations`` when it is an int in
570 ``[1, 99]``; otherwise falls back to
571 :data:`DEFAULT_MAX_REACT_ITERATIONS`.
574 if isinstance(v, int)
and 1 <= v <= 99:
576 return DEFAULT_MAX_REACT_ITERATIONS
579 """Record a chat log filename associated with this session."""
588_GEN_TREE_CACHE: tuple[str, str] |
None =
None
592 """Relative paths git ignores under *root* (best-effort; '' on any error).
594 Uses git's own ignore logic via ``ls-files --others --ignored
595 --exclude-standard --directory`` so the tree stays self-maintaining when
596 the operator gitignores a dir. Returns forward-slash relpaths, wholly-
597 ignored dirs without a trailing slash."""
599 out = subprocess.run(
600 [
"git",
"-C", str(root),
"ls-files",
"--others",
"--ignored",
601 "--exclude-standard",
"--directory"],
602 capture_output=
True, text=
True, timeout=5, check=
False,
604 except (OSError, subprocess.SubprocessError):
607 ln.strip().rstrip(
"/").replace(
"\\",
"/")
608 for ln
in out.stdout.splitlines()
if ln.strip()
615 exclude_dirs:
"list[str] | None" =
None,
616 exclude_suffixes:
"list[str] | None" =
None,
617 stub_dirs:
"list[str] | None" =
None,
618 honor_gitignore: bool |
None =
None,
619 work_dir: str |
None =
None,
621 """Build a compact project tree for injection into the system prompt.
623 Cached by (git HEAD hash + config signature) so repeated session starts
624 don't re-scan the filesystem. Excludes hidden dirs (dot-prefixed), the
625 configured ``exclude_dirs``, names ending in any ``exclude_suffixes``
626 (e.g. ``.bk``), the ``work_dir`` scratch area, and anything git ignores
627 when ``honor_gitignore``. Dirs in ``stub_dirs`` (e.g. ``sessions``,
628 ``plans``) are shown as existence-only with a ``list_dir`` hint so their
629 high-churn contents don't bloat the prompt.
631 Config defaults come from :data:`oc_chat.core.settings.DEFAULT_SETTINGS`
632 so a no-arg call matches a settings-default call (plan 133).
634 global _GEN_TREE_CACHE
636 _pt = DEFAULT_SETTINGS[
"project_tree"]
638 excl_dirs = frozenset(exclude_dirs
if exclude_dirs
is not None else _pt[
"exclude_dirs"])
639 excl_suffixes = tuple(exclude_suffixes
if exclude_suffixes
is not None else _pt[
"exclude_suffixes"])
640 excl_suffixes = excl_suffixes + (
".pyc",
".pyo")
641 stubs = frozenset(stub_dirs
if stub_dirs
is not None else _pt.get(
"stub_dirs", []))
642 honor_gi = _pt[
"honor_gitignore"]
if honor_gitignore
is None else honor_gitignore
643 wdir = (_pt[
"work_dir"]
if work_dir
is None else work_dir)
or None
645 root = Path.cwd().resolve()
648 cfg_sig = repr((max_depth, sorted(excl_dirs), excl_suffixes, sorted(stubs),
649 honor_gi, wdir, sorted(gitignored)))
650 cache_key = (_git_head_hash()
or "no-git", cfg_sig)
651 if _GEN_TREE_CACHE
is not None and _GEN_TREE_CACHE[0] == cache_key:
652 return _GEN_TREE_CACHE[1]
654 lines: list[str] = []
655 lines.append(
"## Project Map (max depth {})".format(max_depth))
657 def _walk(parent: Path, rel_parent: str, depth: int) ->
None:
658 if depth > max_depth:
661 entries = sorted(parent.iterdir(), key=
lambda e: (
not e.is_dir(), e.name.lower()))
662 except PermissionError:
664 for entry
in entries:
666 if name.startswith(
"."):
668 if name.endswith(excl_suffixes):
670 rel = f
"{rel_parent}/{name}" if rel_parent
else name
671 if rel
in gitignored:
674 if name
in excl_dirs
or (wdir
and name == wdir):
678 " " * depth +
"--- " + name
679 + f
'/ (contents omitted; use list_dir("{name}/") to enumerate)'
682 lines.append(
" " * depth +
"--- " + name +
"/")
683 if depth < max_depth:
684 _walk(entry, name, depth + 1)
686 lines.append(
" " * depth +
"--- " + name)
689 tree =
"\n".join(lines)
690 _GEN_TREE_CACHE = (cache_key, tree)
695 """Clear F67 external-content trust BEFORE a regenerated identity is exposed.
697 A regenerated ``session_id`` means we could not establish which record this
698 is. Whatever trust the process had accumulated belonged to a different
699 identity, so it must not carry over. Best-effort by design: a diagnostics
700 failure must never block a session load.
702 func =
"_reset_trust_before_exposure"
705 reset_session_trust()
709 revoke_session_grants()
719 reset_session_checkpoints()
725 except Exception
as exc:
726 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: trust reset failed: {exc}", 2)
730 """Return the persisted rename aliases that pass the canonical slug grammar.
732 Untrusted persisted path input: accepted values are EXACTLY canonical
733 :func:`~oc_chat.core.event_bootstrap._slug` outputs, which rejects
734 separators, ``.``/``..``, absolute / drive / UNC forms and every
735 non-canonical spelling in one predicate. Order is preserved and duplicates
736 are dropped — a multi-epoch A -> B -> C rename needs the whole ordered
737 collection, and one alias cannot represent it.
739 Invalid entries are dropped with a COUNTED diagnostic that never echoes the
742 func =
"_validated_event_slugs"
743 if not isinstance(raw, (list, tuple)):
749 if _valid_alias(item)
and item
not in out:
751 elif not _valid_alias(item):
754 _dbg(
"OC_CHAT.MAIN", func,
755 f
"VALIDATION_ERROR: dropped {dropped} invalid legacy_event_slugs entr(ies)", 2)
760 session:
"ChatSession", meta: dict, *, pure: bool =
False
762 """Restore persisted session fields from a ``meta.json`` dict (Phase 3–4).
764 ``pure`` (plan 152-b D1) is passed ONLY by the load/switch transition seam,
765 which hydrates a detached CANDIDATE and defers every live effect to the
766 commit critical section. It suppresses EXACTLY the two live-state mutations
767 this function otherwise performs directly:
769 - it does NOT call :func:`_reset_trust_before_exposure` — F67 trust rotation
770 is prepared and installed by the seam (``prepare_session_binding`` /
771 ``install_prepared_session_binding``), keyed on the candidate's final id;
772 - it does NOT call ``set_session_sandbox_root_override`` — the seam resolves
773 the override in PREPARE and installs the process-global at commit.
775 Every FIELD assignment (including ``session.sandbox_root_override``, which the
776 seam's ``prepare_transition`` reads back off the candidate) still lands, so a
777 pure call fully hydrates the candidate object and returns ``None``. The
778 default (``pure=False``) is byte-compatible legacy behaviour for the three
779 non-seam callers (startup resume, main startup, batch).
781 func =
"apply_session_metadata_fields"
803 sid = meta.get(
"session_id")
804 if is_canonical_session_id(sid):
805 session.session_id = sid
807 if not is_canonical_session_id(getattr(session,
"session_id",
None)):
808 session.session_id = str(uuid.uuid4())
810 _dbg(
"OC_CHAT.MAIN", func,
812 f
"VALIDATION_ERROR: persisted session_id is not a canonical uuid4 "
813 f
"(type={type(sid).__name__}); kept a freshly-minted id instead", 3)
827 session.last_turn_max_prompt_tokens = int(meta.get(
"last_turn_max_prompt_tokens", 0)
or 0)
828 session.rollover_hint_shown = bool(meta.get(
"rollover_hint_shown",
False))
829 tp = meta.get(
"token_profile",
"balanced")
830 session.token_profile = tp
if tp
in TOKEN_PROFILES
else "balanced"
831 mr = meta.get(
"max_react_iterations")
832 session.max_react_iterations = mr
if isinstance(mr, int)
and 1 <= mr <= 99
else None
833 session.cache_turn_snapshots = list(meta.get(
"cache_turn_snapshots")
or [])[-30:]
834 apv = meta.get(
"active_plan_id")
835 session.active_plan_id = apv
if isinstance(apv, str)
and apv
else None
836 raw_gen = meta.get(
"generation_options")
837 session.generation_options = dict(raw_gen)
if isinstance(raw_gen, dict)
else {}
838 raw_endpoint = meta.get(
"endpoint_name",
"")
839 session.endpoint_name = raw_endpoint
if isinstance(raw_endpoint, str)
else ""
840 raw_sandbox = meta.get(
"sandbox_root_override")
841 candidate = raw_sandbox
if isinstance(raw_sandbox, str)
and raw_sandbox
else None
849 if candidate
is not None:
851 valid = Path(candidate).expanduser().resolve(strict=
False).is_dir()
852 except (OSError, ValueError):
858 f
"rejected sandbox_root_override {candidate!r} (not an existing "
859 f
"directory); failing closed to launch-time root",
863 if candidate
is not None:
871 check_deployment_shape(candidate, resolve_risk_profile())
872 except DeploymentShapeError:
873 _dbg(
"OC_CHAT.MAIN", func,
874 f
"rejected sandbox_root_override {candidate!r} (deployment-shape "
875 f
"overlap under guarded/isolated); failing closed to launch-time root", 2)
877 session.sandbox_root_override = candidate
886 set_session_sandbox_root_override(session.sandbox_root_override)
887 except Exception
as exc:
892 f
"VALIDATION_ERROR: sandbox override sync failed: {exc}",
list cache_turn_snapshots
None add_user(self, str text)
None add_assistant_with_tool_calls(self, dict msg)
None add_tool_result(self, dict result)
None add_assistant(self, str text)
None add_chat_log(self, str filename)
int last_turn_max_prompt_tokens
None set_model(self, str new_model)
None set_system_prompt(self, str prompt)
None record_usage(self, dict usage)
"SessionCost" from_dict(cls, object d)
None add_sample(self, "float | None" cost_usd)
bool has_fired(self, float threshold)
None mark_threshold_fired(self, float threshold)
list thresholds_fired_usd
int total_completion_tokens
int prompt_cache_hit_tokens
int prompt_cache_miss_tokens
"TokenStats" from_dict(cls, dict d)
None add_cost_sample(self, "float | None" cost_usd)
None add_usage(self, dict usage)
None apply_session_metadata_fields("ChatSession" session, dict meta, *, bool pure=False)
list _validated_event_slugs(object raw)
str build_system_prompt(str base_instruction, list|None tool_schemas, *, str reference="compact")
None _reset_trust_before_exposure()
frozenset[str] _gitignored_relpaths(Path root)
str generate_project_tree(int max_depth=2, *, "list[str] | None" exclude_dirs=None, "list[str] | None" exclude_suffixes=None, "list[str] | None" stub_dirs=None, bool|None honor_gitignore=None, str|None work_dir=None)
None ensure_system_includes_tool_reference("ChatSession" session, list|None tool_schemas)
bool _migrate_legacy_system_prompt("ChatSession" session, list|None tool_schemas)