oc_chat
Loading...
Searching...
No Matches
session.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/session.py
4
5"""
6Session and token-stats data models.
7
8Purpose
9-------
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.
14
15Responsibilities
16----------------
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``).
30
31Diagnostics
32-----------
33Domain: OC_CHAT.MAIN
34Levels:
35 L2 — lifecycle events
36 L3 — semantic details
37 L4 — deep tracing
38
39Contracts
40---------
41- ``ChatSession`` is mutable and not thread-safe; one instance per
42 REPL.
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
46 raise.
47
48Dependencies
49------------
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)
69
70See Also
71--------
72oc_chat/docs/conventions/CONTRACTS.md §1.3 TokenStats, §1.4 ChatSession, §7 Session
73message dict shape — owners of the per-session state contracts.
74"""
75import json
76import subprocess
77import uuid
78from dataclasses import dataclass, field
79from pathlib import Path
80
81from oc_diagnostics import _dbg
82
83from oc_chat.core.cache_usage import usage_prompt_cache_hit_miss
84from oc_chat.core.config import (
85 DEFAULT_MAX_REACT_ITERATIONS,
86 DEFAULT_REASONING_EFFORT,
87 TOKEN_PROFILES,
88)
89# Plan 135 §3.5: git logic relocated to git_helpers.py (kept cohesive). The
90# private alias preserves the existing internal call site (project-tree cache key).
91from oc_chat.core.git_helpers import git_head_hash as _git_head_hash
92# Plan 135 §3.6/§3.3: shared policy text + compact tool map (no in-prompt schema dump).
93from oc_chat.core.policy import CANONICAL_POLICY, NO_TOOLS_POLICY, TOOLS_POLICY_PREAMBLE
94from oc_chat.core.tool_prompt import COMPACT_TOOL_MAP_SENTINEL, compact_tool_map
95
96
97def _default_model() -> str:
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).
101 """
102 func = "_default_model"
103 import os
104 from oc_chat.core.provider_plugin import registry
105 name = os.environ.get("OC_CHAT_PROVIDER", "deepseek")
106 try:
107 return registry.get(name).default_model
108 except (KeyError, AttributeError) as e:
109 # VALIDATION_ERROR — provider not yet registered, expected in early test setup
110 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
111 return ""
112
113# Plan 135 §4.3: the default system persona is the canonical Option C policy.
114# ``_DEFAULT_SYSTEM`` survives ONLY as this deliberate one-line alias for import
115# stability; every other reference was swapped to ``CANONICAL_POLICY`` (the §4.7
116# grep gate enforces this).
117_DEFAULT_SYSTEM = CANONICAL_POLICY
118
119# Appended to the system prompt when tool schemas are embedded (Phase 2 cache prefix).
120SESSION_TOOL_SCHEMA_SENTINEL = "<!--dc:embedded-tool-schemas-->"
121
122# Marks the user message that holds the per-session variable context
123# (project tree + AGENT_CONTEXT.md). Used by startup helpers to locate
124# and refresh the message on session load without touching conversation history.
125VARIABLE_CONTEXT_SENTINEL = "<!--oc:variable-context-->"
126
127
128# Header that opened the legacy embedded-schema block (pre-135). Migration locates
129# the block by THIS header (the JSON dump precedes the sentinel — §4.3), never by
130# the sentinel itself.
131_LEGACY_TOOL_BLOCK_HEADER = (
132 # OCT-LINT: disable=no_hardcoded_secrets reason="Config keys and style strings, not secrets"
133 "\n\n--\nOC Chat tool definitions (JSON Schema; duplicated from the API "
134 "tools parameter for cache stability):"
135)
136
137
139 base_instruction: str, tool_schemas: list | None, *, reference: str = "compact"
140) -> str:
141 """Compose ``messages[0]``: base policy + a conditional tool-use section.
142
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)``.
149 """
150 text = (base_instruction or "").rstrip()
151 if tool_schemas:
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}"
154
155
156def ensure_system_includes_tool_reference(session: "ChatSession", tool_schemas: list | None) -> None:
157 """Idempotently ensure ``messages[0]`` carries the tool reference.
158
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).
164 """
165 if not tool_schemas or not session.messages:
166 return
167 first = session.messages[0]
168 if first.get("role") != "system":
169 return
170 content = first.get("content") or ""
171 if COMPACT_TOOL_MAP_SENTINEL in content or SESSION_TOOL_SCHEMA_SENTINEL in content:
172 return
173 first["content"] = build_system_prompt(content, tool_schemas)
174 session.dirty = True
175
176
177def _migrate_legacy_system_prompt(session: "ChatSession", tool_schemas: list | None) -> bool:
178 """Convert a legacy embedded-schema ``messages[0]`` to the compact reference.
179
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.
187 """
188 func = "_migrate_legacy_system_prompt"
189 if not session.messages:
190 return False
191 first = session.messages[0]
192 if first.get("role") != "system":
193 return False
194 content = first.get("content") or ""
195 if SESSION_TOOL_SCHEMA_SENTINEL not in content:
196 return False
197 idx = content.rfind(_LEGACY_TOOL_BLOCK_HEADER)
198 if idx == -1:
199 _dbg("OC_CHAT.MAIN", func,
200 # OCT-LINT: disable=no_hardcoded_secrets reason="Config keys and style strings, not secrets"
201 "WARNING: legacy schema sentinel present but block header not found; "
202 "leaving messages[0] untouched (conservative-fail)", 1, override=True)
203 return False
204 base = content[:idx].rstrip()
205 first["content"] = build_system_prompt(base, tool_schemas)
206 session.dirty = True
207 _dbg("OC_CHAT.MAIN", func, "migrated legacy embedded-schema prompt to compact reference", 2)
208 return True
209
210
211def _auto_name() -> str:
212 """Generate a default session name from random words.
213
214 Returns kebab-case names like ``sunny-weaving-honey``.
215 Checks existing sessions to avoid collisions.
216 """
217 from oc_chat.core.wordgen import random_name
218 from oc_chat.core.persistence import list_sessions
219 existing = {s["name"] for s in list_sessions()}
220 return random_name(exists_fn=lambda n: n in existing)
221
222
223@dataclass
225 """Session-scoped USD spend, folded honestly (CAPS-01, plan 237 P2).
226
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.
232
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.
239
240 ``thresholds_fired_usd`` records which checkpoints this session has already
241 announced, so a reload resumes rather than re-announcing them.
242 """
243
244 priced_usd: float = 0.0
245 unpriced_samples: int = 0
246 thresholds_fired_usd: list = field(default_factory=list)
247
248 def add_sample(self, cost_usd: "float | None") -> None:
249 """Fold ONE priced sample, or count one unpriceable sample."""
250 if cost_usd is None:
251 self.unpriced_samples += 1
252 return
253 self.priced_usd += float(cost_usd)
254
255 def mark_threshold_fired(self, threshold: float) -> None:
256 """Record that *threshold* has been announced for this session."""
257 if threshold not in self.thresholds_fired_usd:
258 self.thresholds_fired_usd.append(float(threshold))
259
260 def has_fired(self, threshold: float) -> bool:
261 """True when *threshold* was already announced this session."""
262 return any(abs(float(t) - float(threshold)) < 1e-9
263 for t in self.thresholds_fired_usd)
264
265 def to_dict(self) -> dict:
266 """Return a JSON-serialisable dict of the session cost fold."""
267 return {
268 "priced_usd": float(self.priced_usd),
269 "unpriced_samples": int(self.unpriced_samples),
270 "thresholds_fired_usd": [float(t) for t in self.thresholds_fired_usd],
271 }
272
273 @classmethod
274 def from_dict(cls, d: object) -> "SessionCost":
275 """Reconstruct from a persisted dict; anything unusable yields a fresh zero.
276
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):
282 return d
283 if not isinstance(d, dict):
284 return cls()
285 try:
286 fired = [float(t) for t in (d.get("thresholds_fired_usd") or [])]
287 except (TypeError, ValueError):
288 fired = []
289 try:
290 priced = float(d.get("priced_usd", 0.0) or 0.0)
291 except (TypeError, ValueError):
292 priced = 0.0
293 try:
294 unpriced = int(d.get("unpriced_samples", 0) or 0)
295 except (TypeError, ValueError):
296 unpriced = 0
297 return cls(priced_usd=max(0.0, priced),
298 unpriced_samples=max(0, unpriced),
299 thresholds_fired_usd=fired)
300
301
302@dataclass
304 """Per-session aggregated token usage.
305
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`.
309 """
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
317 # CAPS-01 (plan 237 P2): the session USD fold rides HERE rather than as a
318 # sibling field on ChatSession because ``TokenStats.to_dict``/``from_dict``
319 # is already the one persistence seam every restore path goes through
320 # (startup, the 152-b transition seam, batch_runner, list_sessions). A
321 # sibling would have needed four call-site edits, three of them in modules
322 # this plan's isolation table does not open.
323 cost: SessionCost = field(default_factory=SessionCost)
324
325 def add_cost_sample(self, cost_usd: "float | None") -> None:
326 """Fold one provider usage sample's USD cost (``None`` = unpriceable)."""
327 self.cost.add_sample(cost_usd)
328
329 def add_usage(self, usage: dict) -> None:
330 """Accumulate token usage from a single API response."""
331 self.total_prompt_tokens += usage.get("prompt_tokens", 0)
332 self.total_completion_tokens += usage.get("completion_tokens", 0)
333 self.total_tokens += usage.get("total_tokens", 0)
334 hit, miss = usage_prompt_cache_hit_miss(usage)
335 self.prompt_cache_hit_tokens += hit
336 self.prompt_cache_miss_tokens += miss
337
338 def add_message(self) -> None:
339 """Increment the user/assistant message counter."""
340 self.message_count += 1
341
342 def add_tool_call(self) -> None:
343 """Increment the tool-call counter."""
344 self.tool_call_count += 1
345
346 def to_dict(self) -> dict:
347 """Return a JSON-serialisable dict of the stats."""
348 return {
349 "message_count": self.message_count,
350 "tool_call_count": self.tool_call_count,
351 "total_prompt_tokens": self.total_prompt_tokens,
352 "total_completion_tokens": self.total_completion_tokens,
353 "total_tokens": self.total_tokens,
354 "prompt_cache_hit_tokens": self.prompt_cache_hit_tokens,
355 "prompt_cache_miss_tokens": self.prompt_cache_miss_tokens,
356 # CAPS-01: additive key. A pre-237 reader ignores it; a 237 reader
357 # given a pre-237 dict starts the fold at zero (see SessionCost).
358 "session_cost": self.cost.to_dict(),
359 }
360
361 @classmethod
362 def from_dict(cls, d: dict) -> "TokenStats":
363 """Reconstruct a :class:`TokenStats` from a dict (e.g. loaded JSON).
364
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.
367 """
368 if isinstance(d, cls):
369 return d
370 return 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")),
379 )
380
381
382@dataclass
384 """Mutable state for a single chat conversation.
385
386 Tracks the message list, model selection, reasoning effort, token
387 stats, associated chat logs, and a dirty flag for auto-save logic.
388 """
389
390 name: str = field(default_factory=_auto_name)
391 # M2.5 — stable per-session identity. Minted once at construction, persisted
392 # in meta.json, and is the canonical source for ``TurnEvent.session_id`` on
393 # every M2 event (retires the M2.3 ``_session_id_for`` shim). Pre-M2.5
394 # sessions mint a fresh id on first load under M2.5 and keep it thereafter.
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}
400 ])
401 dirty: bool = False
402 filepath: str | None = None
403 stats: TokenStats = field(default_factory=TokenStats)
404 chat_logs: list = field(default_factory=list) # informational: filename of current chat log
405 load_failed: bool = False # set when a previous load_chat_log raised — guards against overwrite
406 ui_prefs: dict = field(default_factory=dict)
407 # OCT-LINT: disable=no_hardcoded_secrets reason="Config keys and style strings, not secrets"
408 """Per-session UI preferences (theme, show_reasoning, show_stats, …).
409
410 Persisted under the ``ui`` key in ``meta.json``. See ``/ui`` command.
411 Phase 0 stores these but only :class:`RichPresenter` (Phase 1+) will
412 visibly honour them.
413 """
414
415 # Phase 3 — largest ``prompt_tokens`` seen on the last completed chat turn (API truth).
416 last_turn_max_prompt_tokens: int = 0
417 # One-time soft nudge after many user turns (session rollover UX).
418 rollover_hint_shown: bool = False
419
420 # Phase 4 — token budget profile for completion scaling (``/budget``).
421 # OCT-LINT: disable=no_hardcoded_secrets reason="Config keys and style strings, not secrets"
422 token_profile: str = 'balanced'
423 # Optional override for ReAct max iterations (``/react N``); ``None`` = use default.
424 max_react_iterations: int | None = None
425
426 # Phase J — id of the plan this session is currently focused on.
427 # Value = filename stem without suffix or ``.md`` (e.g. ``032-sunny-weaving-honey``).
428 # ``None`` when no plan is selected. Persisted in ``meta.json``.
429 active_plan_id: str | None = None
430
431 # Phase O4 — per-session generation options (temperature, top_k, etc.).
432 # Stored as a plain dict to decouple from the Pydantic model at serialisation time.
433 generation_options: dict = field(default_factory=dict)
434
435 # Phase O2 — name of the EndpointRegistry entry the session last
436 # used (e.g. ``"laptop-gpu"``). Empty string means "use the
437 # registry's default active entry on session load".
438 endpoint_name: str = ""
439
440 # Per-iteration stats file handle (--stats-file PATH). Opened by main,
441 # used by the ReAct loop, closed on session save/exit. Not serialised.
442 # Gated by --no-iteration-stats CLI flag.
443 show_iteration_stats: bool = True
444 iteration_stats_file: object = field(default=None, repr=False, compare=False)
445 # Recent per-turn cache snapshots for ``/cache`` (max length trimmed on append).
446 cache_turn_snapshots: list = field(default_factory=list)
447
448 # M0.4 — optional per-session filesystem sandbox override.
449 sandbox_root_override: str | None = None
450
451 # Plan 152 D8 — event-dir rename continuity. Event dirs key on the immutable
452 # ``session_id``; these are the name-SLUGS this session wrote under before
453 # earlier renames, so ``/trace`` and ``/packet`` can still reach that
454 # history. Ordered (multi-epoch A -> B -> C renames are real), validated
455 # against the canonical slug grammar, and deduplicated. Written ONLY by the
456 # rename transition's own commit — event-dir discovery is read-only.
457 legacy_event_slugs: list = field(default_factory=list)
458
459 # Plan 152 D8/R11-09 — set on the OLD record when a rename supersedes it, so
460 # the on-disk same-session_id sibling every rename creates is deterministically
461 # non-canonical from birth instead of being adjudicated by load order.
462 superseded_by: str | None = None
463
464 def add_user(self, text: str) -> None:
465 """Append a user message and mark the session dirty."""
466 func = "add_user"
467 _dbg("OC_CHAT.MAIN", func, f"chars={len(text)} session={self.name}", 3)
468 self.messages.append({"role": "user", "content": text})
469 self.dirty = True
470
471 def add_assistant(self, text: str) -> None:
472 """Append an assistant message, mark dirty, and bump the message count."""
473 self.messages.append({"role": "assistant", "content": text})
474 self.dirty = True
475 self.stats.add_message()
476
477 def add_assistant_with_tool_calls(self, msg: dict) -> None:
478 """Append an assistant message that contains ``tool_calls`` (added after streaming)."""
479 self.messages.append(msg)
480 self.dirty = True
481 self.stats.add_message()
482
483 def add_tool_result(self, result: dict) -> None:
484 """Append a tool-result message, mark dirty, and count the tool call."""
485 self.messages.append(result)
486 self.dirty = True
487 self.stats.add_tool_call()
488
489 def record_usage(self, usage: dict) -> None:
490 """Record token usage from an API response chunk."""
491 self.stats.add_usage(usage)
492
493 def clear(self) -> None:
494 """Reset the conversation, keeping only the system prompt."""
495 self.messages = [{"role": "system", "content": self.messages[0]["content"]}]
497 self.rollover_hint_shown = False
498 self.cache_turn_snapshots = []
499 self.max_react_iterations = None
500 self.dirty = True
501 # M8.0 F67: clearing the conversation also drops session-trusted external
502 # sources (an in-place /session clear keeps the same session object, so the
503 # per-turn session-change reset would not catch it).
504 from oc_chat.core.external_content_gate import reset_session_trust
505 reset_session_trust()
506 # Plan 235 (SHELL-02): the session-long Tier-2 python grant is session-
507 # scoped for the same reason and drops at the same seam.
508 from oc_chat.core.tier2_session_grant import revoke_session_grants
509 revoke_session_grants()
510 # Plan 237 (CAPS-01): a cleared conversation is a fresh session, so its
511 # money starts at zero and every checkpoint may fire again. BOTH halves
512 # drop here — the persisted fold (this object) and the process-scoped
513 # "don't ask again" latches, which are the exact analogue of the F67
514 # trust set above.
515 from oc_chat.core.session_checkpoints import reset_session_checkpoints
516 self.stats.cost = SessionCost()
517 reset_session_checkpoints()
518 # Plan 283 (SHELL-04): the confirmation mode and the per-program answers
519 # this session was told to remember are the same kind of state as the
520 # latches above — a standing answer nobody granted twice must not survive
521 # into what is, from here on, a different conversation.
522 from oc_chat.core.session_mode import reset_session_mode
523 reset_session_mode()
524
525 def set_system_prompt(self, prompt: str) -> None:
526 """Replace the system prompt and mark the session dirty."""
527 self.messages[0] = {"role": "system", "content": prompt}
528 self.dirty = True
529
530 def set_model(self, new_model: str) -> None:
531 """Switch the active model through the single choke-point (plan 135 §3.5).
532
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).
542 """
543 if new_model == self.model:
544 return
545 self.model = new_model
546 self.dirty = True
547 # Deferred import: startup imports session at module load (avoid a cycle).
548 from oc_chat.core.startup import refresh_variable_context
549
550 refresh_variable_context(self)
551
552 def mark_clean(self) -> None:
553 """Clear the dirty flag (e.g. after a successful save)."""
554 self.dirty = False
555
556 @property
557 def system_prompt(self) -> str:
558 """Return the current system prompt text."""
559 return self.messages[0]["content"]
560
561 @property
562 def message_count(self) -> int:
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"))
565
566 def react_cap(self) -> int:
567 """Effective ReAct iteration cap (``/react`` override or default).
568
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`.
572 """
573 v = self.max_react_iterations
574 if isinstance(v, int) and 1 <= v <= 99:
575 return v
576 return DEFAULT_MAX_REACT_ITERATIONS
577
578 def add_chat_log(self, filename: str) -> None:
579 """Record a chat log filename associated with this session."""
580 if filename not in self.chat_logs:
581 self.chat_logs.append(filename)
582 self.dirty = True
583
584
585
586# ── Project tree cache (keyed by git HEAD hash) ─────────────────────────
587
588_GEN_TREE_CACHE: tuple[str, str] | None = None
589
590
591def _gitignored_relpaths(root: Path) -> frozenset[str]:
592 """Relative paths git ignores under *root* (best-effort; '' on any error).
593
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."""
598 try:
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,
603 )
604 except (OSError, subprocess.SubprocessError):
605 return frozenset()
606 return frozenset(
607 ln.strip().rstrip("/").replace("\\", "/")
608 for ln in out.stdout.splitlines() if ln.strip()
609 )
610
611
613 max_depth: int = 2,
614 *,
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,
620) -> str:
621 """Build a compact project tree for injection into the system prompt.
622
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.
630
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).
633 """
634 global _GEN_TREE_CACHE
635 from oc_chat.core.settings import DEFAULT_SETTINGS
636 _pt = DEFAULT_SETTINGS["project_tree"]
637
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
644
645 root = Path.cwd().resolve()
646 gitignored = _gitignored_relpaths(root) if honor_gi else frozenset()
647
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]
653
654 lines: list[str] = []
655 lines.append("## Project Map (max depth {})".format(max_depth))
656
657 def _walk(parent: Path, rel_parent: str, depth: int) -> None:
658 if depth > max_depth:
659 return
660 try:
661 entries = sorted(parent.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
662 except PermissionError:
663 return
664 for entry in entries:
665 name = entry.name
666 if name.startswith("."):
667 continue
668 if name.endswith(excl_suffixes):
669 continue
670 rel = f"{rel_parent}/{name}" if rel_parent else name
671 if rel in gitignored:
672 continue
673 if entry.is_dir():
674 if name in excl_dirs or (wdir and name == wdir):
675 continue
676 if name in stubs:
677 lines.append(
678 " " * depth + "--- " + name
679 + f'/ (contents omitted; use list_dir("{name}/") to enumerate)'
680 )
681 continue
682 lines.append(" " * depth + "--- " + name + "/")
683 if depth < max_depth:
684 _walk(entry, name, depth + 1)
685 else:
686 lines.append(" " * depth + "--- " + name)
687
688 _walk(root, "", 0)
689 tree = "\n".join(lines)
690 _GEN_TREE_CACHE = (cache_key, tree)
691 return tree
692
693
695 """Clear F67 external-content trust BEFORE a regenerated identity is exposed.
696
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.
701 """
702 func = "_reset_trust_before_exposure"
703 try:
704 from oc_chat.core.external_content_gate import reset_session_trust
705 reset_session_trust()
706 # Plan 235 (SHELL-02): the Tier-2 python grant belonged to the identity we
707 # could not establish either, so it drops before the new one is exposed.
708 from oc_chat.core.tier2_session_grant import revoke_session_grants
709 revoke_session_grants()
710 # Plan 237 (CAPS-01): the "don't ask again this session" latches belonged
711 # to that identity too, so they drop here as well. The PERSISTED cost fold
712 # deliberately does NOT reset at this seam: every caller restores
713 # ``session.stats`` from the record's own meta.json BEFORE reaching here
714 # (startup._load_session_by_meta, session_transition), so zeroing it here
715 # would erase the total the record itself carries and silently hand a
716 # resumed session a fresh $50 of headroom. It resets in
717 # ``ChatSession.clear``, which is where a session genuinely starts over.
718 from oc_chat.core.session_checkpoints import reset_session_checkpoints
719 reset_session_checkpoints()
720 # Plan 283 (SHELL-04): the Auto mode and the remembered answers belonged
721 # to the identity we could not establish either, so they drop before the
722 # new one is exposed.
723 from oc_chat.core.session_mode import reset_session_mode
724 reset_session_mode()
725 except Exception as exc: # pragma: no cover - defensive
726 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: trust reset failed: {exc}", 2)
727
728
729def _validated_event_slugs(raw: object) -> list:
730 """Return the persisted rename aliases that pass the canonical slug grammar.
731
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.
738
739 Invalid entries are dropped with a COUNTED diagnostic that never echoes the
740 value.
741 """
742 func = "_validated_event_slugs"
743 if not isinstance(raw, (list, tuple)):
744 return []
745 from oc_chat.core.event_bootstrap import _valid_alias
746 out: list = []
747 dropped = 0
748 for item in raw:
749 if _valid_alias(item) and item not in out:
750 out.append(item)
751 elif not _valid_alias(item):
752 dropped += 1
753 if dropped:
754 _dbg("OC_CHAT.MAIN", func,
755 f"VALIDATION_ERROR: dropped {dropped} invalid legacy_event_slugs entr(ies)", 2)
756 return out
757
758
760 session: "ChatSession", meta: dict, *, pure: bool = False
761) -> None:
762 """Restore persisted session fields from a ``meta.json`` dict (Phase 3–4).
763
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:
768
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.
774
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).
780 """
781 func = "apply_session_metadata_fields"
782 # M2.5 — restore the persisted session_id; keep the construction-minted UUID
783 # when the meta predates M2.5 (back-compat) so the id stays stable thereafter.
784 #
785 # Plan 152 D9 — VALIDATE AT THE PERSISTENCE BOUNDARY. This used to accept any
786 # non-empty string, so an arbitrary persisted value became the F67 external
787 # -content trust key. A trust key derived from an unvalidated string is not a
788 # complete gate, and every neighbouring field here is already validated.
789 #
790 # On a malformed OR missing id the session ends up with a fresh canonical
791 # uuid4 rather than the persisted value — never trusted as-is. Rejecting the
792 # load instead would make legacy pre-id artifacts unloadable (anti-lock-in:
793 # the user owns their sessions).
794 #
795 # "Fresh" does NOT mean "always mint a new one". ``ChatSession.__init__``
796 # already minted a canonical uuid4 that has never been on disk and has never
797 # been trusted, so when the persisted id is unusable the minted one already
798 # satisfies the property — keeping it is what the pre-existing MISSING-id
799 # back-compat contract guarantees, and minting a second uuid4 would break
800 # that for no security gain. A new one is minted only if the in-memory id is
801 # itself not canonical.
802 from oc_chat.core.external_content_gate import is_canonical_session_id
803 sid = meta.get("session_id")
804 if is_canonical_session_id(sid):
805 session.session_id = sid # type: ignore[assignment]
806 else:
807 if not is_canonical_session_id(getattr(session, "session_id", None)):
808 session.session_id = str(uuid.uuid4())
809 if sid is not None:
810 _dbg("OC_CHAT.MAIN", func,
811 # OCT-LINT: disable=no_hardcoded_secrets reason="Config keys and style strings, not secrets"
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)
814 # The persisted identity could not be established, so whatever trust this
815 # process accumulated belonged to a different identity. Under ``pure`` the
816 # seam owns rotation (keyed on the candidate's final id) and installs it
817 # at commit BEFORE identity publication — so the live trust set must NOT
818 # be cleared here, where the candidate is not yet the active session.
819 if not pure:
821
822 # Plan 152 D8/R11-09 — the ordered, validated, deduplicated rename-alias
823 # collection. Re-validated AT USE by ``event_read_dirs`` too, because
824 # ``load_session_metadata`` returns the RAW dict on a validation error, which
825 # makes model-level validation alone bypassable.
826 session.legacy_event_slugs = _validated_event_slugs(meta.get("legacy_event_slugs"))
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
842 # AC-V09-5 (M8.0 WU-1): validate the persisted override at apply-time and fail
843 # closed to the launch-time root if it does not resolve to an existing
844 # directory -- mirroring the existence check ``/sandbox set`` already enforces,
845 # so a tampered ``meta.json`` cannot relocate the write sandbox to a planted /
846 # non-existent path on resume (M8 F48/F69). Authenticity (operator-signed vs
847 # injection-planted-but-valid) is the M8.3 persistence keyed MAC (F48a); this
848 # is the deterministic floor.
849 if candidate is not None:
850 try:
851 valid = Path(candidate).expanduser().resolve(strict=False).is_dir()
852 except (OSError, ValueError):
853 valid = False
854 if not valid:
855 _dbg(
856 "OC_CHAT.MAIN",
857 func,
858 f"rejected sandbox_root_override {candidate!r} (not an existing "
859 f"directory); failing closed to launch-time root",
860 2,
861 )
862 candidate = None
863 if candidate is not None:
864 # WU-7 / G-1: under guarded/isolated, fail closed if the persisted override
865 # would put the write sandbox in overlap with a harness importable root
866 # (write->import in-process ACE / F74 MAC preimage). The launch-time root
867 # already passed this at startup; this guards the resume apply-path (F48/F57).
868 try:
869 from oc_chat.core.risk_profile import resolve_risk_profile
870 from oc_chat.core.deployment_shape import check_deployment_shape, DeploymentShapeError
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)
876 candidate = None
877 session.sandbox_root_override = candidate
878 # Under ``pure`` the process-global is a LIVE effect the seam owns: it
879 # resolves the override in PREPARE (off ``session.sandbox_root_override``,
880 # just assigned above) and installs it inside the commit critical section.
881 # Touching the global here would mutate live state from a non-active
882 # candidate — exactly what the pure hydration flag exists to prevent.
883 if not pure:
884 try:
885 from oc_chat.tools._sandbox import set_session_sandbox_root_override
886 set_session_sandbox_root_override(session.sandbox_root_override)
887 except Exception as exc:
888 _dbg(
889 "OC_CHAT.MAIN",
890 func,
891 # OCT-LINT: disable=no_hardcoded_secrets reason="Config keys and style strings, not secrets"
892 f"VALIDATION_ERROR: sandbox override sync failed: {exc}",
893 3,
894 )
None add_user(self, str text)
Definition session.py:464
None add_assistant_with_tool_calls(self, dict msg)
Definition session.py:477
None add_tool_result(self, dict result)
Definition session.py:483
None add_assistant(self, str text)
Definition session.py:471
None add_chat_log(self, str filename)
Definition session.py:578
None set_model(self, str new_model)
Definition session.py:530
None set_system_prompt(self, str prompt)
Definition session.py:525
None record_usage(self, dict usage)
Definition session.py:489
"SessionCost" from_dict(cls, object d)
Definition session.py:274
None add_sample(self, "float | None" cost_usd)
Definition session.py:248
bool has_fired(self, float threshold)
Definition session.py:260
None mark_threshold_fired(self, float threshold)
Definition session.py:255
"TokenStats" from_dict(cls, dict d)
Definition session.py:362
None add_cost_sample(self, "float | None" cost_usd)
Definition session.py:325
None add_usage(self, dict usage)
Definition session.py:329
None apply_session_metadata_fields("ChatSession" session, dict meta, *, bool pure=False)
Definition session.py:761
list _validated_event_slugs(object raw)
Definition session.py:729
str build_system_prompt(str base_instruction, list|None tool_schemas, *, str reference="compact")
Definition session.py:140
None _reset_trust_before_exposure()
Definition session.py:694
frozenset[str] _gitignored_relpaths(Path root)
Definition session.py:591
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)
Definition session.py:620
None ensure_system_includes_tool_reference("ChatSession" session, list|None tool_schemas)
Definition session.py:156
bool _migrate_legacy_system_prompt("ChatSession" session, list|None tool_schemas)
Definition session.py:177