diff --git a/README.md b/README.md index d679d727..db6ec718 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Turnstone includes a built-in governance layer for enterprise deployments — ma - **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning - **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention -All governance features are managed through the console admin panel (10 tabs) and the full REST API. See [docs/governance.md](docs/governance.md) for setup and configuration. +All governance features are managed through the console admin panel (13 tabs) and the full REST API. Runtime settings (model, tools, rate limiting, health, judge, memory) are configurable via the admin Settings tab — no config file edits or restarts needed for most changes. See [docs/governance.md](docs/governance.md) for setup and [docs/settings.md](docs/settings.md) for the settings reference. ### Intent Validation (LLM Judge) diff --git a/docs/architecture.md b/docs/architecture.md index 88a72183..09c30eea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1018,8 +1018,8 @@ Three hierarchical scopes control endpoint access: - **Console** is the auth management hub — it hosts the admin endpoints for creating users, issuing API tokens, and managing channel mappings. User records and token hashes live in the shared storage backend. The console - dashboard includes an **admin panel** (Users and Tokens tabs) for managing - credentials through the browser. + dashboard includes an **admin panel** (13 tabs) for managing + credentials, governance, and runtime settings through the browser. - **Server** is a JWT validator only — it validates tokens on each request but never creates users or tokens. Both processes share the same `jwt_secret` (via `TURNSTONE_JWT_SECRET` env var or `[auth].jwt_secret` config). @@ -1393,7 +1393,8 @@ enforcement tracks consumption in `session.send()` with 80% warning and 100% approval gate via the `__budget_override__` synthetic tool name. The console admin panel adds 6 governance tabs (Roles, Policies, Templates, -WS Templates, Usage, Audit) for a total of 11 tabs, all permission-gated. +WS Templates, Usage, Audit), a Memories tab, and a Settings tab (form-based +editor for all ConfigStore settings) for a total of 13 tabs, all permission-gated. Both Python and TypeScript SDKs expose governance methods on the console client. diff --git a/docs/console.md b/docs/console.md index c326ff3c..fc48fe09 100644 --- a/docs/console.md +++ b/docs/console.md @@ -421,8 +421,9 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna Accessed via the "admin" button in the header (visible when authenticated with `approve` scope). Provides user, API token, channel link, and workstream -template management with 11 tabs (see also [Governance](governance.md) for -the Roles, Policies, Templates, WS Templates, Usage, and Audit tabs): +template management with 13 tabs (see also [Governance](governance.md) for +the Roles, Policies, Templates, WS Templates, Usage, and Audit tabs, and +[Settings](settings.md) for the database-backed configuration editor): **Users tab:** diff --git a/docs/governance.md b/docs/governance.md index 209d9dd6..8e0a9ff3 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -99,7 +99,7 @@ Workstream templates are behavioral profiles applied at workstream creation — **Admin API:** 7 endpoints under `/v1/api/admin/ws-templates` (list, create, get, update, delete, version history) plus a read-only summary at `/v1/api/ws-templates`. Permission: `admin.ws_templates`. -**Console UI:** "WS Templates" tab (11th admin tab) with CRUD table, create/edit modals (name, description, system prompt source toggle, model, auto-approve, per-tool auto-approve, temperature, reasoning effort, max tokens, agent max turns, token budget, enabled), and version history modal. "Profile" dropdown on workstream creation modal. "WS Template" dropdown on scheduler create/edit modals. +**Console UI:** "WS Templates" tab with CRUD table, create/edit modals (name, description, system prompt source toggle, model, auto-approve, per-tool auto-approve, temperature, reasoning effort, max tokens, agent max turns, token budget, enabled), and version history modal. "Profile" dropdown on workstream creation modal. "WS Template" dropdown on scheduler create/edit modals. **Token budget enforcement:** Tracked in `session.send()`. At 80% consumption, emits an info message. At 100%, the next turn requires explicit approval via the `__budget_override__` synthetic tool name (reuses existing approval UI — inline in browser, Discord buttons, bridge auto-approve). The synthetic name can be targeted by tool policies (e.g. `__budget_override__` → `allow` for admins). diff --git a/tests/test_model_registry.py b/tests/test_model_registry.py index 4c3b5806..9f764228 100644 --- a/tests/test_model_registry.py +++ b/tests/test_model_registry.py @@ -28,7 +28,7 @@ class TestModelConfig: ) assert cfg.alias == "local" assert cfg.model == "qwen3-32b" - assert cfg.context_window == 131072 # default + assert cfg.context_window == 32768 # default def test_custom_context_window(self) -> None: cfg = ModelConfig( diff --git a/turnstone/bootstrap.py b/turnstone/bootstrap.py index e257cddf..87e19550 100644 --- a/turnstone/bootstrap.py +++ b/turnstone/bootstrap.py @@ -122,6 +122,15 @@ This is a one-time endpoint that only works when zero users exist. Subsequent governance setup (roles, policies, templates) uses the console admin API \ with the JWT returned from setup. +## Runtime Settings (ConfigStore) +After the stack is running, ~40 runtime settings (model, temperature, max_tokens, \ +reasoning_effort, tool timeout, rate limiting, health probes, judge config, memory \ +config, etc.) are configurable via the admin Settings tab in the console — no \ +config.toml edits or restarts needed for most changes. These settings are stored in \ +the database and apply cluster-wide. The `.env` file only needs bootstrap-critical \ +settings (database, Redis, auth, ports, API keys). Tell users they can fine-tune \ +model and behavioral settings after deployment through the admin panel. + ## Built-in Roles - **Admin** (`builtin-admin`): Full access — read, write, approve, all admin.* permissions - **Operator** (`builtin-operator`): read, write, workstreams.create, workstreams.close diff --git a/turnstone/cli.py b/turnstone/cli.py index b101f98d..efa71bd5 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -799,8 +799,8 @@ def main() -> None: parser.add_argument( "--context-window", type=int, - default=131072, - help="Context window size in tokens (default: 131072)", + default=0, + help="Context window size in tokens (0 = auto-detect from model)", ) parser.add_argument( "--compact-max-tokens", @@ -986,10 +986,12 @@ def main() -> None: else: model, detected_ctx = detect_model(client, provider=provider_name) - # Use detected context window when the user hasn't explicitly set one + # Use detected context window, fall back to CLI override or 32768 context_window = args.context_window - if detected_ctx and context_window == 131072: # default unchanged + if detected_ctx and not context_window: # 0 = auto-detect context_window = detected_ctx + elif not context_window: + context_window = 32768 # Build model registry (reads [models.*] sections from config.toml) from turnstone.core.model_registry import load_model_registry diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 413eb481..f48f61e7 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -2834,6 +2834,8 @@ async def admin_settings_schema(request: Request) -> JSONResponse: "max_value": defn.max_value, "choices": defn.choices, "restart_required": defn.restart_required, + "help": defn.help, + "reference_url": defn.reference_url, } ) diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index 2fcfa03f..e224551e 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -1611,6 +1611,13 @@ function _removeTrap(handler) { // Global Escape key for admin modals document.addEventListener("keydown", function (e) { if (e.key !== "Escape") return; + // Close any open settings help popover first + var openHelp = document.querySelector('.settings-help-popover[style=""]'); + if (openHelp) { + e.preventDefault(); + _closeAllSettingsHelp(); + return; + } var cu = document.getElementById("create-user-overlay"); if (cu && cu.style.display !== "none") { e.preventDefault(); @@ -1789,13 +1796,554 @@ function _confirmCallback() { } // --------------------------------------------------------------------------- -// Settings (stub — full implementation is a separate project) +// Settings — form-based editor grouped by section // --------------------------------------------------------------------------- +var _settingsOriginal = {}; // original values for dirty detection + +// Section display order +var _settingsSectionOrder = [ + "model", + "session", + "tools", + "server", + "mcp", + "ratelimit", + "health", + "judge", + "memory", +]; + +function _settingsSectionLabel(section) { + var labels = { + model: "Model", + session: "Session", + tools: "Tools", + server: "Server", + mcp: "MCP", + ratelimit: "Rate Limiting", + health: "Health", + judge: "Judge", + memory: "Memory", + }; + return labels[section] || section; +} + function loadSettings() { var el = document.getElementById("admin-settings-content"); - if (el) - el.innerHTML = '
Settings coming soon
'; + if (!el) return; + + Promise.all([ + authFetch("/v1/api/admin/settings").then(function (r) { + if (!r.ok) throw new Error("Failed to load settings"); + return r.json(); + }), + authFetch("/v1/api/admin/settings/schema").then(function (r) { + if (!r.ok) throw new Error("Failed to load schema"); + return r.json(); + }), + ]) + .then(function (results) { + var valuesArr = results[0].settings || []; + var schemaArr = results[1].schema || []; + + // Build schema lookup + var schemaMap = {}; + for (var i = 0; i < schemaArr.length; i++) { + schemaMap[schemaArr[i].key] = schemaArr[i]; + } + + // Merge values + schema + var merged = {}; + for (var j = 0; j < valuesArr.length; j++) { + var v = valuesArr[j]; + var s = schemaMap[v.key] || {}; + merged[v.key] = { + key: v.key, + value: v.value, + source: v.source, + type: v.type || s.type || "str", + default_value: s.default !== undefined ? s.default : "", + description: v.description || s.description || "", + section: v.section || s.section || "", + is_secret: v.is_secret || false, + min_value: s.min_value, + max_value: s.max_value, + choices: s.choices || null, + restart_required: v.restart_required || false, + changed_by: v.changed_by || "", + updated: v.updated || "", + help: s.help || "", + reference_url: s.reference_url || "", + }; + } + + _settingsOriginal = {}; + + // Group by section + var grouped = {}; + var keys = Object.keys(merged); + for (var k = 0; k < keys.length; k++) { + var item = merged[keys[k]]; + var sec = item.section || "other"; + if (!grouped[sec]) grouped[sec] = []; + grouped[sec].push(item); + } + + _renderSettings(el, grouped); + }) + .catch(function (err) { + el.innerHTML = + '
Failed to load settings: ' + + escapeHtml(err.message || String(err)) + + "
"; + }); +} + +function _renderSettings(container, grouped) { + var html = ""; + + for (var i = 0; i < _settingsSectionOrder.length; i++) { + var sec = _settingsSectionOrder[i]; + var items = grouped[sec]; + if (!items || items.length === 0) continue; + + html += + '
'; + html += + '"; + html += + '
'; + + for (var j = 0; j < items.length; j++) { + html += _renderSettingRow(items[j]); + } + + html += "
"; + } + + // Render any sections not in the explicit order + var allSections = Object.keys(grouped); + for (var s = 0; s < allSections.length; s++) { + if (_settingsSectionOrder.indexOf(allSections[s]) === -1) { + var extra = grouped[allSections[s]]; + html += + '
'; + html += + '"; + html += + '
'; + for (var x = 0; x < extra.length; x++) { + html += _renderSettingRow(extra[x]); + } + html += "
"; + } + } + + container.innerHTML = html; + + // Store original values for dirty detection + var inputs = container.querySelectorAll("[data-setting-key]"); + for (var n = 0; n < inputs.length; n++) { + var inp = inputs[n]; + var key = inp.getAttribute("data-setting-key"); + if (inp.type === "checkbox") { + _settingsOriginal[key] = inp.checked; + } else { + _settingsOriginal[key] = inp.value; + } + } +} + +function _renderSettingRow(item) { + var shortKey = + item.key.indexOf(".") !== -1 + ? item.key.substring(item.key.indexOf(".") + 1) + : item.key; + var escapedKey = escapeHtml(item.key); + var escapedShort = escapeHtml(shortKey); + var escapedDesc = escapeHtml(item.description); + + var html = '
'; + + // Label column + html += '
'; + html += '
'; + html += escapeHtml(shortKey); + if (item.help) { + html += + ' '; + } + html += "
"; + if (item.description) { + html += '
' + escapedDesc + "
"; + } + if (item.help) { + html += '"; + } + html += "
"; + + // Input column + html += '
'; + if (item.is_secret) { + html += + '(managed via config file / env)'; + } else if (item.type === "bool") { + var checked = + item.value === true || item.value === "true" ? " checked" : ""; + html += + ''; + } else if (item.choices && item.choices.length > 0) { + html += + '"; + } else if (item.type === "int" || item.type === "float") { + var step = item.type === "float" ? "0.01" : "1"; + var minAttr = + item.min_value !== null && item.min_value !== undefined + ? ' min="' + item.min_value + '"' + : ""; + var maxAttr = + item.max_value !== null && item.max_value !== undefined + ? ' max="' + item.max_value + '"' + : ""; + html += + '"; + } else { + // str + html += + '"; + } + html += "
"; + + // Actions column + html += '
'; + + // Restart badge (left of source badge, hidden until dirty or post-save) + if (item.restart_required) { + html += + 'restart'; + } + + // Source badge + if (item.source === "storage") { + html += 'storage'; + } else { + html += 'default'; + } + + // Save button (hidden until value changes) + if (!item.is_secret) { + html += + '"; + } + + // Reset link (when stored — including secrets, to clear legacy overrides) + if (item.source === "storage") { + html += + '"; + } + + html += "
"; + html += "
"; + return html; +} + +function _toggleSettingsHelp(e, btn) { + e.stopPropagation(); + var popover = btn + .closest(".settings-label-col") + .querySelector(".settings-help-popover"); + if (!popover) return; + var isVisible = popover.style.display !== "none"; + // Close any other open popovers and reset their buttons + _closeAllSettingsHelp(popover); + popover.style.display = isVisible ? "none" : ""; + btn.setAttribute("aria-expanded", isVisible ? "false" : "true"); +} + +function _closeAllSettingsHelp(except) { + var allOpen = document.querySelectorAll('.settings-help-popover[style=""]'); + for (var i = 0; i < allOpen.length; i++) { + if (allOpen[i] !== except) { + allOpen[i].style.display = "none"; + var col = allOpen[i].closest(".settings-label-col"); + if (col) { + var helpBtn = col.querySelector(".settings-help-btn"); + if (helpBtn) helpBtn.setAttribute("aria-expanded", "false"); + } + } + } +} + +function _onSettingsHeaderKey(e, el) { + if ((e.key === "Enter" || e.key === " ") && !e.repeat) { + e.preventDefault(); + _toggleSettingsSection(el); + } +} + +function _toggleSettingsSection(headerEl) { + var section = headerEl.parentElement; + if (section.hasAttribute("data-collapsed")) { + section.removeAttribute("data-collapsed"); + headerEl.setAttribute("aria-expanded", "true"); + } else { + section.setAttribute("data-collapsed", ""); + headerEl.setAttribute("aria-expanded", "false"); + } +} + +function _onSettingChange(key) { + var inp = document.querySelector('[data-setting-key="' + key + '"]'); + var saveBtn = document.querySelector('[data-save-key="' + key + '"]'); + if (!inp || !saveBtn) return; + + var current; + if (inp.type === "checkbox") { + current = inp.checked; + } else { + current = inp.value; + } + + var orig = _settingsOriginal[key]; + var dirty; + if (inp.type === "checkbox") { + dirty = current !== orig; + } else if (inp.type === "number" && current !== "" && orig !== "") { + // Compare numerically to avoid false positives (0.1 vs 0.10) + dirty = Number(current) !== Number(orig); + } else { + dirty = String(current) !== String(orig); + } + + // Disable save for empty number fields (server will reject) + var emptyNumber = inp.type === "number" && current === ""; + if (dirty && !emptyNumber) { + saveBtn.classList.add("visible"); + } else { + saveBtn.classList.remove("visible"); + } + + // Show/hide restart badge alongside dirty state (but keep it if already saved) + var restartBadge = document.querySelector('[data-restart-key="' + key + '"]'); + if (restartBadge && !restartBadge.classList.contains("saved")) { + restartBadge.classList.toggle("visible", dirty); + } +} + +function _saveSettingValue(key) { + var inp = document.querySelector('[data-setting-key="' + key + '"]'); + var saveBtn = document.querySelector('[data-save-key="' + key + '"]'); + if (!inp) return; + + var value; + if (inp.type === "checkbox") { + value = inp.checked; + } else if (inp.type === "number") { + if (inp.value === "") { + showToast("Value is required"); + return; + } + value = Number(inp.value); + } else { + value = inp.value; + } + + if (saveBtn) { + saveBtn.textContent = "saving\u2026"; + saveBtn.disabled = true; + } + + authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: value }), + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Save failed"); + }); + return r.json(); + }) + .then(function () { + // Update original so dirty detection resets + if (inp.type === "checkbox") { + _settingsOriginal[key] = inp.checked; + } else { + _settingsOriginal[key] = inp.value; + } + if (saveBtn) { + saveBtn.textContent = "save"; + saveBtn.disabled = false; + saveBtn.classList.remove("visible"); + } + + // Update source badge to "storage" + var row = document.querySelector('[data-row-key="' + key + '"]'); + if (row) { + var badge = row.querySelector(".scope-badge"); + if (badge) { + badge.className = "scope-badge scope-write"; + badge.textContent = "storage"; + } + // Add reset button if not present + if (!row.querySelector('[data-reset-key="' + key + '"]')) { + var actions = row.querySelector(".settings-actions"); + if (actions) { + var resetBtn = document.createElement("button"); + resetBtn.className = "settings-reset-btn"; + resetBtn.setAttribute("data-reset-key", key); + resetBtn.textContent = "reset"; + resetBtn.onclick = function () { + _resetSetting(key); + }; + actions.appendChild(resetBtn); + } + } + } + + // Show restart badge post-save (stays until page reload = restart) + var restartBadge = document.querySelector( + '[data-restart-key="' + key + '"]', + ); + if (restartBadge) { + restartBadge.classList.add("visible"); + restartBadge.classList.add("saved"); + } + + // Brief row flash for visual feedback + if (row) { + row.style.background = "var(--accent-glow)"; + setTimeout(function () { + row.style.background = ""; + }, 600); + } + + showToast( + "Saved " + key + (restartBadge ? " \u2014 restart required" : ""), + ); + }) + .catch(function (err) { + if (saveBtn) { + saveBtn.textContent = "save"; + saveBtn.disabled = false; + } + showToast("Error: " + (err.message || err)); + }); +} + +function _resetSetting(key) { + showConfirmModal( + "Reset Setting", + "Reset \u2018" + + key + + "\u2019 to its default value? The stored override will be removed.", + "Reset", + function () { + authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), { + method: "DELETE", + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Reset failed"); + }); + showToast("Reset " + key + " to default"); + loadSettings(); + }) + .catch(function (err) { + showToast("Error: " + (err.message || err)); + }); + }, + ); } // --------------------------------------------------------------------------- diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 4ebeaf43..2ded62cc 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -386,9 +386,10 @@ diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css index d229e644..670e5158 100644 --- a/turnstone/console/static/style.css +++ b/turnstone/console/static/style.css @@ -747,6 +747,10 @@ padding: 4px 0; overflow-y: auto; order: 1; + align-self: flex-start; + position: sticky; + top: 0; + max-height: 100vh; } /* Group labels */ @@ -1593,6 +1597,260 @@ .perm-grid { grid-template-columns: 1fr; } } +/* ========================================================================== + Settings tab — form editor + ========================================================================== */ +.settings-section { margin-bottom: 12px; } +.settings-section-header { + cursor: pointer; + padding: 8px 12px; + font-family: var(--font-display); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-dim); + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + user-select: none; +} +.settings-section-header:hover { color: var(--fg); } +.settings-section-header::after { content: "\25BE"; margin-left: 8px; font-size: 11px; } +.settings-section[data-collapsed] .settings-section-body { display: none; } +.settings-section[data-collapsed] .settings-section-header::after { content: "\25B8"; } +/* Setting row — 3-column grid */ +.settings-row { + display: grid; + grid-template-columns: 200px 1fr auto; + gap: 8px 16px; + padding: 8px 12px; + align-items: center; + border-bottom: 1px solid var(--border); +} +.settings-row:hover { background: var(--row-alt, rgba(255,255,255,0.015)); } + +/* Label column */ +.settings-label-col { min-width: 0; } +.settings-label { font-size: 12px; color: var(--fg); font-family: var(--font-mono); } +.settings-desc { font-size: 10px; color: var(--fg-dim); margin-top: 2px; line-height: 1.3; } + +/* Input column */ +.settings-input input[type="text"], +.settings-input input[type="number"], +.settings-input select { + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 3px; + padding: 4px 8px; + font-family: var(--font-mono); + font-size: 12px; + width: 100%; + max-width: 300px; + box-sizing: border-box; +} +/* Hide number spin buttons (Firefox + WebKit) */ +.settings-input input[type="number"] { -moz-appearance: textfield; } +.settings-input input[type="number"]::-webkit-inner-spin-button, +.settings-input input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } + +.settings-input input:focus, +.settings-input select:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px var(--accent-dim); +} +.settings-input select { + appearance: none; + padding-right: 24px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; +} + +/* Bool toggle */ +.settings-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + cursor: pointer; +} +.settings-toggle input { opacity: 0; width: 0; height: 0; position: absolute; } +.settings-toggle-slider { + position: absolute; + inset: 0; + background: var(--bg-highlight); + border: 1px solid var(--border); + border-radius: 10px; + transition: background 0.2s; +} +.settings-toggle-slider::before { + content: ""; + position: absolute; + width: 14px; + height: 14px; + left: 2px; + bottom: 2px; + background: var(--fg-dim); + border-radius: 50%; + transition: transform 0.2s, background 0.2s; +} +.settings-toggle input:checked + .settings-toggle-slider { + background: var(--accent-dim); + border-color: var(--accent); +} +.settings-toggle input:checked + .settings-toggle-slider::before { + transform: translateX(16px); + background: var(--accent); +} +.settings-toggle input:focus-visible + .settings-toggle-slider { + box-shadow: 0 0 0 3px var(--accent-dim); +} + +/* Actions column */ +.settings-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } + +/* Save button */ +.settings-save-btn { + visibility: hidden; + opacity: 0; + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + padding: 2px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + border: 1px solid var(--accent); + color: var(--accent); + background: none; + transition: opacity 0.15s, visibility 0s 0.15s; +} +.settings-save-btn.visible { visibility: visible; opacity: 0.8; transition: opacity 0.15s, visibility 0s; } +.settings-save-btn.visible:hover { opacity: 1; background: var(--accent-dim); } +.settings-save-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.settings-save-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Reset button */ +.settings-reset-btn { + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + padding: 2px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + border: 1px solid var(--border); + color: var(--fg-dim); + background: none; + opacity: 0.7; + transition: opacity 0.15s; +} +.settings-reset-btn:hover { opacity: 1; color: var(--red); border-color: var(--red); } +.settings-reset-btn:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; } + +/* Badges */ +.settings-badge-default { color: var(--fg-dim); border-color: var(--border); } +.settings-restart-badge { + display: none; + font-family: var(--font-display); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--yellow); + padding: 1px 4px; + border: 1px solid var(--yellow-glow); + border-radius: 2px; +} +.settings-restart-badge.visible { display: inline-block; } +.settings-restart-badge.saved { background: var(--yellow-glow); } + +/* Help tooltip */ +.settings-help-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid var(--accent); + background: var(--accent-dim); + color: var(--accent); + font-size: 10px; + font-weight: 600; + font-family: var(--font-display); + cursor: pointer; + vertical-align: middle; + margin-left: 4px; + padding: 0; + line-height: 1; + position: relative; + transition: background 0.15s, color 0.15s; +} +/* Expand tap target to ~26px without changing visual size */ +.settings-help-btn::before { content: ""; position: absolute; inset: -5px; } +.settings-help-btn:hover { background: var(--accent); color: var(--bg); } +.settings-help-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.settings-help-popover { + margin-top: 4px; + padding: 6px 8px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-left: 2px solid var(--accent); + border-radius: 3px; + font-size: 11px; + line-height: 1.4; + color: var(--fg); +} +.settings-help-text { color: var(--fg); } +.settings-help-ref { + color: var(--accent); + text-decoration: none; + font-size: 11px; + font-family: var(--font-display); + white-space: nowrap; +} +.settings-help-ref:hover { text-decoration: underline; } + +/* Secret field — match input box height for grid alignment */ +.settings-secret { + color: var(--fg-dim); + font-style: italic; + font-size: 11px; + cursor: not-allowed; + display: inline-block; + padding: 4px 0; + border: 1px solid transparent; /* invisible border matches input's 1px border */ +} + +/* Docs link in toolbar */ +.settings-docs-link { + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + color: var(--fg-dim); + text-decoration: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 2px 8px; + margin-left: auto; + transition: color 0.15s, border-color 0.15s; +} +.settings-docs-link:hover { color: var(--accent); border-color: var(--accent-dim); } +.settings-docs-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Settings mobile */ +@media (max-width: 700px) { + .settings-row { grid-template-columns: 1fr; gap: 4px; } + .settings-desc { display: none; } + .settings-input input[type="text"], + .settings-input input[type="number"], + .settings-input select { max-width: 100%; } +} + /* ========================================================================== Reduced motion — console-specific ========================================================================== */ @@ -1604,6 +1862,8 @@ .dash-row.has-link::after, .node-group-header::before { transition: none; } #new-ws-box select, #new-ws-box input, #new-ws-buttons button { transition: none; } .admin-nav, .admin-row, .admin-btn-danger, .admin-btn-action { transition: none; } + .settings-toggle-slider, .settings-toggle-slider::before { transition: none; } + .settings-save-btn, .settings-reset-btn, .settings-docs-link, .settings-help-btn { transition: none; } .admin-sidebar, .admin-sidebar-backdrop { transition: none; } #view-admin { animation: none; } .admin-action-btn, .modal-cancel, .modal-submit { transition: none; } diff --git a/turnstone/core/model_registry.py b/turnstone/core/model_registry.py index b71bfec7..5af96b9b 100644 --- a/turnstone/core/model_registry.py +++ b/turnstone/core/model_registry.py @@ -31,7 +31,7 @@ class ModelConfig: base_url: str api_key: str = field(repr=False) model: str - context_window: int = 131072 + context_window: int = 32768 provider: str = "openai" capabilities: dict[str, Any] = field(default_factory=dict) @@ -150,7 +150,7 @@ def load_model_registry( base_url: str, api_key: str, model: str, - context_window: int = 131072, + context_window: int = 32768, provider: str = "openai", ) -> ModelRegistry: """Build a ModelRegistry from CLI args and ``config.toml``. diff --git a/turnstone/core/session.py b/turnstone/core/session.py index f6532ca9..384c3774 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -219,7 +219,7 @@ class ChatSession: max_tokens: int, tool_timeout: int, reasoning_effort: str = "medium", - context_window: int = 131072, + context_window: int = 32768, compact_max_tokens: int = 32768, auto_compact_pct: float = 0.8, agent_max_turns: int = -1, @@ -255,7 +255,7 @@ class ChatSession: self.max_tokens = max_tokens self.tool_timeout = tool_timeout self.reasoning_effort = reasoning_effort - self.context_window = context_window + self.context_window = context_window if context_window > 0 else 32768 self.compact_max_tokens = compact_max_tokens self.auto_compact_pct = auto_compact_pct self.agent_max_turns = agent_max_turns diff --git a/turnstone/core/settings_registry.py b/turnstone/core/settings_registry.py index 41874d66..b3b01a3a 100644 --- a/turnstone/core/settings_registry.py +++ b/turnstone/core/settings_registry.py @@ -27,48 +27,76 @@ class SettingDef: max_value: float | None = None choices: list[str] | None = field(default=None, hash=False) restart_required: bool = False + help: str = "" # plain-English explanation for non-experts + reference_url: str = "" # link to arXiv, docs, or provider reference def _build_registry() -> dict[str, SettingDef]: """Build the settings registry from declarative definitions.""" defs: list[SettingDef] = [ # -- model ---------------------------------------------------------- - SettingDef("model.name", "str", "", "Default model name", "model"), + SettingDef( + "model.name", + "str", + "", + "Default model name (empty = use provider default)", + "model", + help="Which AI model to use for conversations. Leave empty to use the provider's default.", + ), SettingDef( "model.temperature", "float", 0.5, - "Sampling temperature", + "Sampling temperature (ignored by models that don't support it, e.g. o-series)", "model", min_value=0.0, max_value=2.0, + help="Controls randomness in responses. Lower values (0.0\u20130.3) give focused, " + "deterministic output; higher values (0.7\u20131.5) make responses more creative and varied.", + reference_url="https://arxiv.org/abs/1904.09751", ), SettingDef( "model.max_tokens", "int", 32768, - "Max output tokens", + "Max output tokens per response", "model", min_value=1, + help="Upper limit on how long each response can be. One token is roughly 4 characters " + "of English text. Higher values allow longer responses but cost more.", ), SettingDef( "model.reasoning_effort", "str", "medium", - "Reasoning effort level", + "Reasoning effort level (only applies to models with reasoning support)", "model", choices=["", "none", "minimal", "low", "medium", "high", "xhigh", "max"], + help="How much internal \u2018thinking\u2019 the model does before responding. Higher effort " + "improves quality on complex tasks but is slower and uses more tokens. Not all models " + "support this \u2014 it is silently ignored when unsupported.", ), SettingDef( "model.context_window", "int", - 131072, - "Context window size in tokens", + 0, + "Context window size in tokens (0 = auto-detect from model)", "model", - min_value=1024, + min_value=0, + help="How much conversation history the model can see at once, measured in tokens " + "(~4 characters each). Set to 0 to auto-detect from the model. Only override this " + "if auto-detection fails (common with local models).", ), # -- session -------------------------------------------------------- - SettingDef("session.instructions", "str", "", "Default system instructions", "session"), + SettingDef( + "session.instructions", + "str", + "", + "Default system instructions (applied before prompt templates)", + "session", + help="Text that tells the model how to behave (e.g. \u2018You are a helpful coding assistant\u2019). " + "Applied to every conversation before any prompt templates.", + ), SettingDef( "session.retention_days", "int", @@ -84,6 +112,8 @@ def _build_registry() -> dict[str, SettingDef]: "Max tokens for compaction summary", "session", min_value=0, + help="When conversation history is compacted (summarized to save space), this limits " + "how long the summary can be.", ), SettingDef( "session.auto_compact_pct", @@ -93,6 +123,9 @@ def _build_registry() -> dict[str, SettingDef]: "session", min_value=0.0, max_value=1.0, + help="Automatically summarize older messages when the conversation fills this percentage " + "of the context window. For example, 0.8 means compact when 80% full. This prevents " + "conversations from hitting the context limit and losing information.", ), # -- tools ---------------------------------------------------------- SettingDef( @@ -111,6 +144,8 @@ def _build_registry() -> dict[str, SettingDef]: "Tool output truncation limit in chars (0 = auto, 50% of context window)", "tools", min_value=0, + help="Limits how much output from a tool (e.g. a long command result) gets sent back " + "to the model. Prevents large outputs from consuming the entire context window.", ), SettingDef( "tools.agent_max_turns", @@ -120,21 +155,35 @@ def _build_registry() -> dict[str, SettingDef]: "tools", min_value=-1, max_value=200, + help="Limits how many back-and-forth steps a sub-agent can take when executing a plan " + "or task. Prevents runaway agents from consuming excessive tokens.", + ), + SettingDef( + "tools.skip_permissions", + "bool", + False, + "Skip tool approval prompts", + "tools", + help="When enabled, all tool calls are auto-approved without asking the user. " + "Use with caution \u2014 the model will be able to run commands, write files, " + "and take actions without human review.", ), - SettingDef("tools.skip_permissions", "bool", False, "Skip tool approval prompts", "tools"), SettingDef( "tools.search", "str", "auto", - "Tool search mode", + "Tool search mode (auto = enable when tool count exceeds threshold)", "tools", choices=["auto", "on", "off"], + help="When many tools are available (e.g. from MCP servers), the model sees only " + "a subset and searches for the right tool when needed. This reduces cost and " + "improves accuracy by avoiding information overload.", ), SettingDef( "tools.search_threshold", "int", 20, - "Min tool count to enable search", + "Min tool count to activate search in auto mode", "tools", min_value=1, ), @@ -156,6 +205,9 @@ def _build_registry() -> dict[str, SettingDef]: "server", min_value=0, restart_required=True, + help="A workstream is an independent conversation thread. Idle workstreams are " + "evicted (paused and saved) after this timeout to free up resources. They can " + "be resumed later.", ), SettingDef( "server.max_workstreams", @@ -165,6 +217,8 @@ def _build_registry() -> dict[str, SettingDef]: "server", min_value=1, restart_required=True, + help="Maximum number of active conversation threads on this server node. " + "When the limit is reached, the oldest idle workstream is evicted to make room.", ), # -- mcp ------------------------------------------------------------ SettingDef( @@ -174,6 +228,9 @@ def _build_registry() -> dict[str, SettingDef]: "Path to MCP server configuration file", "mcp", restart_required=True, + help="Model Context Protocol (MCP) lets the AI connect to external tool servers. " + "This points to a JSON file listing which MCP servers to connect to on startup.", + reference_url="https://modelcontextprotocol.io", ), SettingDef( "mcp.refresh_interval", @@ -191,6 +248,8 @@ def _build_registry() -> dict[str, SettingDef]: "Enable per-IP rate limiting", "ratelimit", restart_required=True, + help="Limits how fast any single user can make requests, preventing abuse or " + "accidental overload. Uses a token bucket algorithm.", ), SettingDef( "ratelimit.requests_per_second", @@ -209,6 +268,9 @@ def _build_registry() -> dict[str, SettingDef]: "ratelimit", min_value=1, restart_required=True, + help="Allows short bursts of requests above the rate limit. For example, a user " + "can send 20 rapid requests before being throttled, then must stay under the " + "per-second limit.", ), SettingDef( "ratelimit.trusted_proxies", @@ -217,6 +279,8 @@ def _build_registry() -> dict[str, SettingDef]: "Trusted proxy CIDRs for X-Forwarded-For parsing (comma-separated)", "ratelimit", restart_required=True, + help="If your server is behind a load balancer or reverse proxy, list its IP " + "ranges here so rate limiting applies to the real client IP, not the proxy.", ), # -- health --------------------------------------------------------- SettingDef( @@ -226,6 +290,7 @@ def _build_registry() -> dict[str, SettingDef]: "Backend health probe interval in seconds", "health", min_value=5, + help="How often to check whether the AI model backend (e.g. OpenAI API) is reachable.", ), SettingDef( "health.backend_probe_timeout", @@ -242,6 +307,10 @@ def _build_registry() -> dict[str, SettingDef]: "Consecutive failures before circuit opens", "health", min_value=1, + help="If the AI backend fails this many times in a row, the circuit breaker trips " + "and stops sending requests for a cooldown period. This prevents cascading failures " + "and wasted API calls when the backend is down.", + reference_url="https://martinfowler.com/bliki/CircuitBreaker.html", ), SettingDef( "health.circuit_breaker_cooldown", @@ -250,15 +319,29 @@ def _build_registry() -> dict[str, SettingDef]: "Seconds before half-open retry", "health", min_value=5, + help="After the circuit breaker trips, wait this long before sending a single test " + "request to see if the backend has recovered.", ), # -- judge ---------------------------------------------------------- - SettingDef("judge.enabled", "bool", True, "Enable intent validation judge", "judge"), + SettingDef( + "judge.enabled", + "bool", + True, + "Enable intent validation judge", + "judge", + help="Before the AI runs a tool (shell command, file write, etc.), a second evaluation " + "assesses whether the action is safe. This shows a risk verdict alongside the " + "approval prompt so you can make informed decisions.", + ), SettingDef( "judge.model", "str", "", "Model for LLM judge (empty = same as session)", "judge", + help="The judge can use a different AI model than the main conversation. Leave empty " + "to use the same model (self-consistency), or specify a different model for " + "cross-model evaluation.", ), SettingDef("judge.provider", "str", "", "Provider for judge model", "judge"), SettingDef("judge.base_url", "str", "", "Base URL for judge model API", "judge"), @@ -278,6 +361,9 @@ def _build_registry() -> dict[str, SettingDef]: "judge", min_value=0.0, max_value=1.0, + help="The judge reports how confident it is in its safety assessment (0\u20131). " + "Verdicts below this threshold are flagged as low-confidence. Future versions " + "can use this for auto-approval of high-confidence safe verdicts.", ), SettingDef( "judge.max_context_ratio", @@ -287,6 +373,8 @@ def _build_registry() -> dict[str, SettingDef]: "judge", min_value=0.1, max_value=1.0, + help="How much of the conversation history to show the judge. Lower values are cheaper " + "and faster but give the judge less context to evaluate intent.", ), SettingDef( "judge.timeout", @@ -297,17 +385,26 @@ def _build_registry() -> dict[str, SettingDef]: min_value=5.0, ), SettingDef( - "judge.read_only_tools", "bool", True, "Restrict judge to read-only tools", "judge" + "judge.read_only_tools", + "bool", + True, + "Restrict judge to read-only tools", + "judge", + help="The judge can inspect files and directories to gather evidence for its verdict. " + "When enabled, it can only read \u2014 not modify \u2014 the filesystem.", ), # -- memory --------------------------------------------------------- SettingDef( "memory.relevance_k", "int", 5, - "Top-K memories for BM25 injection", + "Top-K memories for relevance injection", "memory", min_value=1, max_value=50, + help="How many saved memories to automatically include in each conversation. " + "Memories are ranked by text relevance and the top K are injected into the " + "model's context so it can recall past information.", ), SettingDef( "memory.fetch_limit", @@ -317,6 +414,8 @@ def _build_registry() -> dict[str, SettingDef]: "memory", min_value=1, max_value=500, + help="How many memories to load from the database for ranking. The top relevance_k " + "are selected from this pool. Higher values find better matches but cost more.", ), SettingDef( "memory.max_content", @@ -334,8 +433,20 @@ def _build_registry() -> dict[str, SettingDef]: "Seconds between metacognitive nudges", "memory", min_value=0, + help="Metacognitive nudges are gentle reminders to the AI to save useful information " + "from the conversation (e.g. user preferences, project decisions). This controls " + "the minimum time between nudges to avoid being repetitive.", + ), + SettingDef( + "memory.nudges", + "bool", + True, + "Enable metacognitive nudges", + "memory", + help="When enabled, the system periodically reminds the AI to save important " + "information from conversations into long-term memory. This helps the AI " + "remember context across separate conversations.", ), - SettingDef("memory.nudges", "bool", True, "Enable metacognitive nudges", "memory"), ] return {d.key: d for d in defs} diff --git a/turnstone/server.py b/turnstone/server.py index f9d456e8..d2a9e91f 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -1913,12 +1913,15 @@ def main() -> None: model, detected_ctx = detect_model(client, provider=provider_name) - # Use detected context window, fall back to ConfigStore default + # Use detected context window, fall back to ConfigStore override or 32768 + cfg_ctx = config_store.get("model.context_window") if detected_ctx: context_window = detected_ctx log.info("Context window: %s (detected from backend)", f"{context_window:,}") + elif cfg_ctx: # 0 = auto-detect (no override) + context_window = cfg_ctx else: - context_window = config_store.get("model.context_window") + context_window = 32768 # Build model registry (reads [models.*] sections from config.toml) from turnstone.core.model_registry import load_model_registry