Files
cowork-local/config.py
anhtnm1andClaude Opus 5 f61c5474b0 feat(R03): unify model routing and centralise the provider catalogue
EPIC R03 (Team Duy) — Model Providers & Routing. All six tasks done.

R03-T02 — Provider catalogue
  domain/models/provider_descriptor.py     ProviderDescriptor (frozen), WireProtocol, AuthKind
  infrastructure/providers/provider_registry.py
                                           thread-safe registry: id/alias lookup, dynamic
                                           lookup by model id, adapter selection by protocol
  providers/factory.py                     drops its own _REGISTRY table and delegates to the
                                           registry, still raising ProviderError for callers

R03-T03 — RoutingApplicationService (pure Python, 4 modes)
  application/model_routing/routing_models.py
                                           RoutingMode (off/auto/manual/fallback),
                                           RoutingRequest (immutable snapshot), RouteEvaluation,
                                           RoutingOutcome
  application/model_routing/routing_application_service.py
                                           the single decision flow, reached through two narrow
                                           ports plus a caller-supplied confirm callback, so no
                                           Qt import is needed
  application/model_routing/core_routing_adapter.py
                                           binds the ports to core/routing and AppContext

  Fallback is a new resilience mode: keep the selected model while it can serve the turn,
  re-route only when it cannot. Wired end to end through config.py, state.py,
  ui/routing_toggle.py and i18n.py (EN/JA/VI).

