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>
624 lines
28 KiB
Python
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", "?"))
|