7Central OC_Chat settings — the single source of truth for tunable behaviour.
11Loads ``.oc_chat/oc_chat-settings.json`` (committed project defaults) deep-merged
12over the built-in defaults, then deep-merged with an optional gitignored
13``oc_chat-settings.local.json`` (per-machine overrides) — the same split as
14``.claude/settings.json`` + ``settings.local.json``. Settings migrate here
15opportunistically; new settings live here, not as scattered constants.
17The headline consumers (plan 133): the system-prompt composition (which named
18*mode* is active, and which of persona / tools / project-tree / extra files it
19injects) and the project-tree generator (exclude dirs/suffixes, honor-gitignore,
24- :data:`DEFAULT_SETTINGS` — the built-in schema + safe defaults (mode
25 ``standard`` = persona + tools, no tree, no files).
26- :class:`OCChatSettings` — the resolved, validated settings + any load-time
27 warnings + helpers (:meth:`mode_profile`, :meth:`resolve_mode`).
28- :func:`load_settings` — read + deep-merge + validate; never raises on a
29 missing/malformed file (falls back to defaults with a warning).
31Forward compatibility (lenient reader, strict writer)
32-----------------------------------------------------
33Unknown top-level keys, unknown keys inside a mode profile, and unknown
34``project_tree`` keys are recorded as a warning and otherwise ignored, so a
35newer settings file (new mode / new toggle) never breaks an older OC_Chat. An
36active ``mode`` absent from ``modes`` warns and falls back to ``standard``.
37Warnings are returned on :attr:`OCChatSettings.warnings`; the caller surfaces
38them (terminal-visible) once a presenter exists.
44 L2 - lifecycle events (load entry/exit)
45 L3 - semantic details (merge sources, fallbacks)
46 L4 - deep tracing (per-key unknown-warning emission)
50- :func:`load_settings` is total: any IO/JSON error degrades to defaults + a
51 warning; it never raises.
52- :class:`OCChatSettings` is read-only by convention after load.
53- Deep-merge order is defaults < committed < local; ``modes`` and
54 ``project_tree`` merge per-key (a partial override does not drop sibling keys).
58- oc_diagnostics (structured debug logging via _dbg)
59- oc_chat.core.defaults (shipped built-in values that the settings files merge over)
60- oc_chat.core._paths (lazy; find_workspace_root fallback for the settings file location)
61- oc_chat.tools.filesystem (lazy; read_prompt_file for the hardened system_prompt_file read)
63from __future__
import annotations
66from copy
import deepcopy
67from dataclasses
import dataclass, field
68from pathlib
import Path
71from oc_diagnostics
import _dbg
79_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
83SETTINGS_FILENAME =
".oc_chat/oc_chat-settings.json"
85LOCAL_SETTINGS_FILENAME =
".oc_chat/oc_chat-settings.local.json"
89DEFAULT_SETTINGS: dict[str, Any] = {
93 "include_persona": shipped(
"mode_standard_include_persona"),
94 "include_tools": shipped(
"mode_standard_include_tools"),
96 "include_env": shipped(
"mode_standard_include_env"),
97 "env_fields": [
"date",
"timezone"],
98 "include_project_tree": shipped(
"mode_standard_include_project_tree"),
102 "include_persona": shipped(
"mode_option_c_include_persona"),
103 "include_tools": shipped(
"mode_option_c_include_tools"),
105 "include_env": shipped(
"mode_option_c_include_env"),
106 "env_fields": [
"date",
"timezone",
"cwd",
"branch",
"git",
"model",
"mode"],
107 "include_project_tree": shipped(
"mode_option_c_include_project_tree"),
108 "include_files": [
"AGENT_CONTEXT.md"],
114 "system_prompt_file":
None,
118 "verbose": shipped(
"settings_verbose"),
123 "max_tool_calls_per_turn": shipped(
"tool_calls_per_turn"),
126 "stall_repeat_calls":
None,
127 "stall_error_streak":
None,
128 "stall_no_progress_iterations":
None,
132 "max_output_tokens": shipped(
"settings_max_output_tokens"),
137 "session_cost_hard_cap_usd": shipped(
"settings_session_cost_hard_cap_usd"),
142 "session_cost_thresholds_usd": [],
144 "max_depth": shipped(
"project_tree_max_depth"),
146 "__pycache__",
"logs",
"memory_store",
"_backup",
"nulldir",
149 "exclude_suffixes": [
".bk",
".bak",
"~"],
152 "stub_dirs": [
"sessions",
"plans"],
153 "honor_gitignore": shipped(
"project_tree_honor_gitignore"),
162 "enabled": shipped(
"verification_enabled"),
163 "mutating_tools": [
"write_file",
"oct_new"],
164 "lint": shipped(
"verification_lint"),
165 "typecheck": shipped(
"verification_typecheck"),
166 "tests": shipped(
"verification_tests"),
167 "profile":
"default",
168 "timeout_s": shipped(
"verification_timeout_s"),
169 "max_files": shipped(
"verification_max_files"),
170 "max_findings": shipped(
"verification_max_findings"),
171 "jobs": shipped(
"verification_jobs"),
172 "max_runs_per_turn": shipped(
"verification_max_runs_per_turn"),
173 "baseline": shipped(
"verification_baseline"),
174 "baseline_timeout_s": shipped(
"verification_baseline_timeout_s"),
175 "baseline_scope":
None,
176 "subloop_readonly":
None,
185_MODE_DEFAULTS: dict[str, Any] = {
186 "include_persona":
True,
187 "include_tools":
True,
189 "include_env":
False,
191 "include_project_tree":
False,
204 return frozenset(DEFAULT_SETTINGS)
208 return frozenset(_MODE_DEFAULTS)
212 return frozenset(DEFAULT_SETTINGS[
"project_tree"])
217 """Resolved, validated settings. Read-only by convention after load."""
220 modes: dict[str, dict[str, Any]]
222 system_prompt_file: str |
None
223 project_tree: dict[str, Any]
224 verbose: bool = shipped(
"settings_verbose")
227 max_tool_calls_per_turn: int = shipped(
"tool_calls_per_turn")
229 stall_repeat_calls: int |
None =
None
230 stall_error_streak: int |
None =
None
231 stall_no_progress_iterations: int |
None =
None
233 max_output_tokens: int = shipped(
"settings_max_output_tokens")
236 session_cost_hard_cap_usd: int = shipped(
"settings_session_cost_hard_cap_usd")
237 session_cost_thresholds_usd: list[float] = field(default_factory=list)
240 verification: dict[str, Any] = field(default_factory=dict)
241 warnings: list[str] = field(default_factory=list)
244 """Pick the effective mode: CLI override > file `mode` > 'standard'.
246 An unknown name (from either source) warns and falls back to a defined
247 mode ('standard' if present, else the first defined mode)."""
248 chosen = (cli_mode
or self.mode
or "standard").strip()
249 if chosen
in self.
modes:
251 fallback =
"standard" if "standard" in self.
modes else next(iter(self.
modes),
"standard")
253 f
"unknown mode {chosen!r} (not in modes: {sorted(self.modes)}); using {fallback!r}"
258 """Composition toggles for *name*, backfilled from the neutral mode
259 defaults so a partially-specified mode still answers every toggle.
261 Backfill source is :data:`_MODE_DEFAULTS` (the §3.2 schema), not the
262 ``standard`` mode — so ``standard`` carries no privileged status and a
263 new mode that omits a key gets the minimal/off default, not standard's.
265 base = deepcopy(_MODE_DEFAULTS)
266 base.update(self.
modes.get(name, {}))
270 """Return and clear pending warnings (emit-once across call sites)."""
276def _read_json(path: Path, warnings: list[str]) -> dict[str, Any]:
277 """Best-effort JSON read; a malformed/unreadable file warns and yields {}."""
279 text = path.read_text(encoding=
"utf-8")
280 except (OSError, UnicodeDecodeError):
283 data = json.loads(text)
284 except json.JSONDecodeError
as exc:
285 warnings.append(f
"{path.name}: invalid JSON ({exc}); ignored")
287 if not isinstance(data, dict):
288 warnings.append(f
"{path.name}: top-level value must be an object; ignored")
293def _deep_merge(base: dict[str, Any], over: dict[str, Any]) -> dict[str, Any]:
294 """Recursively merge *over* onto *base* (dicts merge per-key; others replace)."""
296 for k, v
in over.items():
297 if isinstance(v, dict)
and isinstance(out.get(k), dict):
305 """Coerce a settings value to ``int``, degrading to *fallback* (plan 237).
307 ``load_settings`` is total by contract, so a hostile or fat-fingered value
308 must not raise. Both CAPS-01 keys are lower-only requests, and the fallback
309 for both is "no request" — the bounded shipped value, never "unbounded"."""
312 except (TypeError, ValueError):
317 raw: Any, *, key: str, warnings: list[str],
319 """Read a positive integer request; tolerate invalid values as absent."""
324 except (TypeError, ValueError):
326 if isinstance(raw, bool)
or value <= 0:
328 f
"setting {key!r} must be a positive integer; ignoring {raw!r}"
335 """Coerce a settings value to a list of floats, dropping what will not parse."""
336 if not isinstance(raw, (list, tuple)):
338 out: list[float] = []
341 out.append(float(item))
342 except (TypeError, ValueError):
348 """Record (but tolerate) keys not in the known schema — forward-compat."""
353 if k
not in known_top:
354 warnings.append(f
"unknown setting {k!r} ignored (forward-compat)")
355 for mode_name, profile
in (raw.get(
"modes")
or {}).items():
356 if isinstance(profile, dict):
358 if k
not in known_mode:
360 f
"unknown key {k!r} in mode {mode_name!r} ignored (forward-compat)"
362 tree = raw.get(
"project_tree")
363 if isinstance(tree, dict):
365 if k
not in known_tree:
367 f
"unknown project_tree key {k!r} ignored (forward-compat)"
371def load_settings(workspace_root: Path | str |
None =
None) -> OCChatSettings:
372 """Load + deep-merge + validate settings. Never raises (degrades to defaults).
374 Merge order: built-in defaults < committed ``oc_chat-settings.json`` <
375 gitignored ``oc_chat-settings.local.json``. Unknown keys in the user files
376 are warned and ignored (forward-compat); the merged result still validates
377 against the known schema."""
378 func =
"load_settings"
379 warnings: list[str] = []
381 if workspace_root
is None:
390 pkg_parent = Path(__file__).resolve().parent.parent.parent
391 if (pkg_parent / SETTINGS_FILENAME).is_file():
396 root = find_workspace_root()
397 except (FileNotFoundError, Exception):
400 root = Path(workspace_root)
402 committed =
_read_json(root / SETTINGS_FILENAME, warnings)
403 local =
_read_json(root / LOCAL_SETTINGS_FILENAME, warnings)
413 mode=str(merged.get(
"mode")
or "standard"),
414 modes={k: dict(v)
for k, v
in (merged.get(
"modes")
or {}).items()
if isinstance(v, dict)},
415 system_prompt=str(merged.get(
"system_prompt")
or ""),
416 system_prompt_file=(merged.get(
"system_prompt_file")
or None),
417 project_tree=dict(merged.get(
"project_tree")
or {}),
418 verbose=bool(merged.get(
"verbose", shipped(
"settings_verbose"))),
419 max_tool_calls_per_turn=int(
420 merged.get(
"max_tool_calls_per_turn", shipped(
"tool_calls_per_turn"))),
422 merged.get(
"stall_repeat_calls"),
423 key=
"stall_repeat_calls",
427 merged.get(
"stall_error_streak"),
428 key=
"stall_error_streak",
432 merged.get(
"stall_no_progress_iterations"),
433 key=
"stall_no_progress_iterations",
436 max_output_tokens=int(
437 merged.get(
"max_output_tokens", shipped(
"settings_max_output_tokens"))),
441 session_cost_hard_cap_usd=
_as_int(
442 merged.get(
"session_cost_hard_cap_usd"),
443 shipped(
"settings_session_cost_hard_cap_usd")),
445 merged.get(
"session_cost_thresholds_usd")),
446 verification=deepcopy(merged.get(
"verification")
or {}),
449 _dbg(
"OC_CHAT.MAIN", func,
450 f
"loaded mode={settings.mode} modes={sorted(settings.modes)} "
451 f
"committed={bool(committed)} local={bool(local)} warnings={len(warnings)}", 2)
456 settings: OCChatSettings, *, default: str, workspace_root: Path | str |
None =
None,
458 """Pick the persona text: non-empty inline `system_prompt` > readable
459 `system_prompt_file` > built-in *default*. If both inline and file are set,
460 warn and use inline. A set-but-unreadable file warns and falls back."""
461 if settings.system_prompt.strip():
462 if settings.system_prompt_file:
463 settings.warnings.append(
464 "both system_prompt and system_prompt_file set; using inline system_prompt"
466 return settings.system_prompt
467 spf = settings.system_prompt_file
477 return read_prompt_file(spf)
478 except PermissionError
as exc:
479 settings.warnings.append(
480 f
"system_prompt_file {spf!r} refused ({exc}); using built-in default"
482 except (OSError, UnicodeDecodeError):
483 settings.warnings.append(
484 f
"system_prompt_file {spf!r} empty or unreadable; using built-in default"
492_RUNTIME: OCChatSettings |
None =
None
493_RUNTIME_MODE: str =
"standard"
497 cli_mode: str |
None =
None, *, workspace_root: Path | str |
None =
None,
499 """Resolve + cache the process settings and effective mode. Call once at
500 launch (after CLI parse). ``cli_mode`` overrides the file's ``mode``."""
501 func =
"init_runtime_settings"
502 global _RUNTIME, _RUNTIME_MODE
504 _RUNTIME_MODE = _RUNTIME.resolve_mode(cli_mode)
505 _dbg(
"OC_CHAT.MAIN", func, f
"effective mode={_RUNTIME_MODE}", 2)
510 """The cached process settings (lazily loaded with defaults if uninitialised)."""
513 assert _RUNTIME
is not None
518 """The effective active mode for this process."""
525 """Composition toggles for the effective mode (backfilled defaults)."""
530 """Per-mode tool whitelist with IDENTITY semantics (plan 135 §3.2).
532 The single helper used at every tool-consumption point (registry, API
533 schemas, compact map). Returns:
534 * ``[]`` when the mode exposes no tools (``include_tools`` false, or
535 ``tools == []``) — an explicit empty whitelist;
536 * ``None`` when the mode exposes ALL discovered tools (``tools`` absent or
537 explicitly ``null``);
538 * the explicit list otherwise (a subset).
540 Never branch on ``if profile.get("tools")`` — that conflates ``[]`` (none)
541 with ``None`` (all). This function keys on ``is None``.
543 if not profile.get(
"include_tools",
True):
545 tools = profile.get(
"tools")
552 """Test helper: clear the cached runtime settings so the next access reloads."""
553 global _RUNTIME, _RUNTIME_MODE
555 _RUNTIME_MODE =
"standard"
563 """Resolved, typed verification-backpressure config (plan 137 §3.7).
565 Built by :func:`verification_config` from the deep-merge of the top-level
566 ``verification`` block with the active mode's partial override. ``react_loop``
567 and the sensor consume this typed shape, never ad-hoc nested dicts.
570 enabled: bool = shipped(
"verification_enabled")
571 mutating_tools: tuple[str, ...] = (
"write_file",
"oct_new")
572 lint: bool = shipped(
"verification_lint")
573 typecheck: bool = shipped(
"verification_typecheck")
574 tests: bool = shipped(
"verification_tests")
575 profile: str =
"default"
576 timeout_s: int = shipped(
"verification_timeout_s")
577 max_files: int = shipped(
"verification_max_files")
578 max_findings: int = shipped(
"verification_max_findings")
579 jobs: int = shipped(
"verification_jobs")
580 max_runs_per_turn: int = shipped(
"verification_max_runs_per_turn")
581 baseline: bool = shipped(
"verification_baseline")
582 baseline_timeout_s: int = shipped(
"verification_baseline_timeout_s")
583 baseline_scope: tuple[str, ...] |
None =
None
588 subloop_readonly: bool =
False
592 """Resolve the typed :class:`VerificationConfig` for *mode* (plan 137 §3.7).
594 Precedence: built-in defaults < committed/local top-level ``verification``
595 (already folded into ``settings.verification`` by :func:`load_settings`) <
596 the active mode's per-mode ``verification`` override. The override is
597 **deep-merged** over the top-level block (``mode_profile``'s shallow update is
598 not relied upon for the sub-keys). ``subloop_readonly`` defaults to follow
599 ``enabled`` when unset (``None``), so the off path is byte-identical.
601 func =
"verification_config"
602 top: dict[str, Any] = dict(settings.verification
or {})
603 override: dict[str, Any] = {}
605 ov = (settings.modes.get(mode)
or {}).get(
"verification")
606 if isinstance(ov, dict):
608 merged =
_deep_merge(top, override)
if override
else top
610 enabled = bool(merged.get(
"enabled", shipped(
"verification_enabled")))
611 raw_subloop = merged.get(
"subloop_readonly",
None)
612 subloop_readonly = enabled
if raw_subloop
is None else bool(raw_subloop)
614 raw_scope = merged.get(
"baseline_scope",
None)
615 if raw_scope
is None:
616 scope: tuple[str, ...] |
None =
None
617 elif isinstance(raw_scope, (list, tuple)):
618 scope = tuple(str(s)
for s
in raw_scope)
620 scope = (str(raw_scope),)
624 mutating_tools=tuple(merged.get(
"mutating_tools")
or (
"write_file",
"oct_new")),
625 lint=bool(merged.get(
"lint", shipped(
"verification_lint"))),
626 typecheck=bool(merged.get(
"typecheck", shipped(
"verification_typecheck"))),
627 tests=bool(merged.get(
"tests", shipped(
"verification_tests"))),
628 profile=str(merged.get(
"profile")
or "default"),
629 timeout_s=int(merged.get(
"timeout_s", shipped(
"verification_timeout_s"))),
630 max_files=int(merged.get(
"max_files", shipped(
"verification_max_files"))),
631 max_findings=int(merged.get(
"max_findings", shipped(
"verification_max_findings"))),
632 jobs=int(merged.get(
"jobs", shipped(
"verification_jobs"))),
633 max_runs_per_turn=int(
634 merged.get(
"max_runs_per_turn", shipped(
"verification_max_runs_per_turn"))),
635 baseline=bool(merged.get(
"baseline", shipped(
"verification_baseline"))),
636 baseline_timeout_s=int(
637 merged.get(
"baseline_timeout_s", shipped(
"verification_baseline_timeout_s"))),
638 baseline_scope=scope,
639 subloop_readonly=subloop_readonly,
641 _dbg(
"OC_CHAT.MAIN", func,
642 f
"mode={mode} enabled={cfg.enabled} subloop_readonly={cfg.subloop_readonly}", 3)
str resolve_mode(self, str|None cli_mode)
dict[str, Any] mode_profile(self, str name)
list[str] drain_warnings(self)
dict[str, Any] active_profile()
None _warn_unknown(dict[str, Any] raw, list[str] warnings)
dict[str, Any] _deep_merge(dict[str, Any] base, dict[str, Any] over)
OCChatSettings init_runtime_settings(str|None cli_mode=None, *, Path|str|None workspace_root=None)
int|None _as_positive_int_or_none(Any raw, *, str key, list[str] warnings)
OCChatSettings runtime_settings()
dict[str, Any] _read_json(Path path, list[str] warnings)
frozenset[str] _known_tree_keys()
int _as_int(Any raw, int fallback)
str resolve_system_prompt(OCChatSettings settings, *, str default, Path|str|None workspace_root=None)
None reset_runtime_settings_for_tests()
frozenset[str] _known_top_keys()
frozenset[str] _known_mode_keys()
list[str]|None resolve_tool_filter(dict[str, Any] profile)
VerificationConfig verification_config(OCChatSettings settings, str|None mode=None)
list[float] _as_float_list(Any raw)
OCChatSettings load_settings(Path|str|None workspace_root=None)