R03-T04 / T05 — Remove the duplicated routing flow
  ui/chat_panel.py (#L638), ui/co4e_tab.py, ui/folder_tab.py each drop ~35 lines of copied
  logic and call the shared service; the widgets now only build a RoutingRequest, host the
  Manual-mode modal and render the outcome.

R03-T06 — Token usage as an event
  infrastructure/telemetry/usage_sink.py   UsageEvent + UsageEventSink protocol, with tracker,
                                           in-memory and composite sinks
  providers/openai_compat.py, providers/anthropic.py
                                           publish a UsageEvent instead of writing to the
                                           usage tracker themselves
  core/usage_tracker.py                    adds current_context() so a sink can borrow and
                                           restore a thread's attribution

R03-T01 — Contract tests
  tests/contracts/test_providers.py parametrises over every provider in the registry: chat()
  signature, canonical assistant message, normalised tool calls, response closed, tool schema
  translation, ProviderError, list_models/test_connection, one UsageEvent per turn.

Test infrastructure fix (required to verify any of the above): tests/conftest.py used to put
the repository's PARENT directory on sys.path, so `import cowork_local.*` resolved against
whichever sibling folder happened to carry that name — on a dev machine, an unrelated older
checkout. The suite reported green while exercising different code. The conftest now binds
this checkout to the cowork_local name in sys.modules.

Verification
  pytest tests/                    236 passed in ~1.8s (102 before this change)
  scripts/check_imports.py         PASS, 0 forbidden imports in domain/ and application/
  new production files             largest is 288 lines, all under the 400 LOC ceiling
  new tests                        134 (50 contract, 70 unit, 14 integration), all offline

scripts/run_quality_gate.py does not exist yet (R10-T02), so DoD item 7 was covered by
check_imports.py plus the full suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:36:20 +09:00

624 lines
28 KiB
Python

"""Application configuration.
Stored as JSON at ``~/.cowork_local/config.json``. Environment variables
override stored values so the app can run immediately in locked-down setups:
OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL
ANTHROPIC_API_KEY, ANTHROPIC_MODEL
COWORK_TEAMS_WEBHOOK
COWORK_ACTIVE_PROVIDER
COWORK_CA_BUNDLE
"""
from __future__ import annotations
import copy
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List
CONFIG_DIR = Path.home() / ".cowork_local"
CONFIG_PATH = CONFIG_DIR / "config.json"
HISTORY_DIR = CONFIG_DIR / "history"
DEFAULT_CONFIG: Dict[str, Any] = {
"active_provider": "openai_compat",
"theme": "dark",
"language": "vi", # "en" | "ja" | "vi" — UI display language
# Advanced/IT-managed override only (no Settings UI): path to a PEM file
# with a corporate/internal gateway's certificate (or its issuing CA), set
# via the COWORK_CA_BUNDLE env var. Normally unnecessary — a self-signed
# gateway certificate (e.g. "SSLCertVerificationError: self-signed
# certificate in certificate chain") is detected and trusted automatically
# per-host on first contact; see core/tls_trust.py.
"tls_ca_bundle": "",
"providers": {
"openai_compat": {
"base_url": "https://your-internal-gateway/v1",
"api_key": "",
"model": "gpt-4o-mini",
},
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key": "",
"model": "claude-sonnet-4-6",
},
# Local models via Ollama's OpenAI-compatible server (no key needed).
"ollama": {
"base_url": "http://localhost:11434/v1",
"api_key": "ollama", # Ollama ignores it, but some clients require a value
"model": "llama3.1",
},
# GitHub Copilot chat (OpenAI-compatible endpoint; paste a Copilot token).
"github_copilot": {
"base_url": "https://api.githubcopilot.com",
"api_key": "",
"model": "gpt-4o",
},
# OpenAI (Codex / GPT models) — OpenAI-compatible; paste an OpenAI API key.
"codex": {
"base_url": "https://api.openai.com/v1",
"api_key": "",
"model": "gpt-4o-mini",
},
},
"code": {
"mode": "confirm", # "confirm" | "auto"
"default_workdir": "",
},
"teams": {
"webhook_url": "",
"notify_on_complete": True,
},
"history": {
"location": "local", # "local" | "onedrive"
"custom_dir": "", # optional explicit folder; overrides location
"autosave": True,
},
"codebase_memory": {
"enabled": False,
"binary_path": "", # empty -> resolved from PATH (codebase-memory-mcp)
"auto_index": True, # index the workdir automatically before the first turn
},
# AI-assisted agent security guardrails — configured in its own Settings
# group next to Microsoft 365 (same screen area, but never touches the
# ms365 dict/rules above). Each layer is independently toggleable; a
# blocked action always notifies the admin (see core/agent_security_alert.py)
# via the SAME signed-in Microsoft 365 account as everything else.
"agent_security": {
"enabled": True, # master switch — ON by default ("chọn hết"); editing the Settings group requires an admin-account unlock
"validate_prompt": True, # AI reviews the user's own request against the rules below
"validate_attachments": True, # AI scans attachment/file content for malicious payloads
"validate_commands": True, # whitelist + optional AI control-agent gate on run_command/install_package
"command_ai_check": False, # extra AI judgement for commands not covered by the whitelist (default: off)
"rules_onedrive_url": "", # optional OneDrive/SharePoint SHARE LINK to a .md rules doc (admin-authored)
"admin_email": "", # violation alerts are emailed here via the signed-in MS365 account
# ---- Sandbox Security Layer ----
"cowork_confirm_commands": False, # show the Approve/Reject dialog before Cowork runs a command (default: off)
"resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU%
"resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB)
"resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB)
"block_network": True, # strip proxy env / point at a black-hole address for agent-run commands
# Allow the agent's fetch_url tool to read web pages / online documents /
# SharePoint-OneDrive share links. SEPARATE from block_network (that only
# sandboxes agent-run shell commands) — reading a URL for info is safe and
# useful, so this defaults ON. Toggle in Settings → Security.
"allow_url_fetch": True,
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
},
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
# the unified "Connectors (MCP)" section — kept here only so config.load()
# can migrate any pre-existing entries; the UI no longer writes it.
"mcp_servers": [],
# Unified "Connectors (MCP)" (Settings). One system for every external tool
# source — grouped by category CAD / CAE / MS365 / Other. Each entry:
# {"id", "name", "category", "enabled", "mode": "mcp_stdio"|"rest_api", plus
# mode-specific fields — see core/ext_connectors.py}. No vendor SDK bundled:
# a mcp_stdio entry points at a real MCP server the user/IT already has; a
# rest_api entry calls a REST endpoint the app/vendor exposes. "Other" is
# the home for generic MCP servers (what used to be the separate "MCP
# Servers" section); MS365 additionally auto-wires the built-in MS365 MCP
# server (see state.py::_ms365_builtin_connection).
"ext_connectors": {
"cad": [],
"cae": [],
"ms365": [],
"other": [],
},
"cowork": {
"output_dir": "", # where Cowork saves generated files; empty -> OneDrive/CoworkLocal/output
"max_parallel": 5, # max messages running at once per tab; extras wait in the queue
},
"context": { # auto-compress long conversations (Cowork + Co4E)
"auto_compact": True, # summarize old turns when near the memory quota
"compact_threshold": 0.8, # trigger at 80% of the context window
"limit_tokens": 0, # 0 = auto per model; else a fixed token budget
},
"jira": { # Jira read connector (agent tool: jira_search / jira_get_issue)
"base_url": "", # e.g. https://your-domain.atlassian.net
"email": "", # Atlassian account email (Basic auth user)
"api_token": "", # Atlassian API token (id.atlassian.com → Security → API tokens)
},
"attachments": {
"max_tokens": 500000, # per attached file; content beyond this is truncated (~4 chars/token)
"max_files": 10, # max number of files attachable to one message
},
"structure": { # Structure (RAG) graph performance caps (0 = unlimited)
"max_nodes": 400,
"max_edges": 400,
},
# Dashboard tab: unit prices (USD per 1M tokens) + display currency.
# Editable right on the Dashboard; rates are static conversions.
"usage": {
"price_per_mtok_in_usd": 0.5,
"price_per_mtok_out_usd": 1.5,
"price_per_mtok_cache_usd": 0.1,
"currency": "USD", # USD | VND | JPY
"usd_to_vnd": 25000.0,
"usd_to_jpy": 150.0,
"model_prices": {}, # per-model USD/1M rates: {model: {"in","out","cache"}}
"pricing_url": "", # reference price-list link (informational)
},
"auth": {
"shared_dir": "", # shared folder path (network share or synced OneDrive folder) holding
# accounts/groups + cross-machine telemetry — plain file I/O, no Graph API
"last_account": "", # last successfully logged-in username, for prefill only — never the code
"last_department": "", # last-typed optional Department at login, for prefill only
},
# Microsoft 365 connections (Settings → "Kết nối Microsoft 365"). This gate
# (unlock_code) is a LOCAL SETTINGS-PANEL LOCK ONLY — it stops someone from
# casually flipping these switches, it is NOT how the app authenticates to
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
"ms365": {
"unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE
"unlocked": False, # runtime-only — never persisted as True, see save()
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
# launches automatically once the user is signed in (OAuth tenant/client
# is still required for real Graph access — this only pre-arms the wiring
# so it "just works" after sign-in, per the unified Connectors design).
"allow_external_internet": True,
# TEMPORARY: only OneDrive + SharePoint are enabled, and they connect via
# the LOCALLY-SYNCED OneDrive folders (core/ms365_local.py) — no OAuth /
# tenant / sign-in. Outlook / Teams / Meeting-transcript are OFF for now
# because they need cloud Graph access (OAuth); re-enable them once the
# cloud sign-in flow is turned back on.
"connectors": {
"outlook": False,
"teams": False,
"onedrive": True,
"sharepoint": True,
"meeting_transcript": False,
},
"tenant_id": "",
"client_id": "",
# "Paste a Teams link" convenience (Settings): a channel/chat link the
# user connected once, so the agent can post to it without ever
# needing a team_id/channel_id/chat_id — see ms365_graph.parse_teams_link.
"teams_link": "",
"teams_target": None, # {"kind": "channel", "team_id", "channel_id"} | {"kind": "chat", "chat_id"}
"teams_introduced": False, # has the "Hi, I'm Co4E" self-intro already been sent for this target?
},
"last_session": { # restored on next launch (crash-resilient)
"cowork": "",
"code": "",
},
"tray": {
"minimize_on_close": True, # closing the window keeps running in the tray
"notify_on_done": True, # tray notification when a task finishes/fails
},
# Which Monitoring tabs a Sub-admin may see (Admin always sees every tab;
# "user" never sees Monitoring at all — unaffected by this). All default
# True so behavior is unchanged until an Admin explicitly restricts one.
"monitoring_visibility": {
"security_events": True,
"mcp_history": True,
"action_logs": True,
"agent_status": True,
},
# Agent tool governance (Monitoring → Tools). Built-in agent tools whose
# NAME is listed here are withheld from the agent (filtered out of the tool
# list at run time). Empty = every built-in tool available (default).
"tools": {
"disabled": [],
},
# Auto Model Assessment & Routing (core/routing/). The app periodically
# assesses each configured model (static metadata + dynamic probes graded
# by a fixed judge), scores them per task type, and can route each chat/
# agent turn to the best-fit model. Assessment RESULTS live in their own
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
# this section is only the behaviour config the user edits.
"routing": {
"switch_mode": "off", # global default: "off" | "auto" | "manual"
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
"reassess_interval_hours": 24, # periodic reassess cadence; 0 disables the schedule
"per_provider_concurrency": 2, # max concurrent probe calls per provider (rate-limit safety)
"judge_provider": "", # judge model's provider ("" → the active provider)
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
# Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An
# empty string means "follow the global switch_mode above".
"surface_modes": {
"cowork": "",
"co4e": "",
"ai_edit": "",
},
},
}
# Friendly labels used across the UI.
PROVIDER_LABELS = {
"openai_compat": "OpenAI-compatible (Internal Gateway)",
"anthropic": "Anthropic Claude",
"ollama": "Ollama (local models)",
"github_copilot": "GitHub Copilot",
"codex": "OpenAI (Codex / GPT)",
}
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively merge ``override`` into a copy of ``base``."""
out = copy.deepcopy(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(out.get(key), dict):
out[key] = _deep_merge(out[key], value)
else:
out[key] = value
return out
def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
data = copy.deepcopy(data)
oc = data["providers"]["openai_compat"]
if os.getenv("OPENAI_API_KEY"):
oc["api_key"] = os.environ["OPENAI_API_KEY"]
if os.getenv("OPENAI_BASE_URL"):
oc["base_url"] = os.environ["OPENAI_BASE_URL"]
if os.getenv("OPENAI_MODEL"):
oc["model"] = os.environ["OPENAI_MODEL"]
an = data["providers"]["anthropic"]
if os.getenv("ANTHROPIC_API_KEY"):
an["api_key"] = os.environ["ANTHROPIC_API_KEY"]
if os.getenv("ANTHROPIC_MODEL"):
an["model"] = os.environ["ANTHROPIC_MODEL"]
if os.getenv("COWORK_TEAMS_WEBHOOK"):
data["teams"]["webhook_url"] = os.environ["COWORK_TEAMS_WEBHOOK"]
if os.getenv("COWORK_ACTIVE_PROVIDER"):
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
if os.getenv("COWORK_CA_BUNDLE"):
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
if os.getenv("COWORK_SANDBOX_PASSWORD"):
data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"]
if os.getenv("COWORK_MS365_UNLOCK_CODE"):
data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"]
return data
def _migrate_connectors(data: Dict[str, Any]) -> None:
"""One-way migration into the unified Connectors (MCP) model, in place:
* ext_connectors["office"] → ext_connectors["ms365"] (renamed category)
* legacy top-level mcp_servers → ext_connectors["other"] as mcp_stdio
connectors (the old standalone "MCP Servers" section was merged in).
Idempotent: re-running does nothing once migrated. Never raises."""
import uuid
ext = data.setdefault("ext_connectors", {})
for cat in ("cad", "cae", "ms365", "other"):
ext.setdefault(cat, [])
# office → ms365 (only migrate non-empty legacy bucket; then drop it)
legacy_office = ext.pop("office", None)
if legacy_office:
seen = {c.get("id") for c in ext["ms365"]}
for c in legacy_office:
c["category"] = "ms365"
if c.get("id") not in seen:
ext["ms365"].append(c)
# legacy generic mcp_servers → ext_connectors["other"] (mcp_stdio)
servers = data.get("mcp_servers") or []
if servers:
existing = {c.get("name") for c in ext["other"]}
for s in servers:
name = s.get("name", "")
if not name or name in existing:
continue
ext["other"].append({
"id": f"other-{uuid.uuid4().hex[:6]}",
"name": name,
"category": "other",
"enabled": bool(s.get("enabled", True)),
"mode": "mcp_stdio",
"command": s.get("command", ""),
"args": s.get("args") or [],
"env": s.get("env") or {},
})
data["mcp_servers"] = [] # migrated — the UI no longer manages this
@dataclass
class AppConfig:
"""In-memory view of the configuration with load/save helpers."""
data: Dict[str, Any] = field(default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG))
path: Path = CONFIG_PATH
# ---- persistence -------------------------------------------------
@classmethod
def load(cls, path: Path = CONFIG_PATH) -> "AppConfig":
merged = copy.deepcopy(DEFAULT_CONFIG)
if path.exists():
try:
stored = json.loads(path.read_text(encoding="utf-8"))
merged = _deep_merge(merged, stored)
except (json.JSONDecodeError, OSError):
# Corrupt config should never block startup.
merged = copy.deepcopy(DEFAULT_CONFIG)
merged = _apply_env_overrides(merged)
# "unlocked" is a runtime-only Settings-panel state (see the "ms365"
# comment in DEFAULT_CONFIG) — never trust a stored/hand-edited value,
# every launch starts locked.
merged.setdefault("ms365", {})["unlocked"] = False
_migrate_connectors(merged) # office→ms365 + legacy mcp_servers→other
return cls(data=merged, path=path)
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
to_write = self.data
if self.data.get("ms365", {}).get("unlocked"):
# Defense in depth: even if some caller saves without having gone
# through the Settings dialog's own auto-lock-after-save flow, the
# unlock state must never reach disk.
to_write = copy.deepcopy(self.data)
to_write["ms365"]["unlocked"] = False
self.path.write_text(
json.dumps(to_write, indent=2, ensure_ascii=False), encoding="utf-8"
)
# ---- convenience accessors --------------------------------------
@property
def active_provider(self) -> str:
# Migrate configs that still point at a removed provider (e.g. an older
# install saved "ollama") to a supported one, so the app never tries to
# build an unknown provider.
val = self.data.get("active_provider", "openai_compat")
return val if val in PROVIDER_LABELS else "openai_compat"
@active_provider.setter
def active_provider(self, value: str) -> None:
self.data["active_provider"] = value
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
name = name or self.active_provider
return self.data["providers"].get(name, {})
@property
def ca_bundle(self) -> str:
"""Path to a custom CA/certificate PEM file, or '' for normal validation.
Used as ``requests``' ``verify=`` argument for every outbound HTTPS call
— see the "tls_ca_bundle" comment above for when this is needed."""
return (self.data.get("tls_ca_bundle") or "").strip()
@ca_bundle.setter
def ca_bundle(self, value: str) -> None:
self.data["tls_ca_bundle"] = (value or "").strip()
# ---- Microsoft 365 connections (Settings-panel lock, see DEFAULT_CONFIG) --
@property
def ms365(self) -> Dict[str, Any]:
return self.data.setdefault("ms365", copy.deepcopy(DEFAULT_CONFIG["ms365"]))
# ---- Login / RBAC / shared cross-machine store (see DEFAULT_CONFIG) ------
@property
def auth(self) -> Dict[str, Any]:
return self.data.setdefault("auth", copy.deepcopy(DEFAULT_CONFIG["auth"]))
@property
def shared_dir(self) -> str:
return (self.auth.get("shared_dir") or "").strip()
def ms365_try_unlock(self, code: str) -> bool:
"""Unlock the MS365 Settings group for this session if ``code`` matches.
This is a client-side UI lock (prevents casually toggling a sensitive
section), NOT Microsoft authentication — see the DEFAULT_CONFIG
comment. Never persisted as unlocked; see ``save()``."""
if (code or "") and code == self.ms365.get("unlock_code", ""):
self.data["ms365"]["unlocked"] = True
return True
return False
def ms365_lock(self) -> None:
self.data.setdefault("ms365", {})["unlocked"] = False
@property
def theme(self) -> str:
return self.data.get("theme", "dark")
@theme.setter
def theme(self, value: str) -> None:
self.data["theme"] = value
@property
def language(self) -> str:
from .i18n import DEFAULT_LANGUAGE, LANGUAGES
val = self.data.get("language", DEFAULT_LANGUAGE)
return val if val in LANGUAGES else DEFAULT_LANGUAGE
@language.setter
def language(self, value: str) -> None:
self.data["language"] = value
@property
def code(self) -> Dict[str, Any]:
return self.data["code"]
@property
def tools_disabled(self) -> list:
"""Built-in agent tool names the admin has turned off (Monitoring → Tools)."""
return self.data.setdefault("tools", {}).setdefault("disabled", [])
def set_tool_enabled(self, name: str, enabled: bool) -> None:
"""Enable/disable a built-in agent tool by name and persist it."""
disabled = set(self.tools_disabled)
if enabled:
disabled.discard(name)
else:
disabled.add(name)
self.data.setdefault("tools", {})["disabled"] = sorted(disabled)
self.save()
@property
def connect_external(self) -> bool:
"""Master switch (Monitoring → Tools → Connector): when off, the agent
connects to NO external connectors (CAD/CAE/MS365/Other MCP + REST).
Defaults ON so existing setups keep working."""
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
def set_connect_external(self, enabled: bool) -> None:
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
self.save()
# ---- one-time seeding bookkeeping (built-in skill library / flows) -------
@property
def seeded_library_skills(self) -> List[str]:
"""Slugs of bundled library skills already seeded into the user's Skill
Manager — so a user-deleted one is never silently re-seeded."""
return list(self.data.setdefault("seeded_library_skills", []))
@seeded_library_skills.setter
def seeded_library_skills(self, slugs) -> None:
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
@property
def seeded_builtin_flows(self) -> List[str]:
"""Ids of built-in Co4E flows already seeded (same respect-user-deletion
rule as seeded_library_skills)."""
return list(self.data.setdefault("seeded_builtin_flows", []))
@seeded_builtin_flows.setter
def seeded_builtin_flows(self, ids) -> None:
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
@property
def teams(self) -> Dict[str, Any]:
return self.data["teams"]
@property
def history(self) -> Dict[str, Any]:
return self.data["history"]
@property
def codebase_memory(self) -> Dict[str, Any]:
return self.data["codebase_memory"]
@property
def agent_security(self) -> Dict[str, Any]:
return self.data["agent_security"]
@property
def mcp_servers(self) -> List[Dict[str, Any]]:
return self.data.setdefault("mcp_servers", [])
@property
def ext_connectors(self) -> Dict[str, List[Dict[str, Any]]]:
"""Unified Connectors (MCP), grouped by category CAD/CAE/MS365/Other —
see core/ext_connectors.py for the per-entry shape and CATEGORIES."""
d = self.data.setdefault("ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
for cat in ("cad", "cae", "ms365", "other"):
d.setdefault(cat, [])
return d
@property
def cowork(self) -> Dict[str, Any]:
return self.data["cowork"]
@property
def routing(self) -> Dict[str, Any]:
"""Auto Model Assessment & Routing behaviour config (see DEFAULT_CONFIG).
Always returns a dict with every expected key present, backfilling any
missing sub-keys from the defaults so older configs upgrade seamlessly."""
d = self.data.setdefault("routing", copy.deepcopy(DEFAULT_CONFIG["routing"]))
for k, v in DEFAULT_CONFIG["routing"].items():
d.setdefault(k, copy.deepcopy(v))
d.setdefault("surface_modes", {})
for surface in ("cowork", "co4e", "ai_edit"):
d["surface_modes"].setdefault(surface, "")
return d
# The routing modes a surface may be in. "fallback" joined the set in
# R03-T03 (keep the selected model; re-route only when it cannot serve the
# turn) — see application/model_routing/routing_models.py::RoutingMode,
# which is the authority on what each mode means.
ROUTING_MODES = ("off", "auto", "manual", "fallback")
def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
A per-surface override wins; an empty override falls back to the global
``switch_mode``. Anything unrecognised degrades to "off" so routing
stays opt-in even with a hand-edited config."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in self.ROUTING_MODES else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Persist a chat surface's routing toggle selection."""
mode = mode if mode in self.ROUTING_MODES else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
@property
def structure(self) -> Dict[str, Any]:
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
@property
def monitoring_visibility(self) -> Dict[str, bool]:
return self.data.setdefault(
"monitoring_visibility", copy.deepcopy(DEFAULT_CONFIG["monitoring_visibility"]))
def cowork_output_dir(self) -> Path:
"""Where Cowork saves generated files (OneDrive folder by default)."""
custom = (self.cowork.get("output_dir") or "").strip()
if custom:
return Path(custom).expanduser()
from . import paths # local import avoids any import cycle
root = paths.primary_onedrive_root()
if root is not None:
return root / "CoworkLocal" / "output"
return CONFIG_DIR / "output" / "cowork"
def history_dir(self) -> Path:
"""Resolve where conversation history is stored.
When a project is open, its history is stored INSIDE the project's
workspace folder (``_project_history_dir``, set by the Workspace screen)
so that sharing/syncing that folder shares the history — another machine
opening the same folder sees the conversations and can continue them.
Otherwise: Local (default) or OneDrive."""
rt = getattr(self, "_project_history_dir", None)
if rt:
return Path(rt)
custom = (self.history.get("custom_dir") or "").strip()
if custom:
return Path(custom).expanduser()
if self.history.get("location") == "onedrive":
from . import paths # local import avoids any import cycle
root = paths.primary_onedrive_root()
if root is not None:
return root / "CoworkLocal" / "history"
return HISTORY_DIR
def model_label(self) -> str:
return str(self.provider_conf().get("model", "?"))