Compare commits

...

9 Commits

Author SHA1 Message Date
Patrick Buckley c5cdfc8f44 chore: bump version to 0.6.1 2026-03-14 13:19:40 -07:00
Patrick Buckley 8895bf07eb feat: admin Settings tab — form-based editor replacing "coming soon" … (#60)
* feat: admin Settings tab — form-based editor replacing "coming soon" stub

Section-grouped layout with collapsible headers for all ~40 ConfigStore
settings (model, session, tools, server, mcp, ratelimit, health, judge,
memory). Type-appropriate inputs: CSS toggle for bools, number with
min/max/step, select for choices, text for strings. Secret fields shown
read-only. Source badge (storage/default), amber restart indicator.

Inline save per field with dirty detection, row flash on success, reset
to default via styled confirm modal. Full WCAG keyboard accessibility
(Enter/Space on section headers, aria-labels, focus-visible). Mobile
responsive single-column at <700px. Reduced-motion safe.

* fix: Settings tab polish — help tooltips, context_window auto-detect, UX fixes

Settings UI:
- Help tooltips: ? button on ~25 settings with plain-English explanations
  and optional reference links (arXiv, Fowler, MCP spec). Click to toggle
  popover, Escape to dismiss, aria-expanded for accessibility.
- Sections start collapsed for scannable overview.
- Restart badge: hidden by default, shows when dirty, persists after save
  with amber glow. Positioned left of source badge.
- Secret row alignment fixed (transparent border matches input box model).
- Docs link in toolbar → Swagger UI Settings section.
- Number inputs: spin buttons hidden (Firefox/WebKit), empty value guard,
  numeric dirty detection (0.1 vs 0.10 no longer false positive).
- Secret reset button enabled when source=storage (clear legacy overrides).
- Space key repeat guard on section headers.
- Sidebar: sticky + max-height:100vh, no longer stretches with content.

Backend:
- context_window default changed from 131072 to 0 (auto-detect). Fallback
  lowered from 131K to 32K (realistic for local models when detection fails).
  Session normalizes 0→32768 defensively.
- Settings registry: help + reference_url fields on SettingDef, richer
  descriptions for model/session/tools/judge/memory settings.
- Schema API includes help + reference_url.
- Bootstrap system prompt: added Runtime Settings section.

Docs: tab counts updated to 13 across README, architecture, console, governance.
2026-03-14 13:12:40 -07:00
Patrick Buckley 101afd84da feat: database-backed settings (ConfigStore) with admin API (#59)
* feat: database-backed settings (ConfigStore) with admin API

Replace config.toml for non-bootstrap settings on the server with a
database-backed ConfigStore.  ~40 settings across model, session,
tools, server, mcp, ratelimit, health, judge, and memory sections are
now managed via the admin Settings API.  CLI flags for these settings
removed from the server entry point (CLI standalone tool unchanged).

Storage: system_settings table (migration 015) with composite PK
(key, node_id) for per-node overrides.  ON CONFLICT upsert in both
SQLite and PostgreSQL.  admin.settings permission granted to
builtin-admin role.

Settings registry (settings_registry.py): code-defined catalog of
all known settings with types, defaults, validation, descriptions.
Registry defaults aligned with previous argparse defaults.

ConfigStore (config_store.py): thread-safe in-memory cache loaded
from storage on init.  Lock-free reads via dict snapshot swap.
reload() for hot-reload via internal endpoint.

Secret settings (judge.api_key) blocked from write via admin API
(403) — must be configured via config.toml or env vars.

warn_migrated_settings() logs warnings for config.toml keys that
overlap with ConfigStore-managed settings.

Console admin API: GET /v1/api/admin/settings (list with effective
values), GET .../schema (registry catalog), PUT .../{key} (update),
DELETE .../{key} (reset to default).  Audit trail on mutations.

MQ: ConfigChangeEvent for cross-node cache invalidation (emission
from console deferred to bridge integration).

Python + TypeScript SDK methods.  63 new tests.  Feature docs at
docs/settings.md, PlantUML diagram 24-settings-architecture.

* fix: address PR review — config-reload scope, registry defaults, doc alignment

- config-reload endpoint requires approve scope (was write)
- config-reload handler is sync def (avoids blocking event loop)
- reasoning_effort passes empty string through (removes `or "medium"`)
- ratelimit.requests_per_second changed to float (matches RateLimiter)
- session.retention_days allows 0 (disable pruning)
- ratelimit.trusted_proxies added to registry + wired in server
- admin_list_settings filters to global settings only (no node_id ambiguity)
- admin_update_setting validates "value" key presence (400 if missing)
- Console config change fans out reload to nodes directly (no MQ dep)
- Docs aligned with actual API response shapes and masking ("***")
2026-03-14 11:42:18 -07:00
Patrick Buckley efd98712e9 feat: [memory] admin panel Memories tab — browse, search, inspect, de… (#57)
* feat: [memory] admin panel Memories tab — browse, search, inspect, delete

Add 13th admin tab in the Observe group for cluster-wide memory
management.  List view with type/scope filter dropdowns and debounced
search input.  Detail modal shows full metadata grid and scrollable
content block.  Delete from both list row and detail modal with
confirmation and audit trail.

Permission-gated behind admin.memories.  Escape key, backdrop click,
and focus trap wired for the detail modal.  Mobile responsive: hides
description and updated columns below 700px.

* fix: memory detail modal — focus, delete safety, CSS shorthand order

Address Copilot review feedback: move focus to close button on modal
open for keyboard accessibility, disable delete button and clear stale
handler during loading/error states to prevent wrong-memory deletion,
and fix font shorthand/font-size ordering in toolbar filter styles.
2026-03-14 02:42:43 -07:00
Patrick Buckley 67f43a7ee0 feat: [memory] REST API endpoints + SDK methods + docs (#56)
* feat: [memory] REST API endpoints + SDK methods + docs

Server API (4 endpoints):
- GET /v1/api/memories — list with type/scope/scope_id/limit filters
- POST /v1/api/memories — save (upsert) with validation
- POST /v1/api/memories/search — search by query (read scope)
- DELETE /v1/api/memories/{name} — delete by name+scope

Console admin API (4 endpoints):
- GET /v1/api/admin/memories — list all memories
- GET /v1/api/admin/memories/search — search with ?q= param
- GET /v1/api/admin/memories/{memory_id} — get by ID
- DELETE /v1/api/admin/memories/{memory_id} — delete by ID with audit

Storage: add delete_structured_memory_by_id, add mem_type filter to
count_structured_memories. Auth: memory DELETE requires write scope,
admin.memories permission added to valid set + builtin-admin role.

Python SDK: list_memories, save_memory, search_memories, delete_memory
on both server (async+sync) and console (async+sync) clients.

TypeScript SDK: matching methods + types on both clients.

Pydantic schemas with Literal type/scope validation, OpenAPI endpoint
specs on both servers. 33 endpoint tests + 8 auth scope tests.

Docs: docs/memory.md feature guide, api-reference.md endpoint docs,
23-memory-architecture.puml diagram.

Also fixes stray `total: int` on CreateChannelUserRequest.

* fix: [memory] address PR review — cross-user scope, schema types, snapshots

Security: user-scoped memory endpoints now bind scope_id to the
authenticated user's identity.  Providing a mismatched scope_id
returns 403, preventing cross-user memory access on all 4 server
endpoints.

Schema: MemoryInfo response uses MemoryType/MemoryScope Literals.
SearchMemoriesRequest uses filter Literals (empty string allowed).
Limit query params declare schema_type="integer" for correct OpenAPI.

Regenerate sdk/typescript/openapi-{server,console}.json snapshots.
Update count_structured_memories docstring for mem_type param.
Fix fallback response to use normalized name after save.

6 new security tests for user-scope access control.
2026-03-14 02:28:47 -07:00
Patrick Buckley 2888e8ce0a feat: MCP cluster-ops example — reference MCP server + SDK implementa… (#55)
* feat: MCP cluster-ops example — reference MCP server + SDK implementation

Standalone MCP server under examples/mcp-cluster-ops/ that exposes
tools for executing commands across a Turnstone cluster via the MQ
client SDK. Serves as a reference implementation for both MCP server
patterns (FastMCP, lifespan, tool handlers) and TurnstoneClient usage.

4 tools: list_nodes, run_on_node, run_on_nodes, run_on_all_nodes.
Parallel dispatch via asyncio.gather, raw ToolResultEvent output
capture, UTF-8 safe truncation, input validation, concurrency caps.

35 tests, ruff clean, mypy --strict clean.

* fix: address review feedback on MCP cluster-ops example

- Remove REDIS_SSL support (RedisBroker doesn't accept ssl kwarg)
- Move max-nodes check from _dispatch_parallel into tool handlers
  for consistent error shape (always returns {"error": ...} object)
- Propagate KeyboardInterrupt/SystemExit from asyncio.gather instead
  of swallowing them as per-node failures
- Fix _truncate omitted bytes count to reflect actual bytes dropped
  after multi-byte boundary adjustment
- Apply strip/dedup to node IDs in run_on_all_nodes (matching
  run_on_nodes behavior)
- Add __name__ guard to __main__.py
- Fix misleading UTF-8 byte count comment in tests
2026-03-14 02:23:43 -07:00
Patrick Buckley d1a248b413 feat: [memory] config section — configurable relevance_k, fetch_limit… (#54)
* feat: [memory] config section — configurable relevance_k, fetch_limit, max_content, nudge_cooldown, nudges

MemoryConfig dataclass in memory_relevance.py, constructed from
config.toml [memory] section via argparse defaults. Replaces
hardcoded constants in session.py. Master nudges=false switch
disables all metacognitive prompting.

* fix: wire memory config into apply_config and correct error wording

Add "memory" to apply_config sections so [memory] config.toml values
actually propagate. Fix "byte limit" → "character limit" since
len(content) measures characters.
2026-03-14 00:51:27 -07:00
Patrick Buckley 723cad24bb feat: structured memory system — typed/scoped memories with BM25 rele… (#53)
* feat: structured memory system — typed/scoped memories with BM25 relevance and metacognitive prompting

Replace flat key-value memories table with structured_memories (migration 014).
Four memory types (user/project/feedback/reference), three scopes
(global/workstream/user). Consolidate remember/recall/forget into two tools:
memory (action-based: save/search/delete/list) and recall (conversation
history only).

BM25 relevance scoring (extracted to turnstone/core/bm25.py) selects top-5
memories for system message injection based on conversation context.
Metacognitive prompting injects ephemeral nudges after corrections, tool
denials, workstream resume, and completion signals.

Scope isolation enforced: system message injection and nudge counts filtered
to visible memories only (global + current workstream + authenticated user).
User scope requires authentication. Content capped at 32KB. ILIKE/LIKE
metacharacters escaped in both backends.

113 new tests (2053 total).

* fix: CI failure + copilot review feedback

- Fix time.monotonic() cooldown: use None sentinel instead of 0.0
  default (monotonic clock starts at boot, not epoch — fresh CI
  runners have uptime < 300s so cooldown check always triggered)
- Catch sa.exc.IntegrityError specifically in upsert instead of
  broad Exception (copilot review)
- Preserve existing description/type on upsert when caller doesn't
  explicitly set them (copilot review)
- Add last_accessed + access_count columns to schema/migration for
  future LRU/LFU eviction support
2026-03-13 21:21:09 -07:00
Patrick Buckley 73cacc8ad6 feat: admin panel — right-aligned sidebar navigation with two-column … (#52)
* feat: admin panel — right-aligned sidebar navigation with two-column modals

Replace the horizontal tab bar (11 tabs, overflowing on standard monitors)
with a grouped sidebar on the right side, matching the admin button's
position in the header for natural spatial flow.

Sidebar: 5 groups (Identity, Automation, Governance, Observe, System) with
12 nav items including new Settings stub. Always visible on desktop (180px),
off-canvas drawer on mobile (<700px) sliding from right with backdrop.

Admin button: toggle behavior (click again to return to overview), active
state with amber highlight + top accent line, aria-expanded management.

Breadcrumb: shows active tab ("Admin / Users", "Admin / Audit", etc).

Modals: WS Template and Schedule create/edit forms restructured into
two-column grid (820px) with "Identity"/"Model Config" and
"Schedule"/"Execution" column headings. All modals gain max-height: 85vh
+ overflow-y: auto safety net. Modal z-index bumped to 600 (above sidebar).

Also: "Tokens" renamed to "API Tokens", redundant "Server default"
placeholders removed from model config fields, view fade-in transition,
comprehensive ARIA (grouped sidebar, aria-hidden on mobile, focus return
on drawer close), reduced-motion support.

* fix: address Copilot review — aria-orientation, settings permission gate, inert sidebar

- Add aria-orientation="vertical" to sidebar tablist for assistive tech
- Gate Settings tab behind admin.users permission so empty-state logic
  works correctly when user has no admin permissions
- Use inert attribute on mobile sidebar when closed to prevent keyboard
  focus from reaching off-canvas controls
- Add resize listener to sync aria-hidden/inert when crossing the
  700px mobile breakpoint
2026-03-13 19:50:35 -07:00
85 changed files with 15186 additions and 1163 deletions
+4 -5
View File
@@ -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)
@@ -193,7 +193,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
## Tools
16 built-in tools, 2 agent tools, plus external tools via MCP:
15 built-in tools, 2 agent tools, plus external tools via MCP:
| Tool | Description | Auto-approved |
|------|-------------|:---:|
@@ -206,9 +206,8 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
| `man` | Read man pages | yes |
| `web_fetch` | Fetch URL content | |
| `web_search` | Web search (provider-native or Tavily) | |
| `remember` | Save persistent facts | yes |
| `recall` | Search memories and history | yes |
| `forget` | Remove a memory | yes |
| `memory` | Structured persistent memory (save/search/delete/list) | yes |
| `recall` | Search conversation history | yes |
| `notify` | Send notifications to linked channels | yes |
| `watch` | Periodic command polling with conditions | |
| `task` | Spawn autonomous sub-agent | |
+405
View File
@@ -940,6 +940,270 @@ Status code: `403`
---
### `GET /v1/api/memories`
List structured memories with optional filters. Requires `read` scope.
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|------------|--------|----------|---------|------------------------------|
| `type` | string | no | `""` | Filter by memory type (user, project, feedback, reference) |
| `scope` | string | no | `""` | Filter by scope (global, workstream, user) |
| `scope_id` | string | no | `""` | Scope qualifier. Auto-resolved for `scope=user` when auth is active. |
| `limit` | int | no | `100` | Max results (capped at 200) |
**Response:**
```json
{
"memories": [
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "The project uses a hexagonal architecture...",
"created": "2026-03-10T10:00:00",
"updated": "2026-03-12T14:30:00"
}
],
"total": 1
}
```
---
### `POST /v1/api/memories`
Save or upsert a structured memory. Requires `write` scope. Returns `201` on
create, `200` on update.
**Request body:**
```json
{
"name": "deployment_process",
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
"description": "CI/CD deployment workflow",
"type": "project",
"scope": "global",
"scope_id": ""
}
```
| Field | Type | Required | Default | Description |
|--------------|--------|----------|-------------|--------------------------------------|
| `name` | string | yes | -- | Memory name (max 256 chars) |
| `content` | string | yes | -- | Memory content (max 65536 chars) |
| `description`| string | no | `""` | Short description for search ranking |
| `type` | string | no | `"project"` | One of: user, project, feedback, reference |
| `scope` | string | no | `"global"` | One of: global, workstream, user |
| `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) |
**Response (created):** `201`
```json
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "deployment_process",
"description": "CI/CD deployment workflow",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "Deploy via GitHub Actions...",
"created": "2026-03-14T10:00:00",
"updated": "2026-03-14T10:00:00"
}
```
**Error responses:**
| Status | Condition |
|--------|--------------------------------------------------------|
| 400 | Missing name, empty content, invalid type/scope, name too long, content too long |
---
### `POST /v1/api/memories/search`
Search memories by query. Uses POST for the request body but is non-mutating
(requires only `read` scope).
**Request body:**
```json
{
"query": "authentication",
"type": "project",
"scope": "",
"limit": 20
}
```
| Field | Type | Required | Default | Description |
|------------|--------|----------|---------|--------------------------------|
| `query` | string | yes | -- | Search query |
| `type` | string | no | `""` | Filter by type |
| `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `20` | Max results (capped at 50) |
**Response:**
```json
{
"memories": [
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "auth_patterns",
"description": "Authentication architecture",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "JWT tokens with HS256...",
"created": "2026-03-10T10:00:00",
"updated": "2026-03-12T14:30:00"
}
],
"total": 1
}
```
**Error:** `400` with `{"error": "query is required"}` if `query` is empty.
---
### `DELETE /v1/api/memories/{name}`
Delete a memory by name and scope. Requires `write` scope.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `name` | string | Memory name |
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|------------|--------|----------|------------|---------------------|
| `scope` | string | no | `"global"` | Scope of the memory |
| `scope_id` | string | no | `""` | Scope qualifier |
**Response (success):** `200`
```json
{"status": "ok", "name": "deployment_process"}
```
**Error (not found):** `404`
```json
{"error": "Memory 'deployment_process' not found"}
```
---
### `GET /v1/api/admin/memories` (Console)
List structured memories across all scopes. Requires `admin.memories`
permission.
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|------------|--------|----------|---------|------------------------------|
| `type` | string | no | `""` | Filter by type |
| `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `100` | Max results (capped at 200) |
**Response:** `200` -- same schema as `GET /v1/api/memories`.
---
### `GET /v1/api/admin/memories/search` (Console)
Search memories by query. Requires `admin.memories` permission.
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|------------|--------|----------|---------|-------------------------------|
| `q` | string | yes | -- | Search query |
| `type` | string | no | `""` | Filter by type |
| `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `20` | Max results (capped at 50) |
**Response:** `200` -- same schema as `GET /v1/api/memories`.
**Error:** `400` with `{"error": "q is required"}` if `q` is empty.
---
### `GET /v1/api/admin/memories/{memory_id}` (Console)
Get a single memory by ID. Requires `admin.memories` permission.
**Path parameters:**
| Parameter | Type | Description |
|-------------|--------|------------------------|
| `memory_id` | string | Memory UUID |
**Response (success):** `200`
```json
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "The project uses...",
"created": "2026-03-10T10:00:00",
"updated": "2026-03-12T14:30:00"
}
```
**Error (not found):** `404`
```json
{"error": "Memory not found"}
```
---
### `DELETE /v1/api/admin/memories/{memory_id}` (Console)
Delete a memory by ID. Records an audit event (`memory.delete`). Requires
`admin.memories` permission.
**Path parameters:**
| Parameter | Type | Description |
|-------------|--------|------------------------|
| `memory_id` | string | Memory UUID |
**Response (success):** `200`
```json
{"status": "ok"}
```
**Error (not found):** `404`
```json
{"error": "Memory not found"}
```
---
### `GET /v1/api/admin/verdicts` (Console)
List intent validation verdicts from the `intent_verdicts` table. This endpoint
@@ -986,6 +1250,147 @@ is on the **console** server and requires the `admin.judge` permission.
---
### `GET /v1/api/admin/settings` (Console)
List all settings with their effective values, defaults, and metadata. Requires
the `admin.settings` permission.
**Response:** `200`
```json
{
"settings": [
{
"key": "model.temperature",
"value": 0.7,
"source": "storage",
"type": "float",
"description": "Sampling temperature",
"section": "model",
"is_secret": false,
"node_id": "",
"changed_by": "admin",
"updated": "2026-03-14T10:00:00",
"restart_required": false
}
]
}
```
---
### `GET /v1/api/admin/settings/schema` (Console)
Return the full registry catalog (all defined settings with metadata). Requires
the `admin.settings` permission. Useful for building dynamic admin UIs.
**Response:** `200`
```json
{
"schema": [
{
"key": "model.temperature",
"type": "float",
"default": 0.5,
"description": "Sampling temperature",
"section": "model",
"is_secret": false,
"min_value": 0.0,
"max_value": 2.0,
"choices": null,
"restart_required": false
}
]
}
```
---
### `PUT /v1/api/admin/settings/{key}` (Console)
Update a setting. Requires the `admin.settings` permission. The value is
validated against the registry definition (type coercion, range checks, choices).
Secret settings (`is_secret=true`) return `403`.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|-------------|
| `key` | string | Dotted setting key (e.g. `model.temperature`) |
**Request body:**
```json
{
"value": 0.7,
"node_id": ""
}
```
| Field | Type | Required | Default | Description |
|-----------|--------|----------|---------|-------------|
| `value` | any | yes | -- | New value (type-coerced against registry) |
| `node_id` | string | no | `""` | Node ID for per-node override |
**Response (success):** `200`
```json
{
"key": "model.temperature",
"value": 0.7,
"source": "storage",
"type": "float",
"description": "Sampling temperature",
"section": "model",
"is_secret": false,
"node_id": "",
"changed_by": "admin",
"updated": "",
"restart_required": false
}
```
**Errors:**
| Status | Condition |
|--------|-----------|
| 400 | Unknown key, invalid value, type mismatch, out of range, missing `value` field |
| 403 | Secret setting (must use config.toml or env) |
---
### `DELETE /v1/api/admin/settings/{key}` (Console)
Reset a setting to its registry default by removing it from storage. Requires
the `admin.settings` permission.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|-------------|
| `key` | string | Dotted setting key |
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|--------|----------|---------|-------------|
| `node_id` | string | no | `""` | Node ID (empty = global) |
**Response (success):** `200`
```json
{"status": "ok", "key": "model.temperature", "default": 0.5}
```
**Response (not found):** `404`
```json
{"error": "Setting 'model.temperature' has no stored value"}
```
---
### `OPTIONS` (any path)
Handles CORS preflight requests.
+13 -10
View File
@@ -3,7 +3,7 @@
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
Anthropic's native Messages API via pluggable provider adapters, and gives the
model 18 built-in tools plus external tools via MCP (Model Context Protocol) for
model 17 built-in tools plus external tools via MCP (Model Context Protocol) for
reading, writing, searching, planning, and executing code.
The core design principle is a **UI-agnostic engine with pluggable frontends**.
@@ -47,7 +47,10 @@ turnstone/
watch.py WatchRunner daemon — periodic command polling, condition DSL, result dispatch
judge.py Intent validation — heuristic rules + LLM judge, advisory verdicts
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
memory.py Persistence facade (delegates to storage backend)
memory.py Persistence facade + structured memory API (delegates to storage backend)
config.py Config file loader (config.toml), apply_config(), warn_migrated_settings()
config_store.py ConfigStore — database-backed settings with in-memory cache, thread-safe get/set
settings_registry.py SettingDef catalog (~40 settings), validation, type coercion, serialization
storage/ Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
metrics.py Prometheus-compatible metrics collector (MetricsCollector)
healthcheck.py BackendHealthMonitor — periodic probe + circuit breaker
@@ -436,13 +439,13 @@ from each schema and builds:
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
### 14 Tools by Category
### 13 Tools by Category
**Read-only (auto-approve)**:
- `read_file` -- read file contents with optional offset/limit
- `search` -- ripgrep-based codebase search
- `man` -- read man pages
- `recall` -- retrieve stored memories
- `recall` -- search conversation history
**Write (requires approval)**:
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
@@ -456,9 +459,8 @@ from each schema and builds:
- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
- `plan` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
**Memory (persistent key-value store)**:
- `remember` -- save a fact
- `forget` -- delete a fact
**Memory (structured persistent store)**:
- `memory` -- save, search, delete, or list memories (typed and scoped)
### Prepare / Execute Pattern
@@ -1016,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).
@@ -1391,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.
+3 -2
View File
@@ -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:**
+1 -1
View File
@@ -127,7 +127,7 @@ group loop [while tool_calls present]
math → sandboxed subprocess
web_fetch → httpx + LLM summarize
web_search → provider-native or Tavily fallback
remember/recall/forget → SQLite
memory/recall → SQLite
end note
note right of TP
+4 -6
View File
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
:Dispatch to _prepare_{func_name}();
note right
**Dispatch table (18 tools):**
**Dispatch table (17 tools):**
┌───────────────┬──────────────────┐
│ Tool │ Needs Approval? │
├───────────────┼──────────────────┤
@@ -40,9 +40,8 @@ partition "Phase 1: Prepare" #E8F5E9 {
│ tool_search │ ✗ Auto-approve │
│ task │ ✓ Yes │
│ plan │ ✓ Yes │
remember │ ✗ Auto-approve │
│ memory │ ✗ Auto-approve │
│ recall │ ✗ Auto-approve │
│ forget │ ✗ Auto-approve │
│ notify │ ✗ Auto-approve │
│ read_resource │ ✓ Yes │
│ use_prompt │ ✓ Yes │
@@ -116,9 +115,8 @@ partition "Phase 3: Execute" #E3F2FD {
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
├─ _exec_notify: HTTP POST to channel gateway
├─ _exec_remember: SQLite INSERT OR REPLACE
├─ _exec_recall: SQLite FTS5/LIKE search
├─ _exec_forget: SQLite DELETE
├─ _exec_memory: structured memory save/search/delete/list
├─ _exec_recall: conversation history FTS5 search
├─ _exec_read_resource: MCPClientManager.read_resource_sync()
├─ _exec_use_prompt: MCPClientManager.get_prompt_sync()
└─ _exec_mcp_tool: MCPClientManager.call_tool_sync()
+1 -1
View File
@@ -79,7 +79,7 @@ note right of BridgeA
1. _ws_auto_approve[ws_id]? → auto
2. All tools in safe set? → auto
(read_file, search, man,
remember, recall, forget)
memory, recall)
3. Otherwise → manual approval
end note
+159
View File
@@ -0,0 +1,159 @@
@startuml
!theme plain
title Turnstone — Structured Memory Architecture
skinparam participant {
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<facade>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<api>> #E8EAF6
BackgroundColor<<sdk>> #F5F5F5
}
participant "ChatSession\n(session.py)" as Session <<session>>
participant "MemoryFacade\n(memory.py)" as Facade <<facade>>
participant "MemoryRelevance\n(memory_relevance.py)" as Relevance <<facade>>
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
participant "Server API\n(server.py)" as API <<api>>
participant "Console Admin\n(console/server.py)" as Admin <<api>>
participant "SDK Client\n(sdk/)" as SDK <<sdk>>
== Phase 1: Tool Path (session.send) ==
Session -> Session : _prepare_tool_calls()\nparse memory(action=...)
note right
Tool schema: 4 actions
save, search, delete, list
Auto-approved (no approval needed)
end note
Session -> Session : _exec_memory(item)
alt action = save
Session -> Facade : save_structured_memory(\nname, content, description,\nmem_type, scope, scope_id)
Facade -> Facade : normalize_key(name)
Facade -> Storage : create_structured_memory()
alt unique constraint violation
Storage --> Facade : IntegrityError
Facade -> Storage : get_structured_memory_by_name()
Storage --> Facade : existing row
Facade -> Storage : update_structured_memory()
end
Storage --> Facade : memory_id
Facade --> Session : (memory_id, old_content)
Session -> Session : _init_system_messages()\nrefresh BM25 context
end
alt action = search
Session -> Facade : search_structured_memories(\nquery, mem_type, scope,\nscope_id, limit)
Facade -> Storage : search_structured_memories()
Storage --> Session : matched rows
end
alt action = delete
Session -> Facade : delete_structured_memory(\nname, scope, scope_id)
Facade -> Storage : delete_structured_memory()
Storage --> Session : bool (existed)
Session -> Session : _init_system_messages()\nrefresh BM25 context
end
== Phase 2: BM25 Relevance Injection ==
Session -> Session : _init_system_messages()\nevery conversation turn
Session -> Session : _get_visible_memories(\nlimit=fetch_limit)
note right
**Scope resolution:**
1. global scope (always)
2. workstream scope (ws_id)
3. user scope (user_id, if auth)
Combined and deduplicated.
end note
Session -> Facade : list_structured_memories()\nper scope
Facade -> Storage : list_structured_memories()
Storage --> Session : up to fetch_limit rows
Session -> Relevance : extract_recent_context(\nmessages, max_messages=3)
Relevance --> Session : user text context
Session -> Relevance : score_memories(\nmemories, context,\nk=relevance_k)
note right
**BM25 scoring:**
Index over name + description
+ content[:200] for each memory.
Returns top-k by relevance.
Empty query returns most recent k.
end note
Relevance --> Session : top-k memories
Session -> Relevance : build_memory_context(\nrelevant_memories)
note right
Formats as XML block:
<memories>
<memory name="..." type="..."
scope="..." description="...">
content (max 500 chars)
</memory>
</memories>
end note
Relevance --> Session : XML string
Session -> Session : inject into\nsystem message
== Phase 3: Server API Path ==
SDK -> API : GET /v1/api/memories\n?type=project&limit=20
API -> Facade : list_structured_memories()
Facade -> Storage : list_structured_memories()
Storage --> API : rows
API --> SDK : {"memories": [...], "total": N}
SDK -> API : POST /v1/api/memories\n{name, content, ...}
API -> API : validate type, scope,\nname length, content length
API -> Facade : save_structured_memory()
Facade -> Storage : create / update
Storage --> API : memory row
API --> SDK : 201 (created) / 200 (updated)
SDK -> API : POST /v1/api/memories/search\n{query, type, ...}
API -> Facade : search_structured_memories()
Facade -> Storage : search_structured_memories()
Storage --> API : matched rows
API --> SDK : {"memories": [...], "total": N}
SDK -> API : DELETE /v1/api/memories/{name}\n?scope=global
API -> Facade : delete_structured_memory()
Facade -> Storage : delete row
API --> SDK : {"status": "ok"}
== Phase 4: Console Admin Path ==
SDK -> Admin : GET /v1/api/admin/memories\n?type=&scope=&limit=
Admin -> Admin : require_permission(\n"admin.memories")
Admin -> Storage : list_structured_memories()
Storage --> Admin : rows
Admin --> SDK : {"memories": [...], "total": N}
SDK -> Admin : GET /v1/api/admin/memories/{id}
Admin -> Storage : get_structured_memory(id)
Storage --> Admin : memory row
Admin --> SDK : memory JSON
SDK -> Admin : DELETE /v1/api/admin/memories/{id}
Admin -> Storage : delete_structured_memory_by_id()
Admin -> Admin : record_audit(\n"memory.delete")
Admin --> SDK : {"status": "ok"}
== Configuration ==
note over Session, Relevance
**MemoryConfig** (from [memory] in config.toml):
relevance_k = 5 -- top-k memories per turn
fetch_limit = 50 -- max memories fetched for scoring
max_content = 32768 -- max content length per memory
nudge_cooldown = 300 -- seconds between metacognitive nudges
nudges = true -- enable/disable memory nudges
end note
@enduml
+151
View File
@@ -0,0 +1,151 @@
@startuml
!theme plain
title Turnstone — Settings Architecture
skinparam participant {
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<config>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<api>> #E8EAF6
BackgroundColor<<sdk>> #F5F5F5
}
participant "Server\n(main)" as Server <<session>>
participant "ConfigStore\n(config_store.py)" as Store <<config>>
participant "SettingsRegistry\n(settings_registry.py)" as Registry <<config>>
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
participant "Console Admin\n(console/server.py)" as Admin <<api>>
participant "SDK Client\n(sdk/)" as SDK <<sdk>>
participant "ChatSession\n(session.py)" as Session <<session>>
== Phase 1: Server Startup ==
Server -> Server : parse_args()\nCLI flags override defaults
Server -> Server : init_storage()\nSQLite / PostgreSQL
Server -> Store ** : ConfigStore(storage, node_id)
Store -> Storage : get_system_settings_bulk(node_id)
note right
1. Load global settings (node_id="")
2. Overlay per-node settings
Returns {key: json_value} dict
end note
Storage --> Store : raw settings
Store -> Registry : deserialize_value(key, json)\nper entry
Registry --> Store : typed values
Store -> Store : swap _cache atomically\nincrement _version
Server -> Server : warn_migrated_settings()
note right
Scans config.toml for keys
now managed by ConfigStore.
Logs warning for each overlap.
end note
Server -> Server : session_factory captures\nConfigStore reference
== Phase 2: Settings Read (session creation) ==
Server -> Session : session_factory(ws_id)
Session -> Store : get("model.temperature")
Store -> Store : cache[key] lookup\n(lock-free)
alt key in cache
Store --> Session : stored value
else key not in cache
Store -> Registry : SETTINGS[key].default
Registry --> Store : default value
Store --> Session : default value
end
note right of Session
Settings are captured once
at workstream creation.
Not re-read on every turn.
end note
== Phase 3: Admin API — List / Schema ==
SDK -> Admin : GET /v1/api/admin/settings
Admin -> Admin : require_permission(\n"admin.settings")
Admin -> Store : all_effective()
Store -> Store : merge cache with\nregistry defaults
Store --> Admin : {key: effective_value}
Admin -> Registry : SETTINGS (metadata)
note right
Annotates each setting with:
type, default, description,
is_stored, is_secret, constraints,
changed_by, updated
end note
Admin --> SDK : {"settings": [...], "total": N}
SDK -> Admin : GET /v1/api/admin/settings/schema
Admin -> Admin : require_permission(\n"admin.settings")
Admin -> Registry : SETTINGS catalog
Admin --> SDK : {"settings": [...], "total": N}
== Phase 4: Admin API — Update ==
SDK -> Admin : PUT /v1/api/admin/settings/\nmodel.temperature\n{"value": 0.7}
Admin -> Admin : require_permission(\n"admin.settings")
Admin -> Registry : validate_key("model.temperature")
Registry --> Admin : SettingDef
alt is_secret == true
Admin --> SDK : 403 Forbidden
else
Admin -> Registry : validate_value(key, 0.7)
note right
Type coercion: float(0.7)
Range check: 0.0 <= 0.7 <= 2.0
Choices check: (none for this key)
end note
Registry --> Admin : typed value
Admin -> Store : set(key, 0.7, changed_by="admin")
Store -> Registry : serialize_value(0.7)\n=> "0.7"
Store -> Storage : upsert_system_setting(\nkey, "0.7", node_id, ...)
Storage --> Store : ok
Store -> Store : swap _cache atomically
Admin -> Admin : record_audit(\n"setting.update")
Admin --> SDK : {"key": "...", "value": 0.7,\n"previous": 0.5}
end
== Phase 5: Admin API — Delete (reset to default) ==
SDK -> Admin : DELETE /v1/api/admin/settings/\nmodel.temperature
Admin -> Admin : require_permission(\n"admin.settings")
Admin -> Store : delete("model.temperature")
Store -> Registry : validate_key(key)
Store -> Storage : delete_system_setting(key, node_id)
Storage --> Store : bool (existed)
Store -> Store : remove from cache,\nswap atomically
Admin -> Admin : record_audit(\n"setting.delete")
Admin --> SDK : {"status": "ok",\n"key": "...", "default": 0.5}
== Phase 6: Hot Reload ==
SDK -> Admin : POST /v1/api/_internal/\nconfig-reload
Admin -> Store : reload()
Store -> Storage : get_system_settings_bulk(node_id)
Storage --> Store : all settings
Store -> Store : rebuild cache,\nswap atomically,\nincrement _version
note right
Existing sessions: unchanged
(frozen at creation time).
New sessions: pick up
updated values immediately.
end note
Admin --> SDK : {"status": "ok"}
== Precedence Summary ==
note over Server, Registry
**Server entry point:**
CLI flag > ConfigStore (database) > registry default
**CLI entry point:**
CLI flag > config.toml > argparse default
**Bootstrap settings** (database, Redis, auth, server bind):
Always from config.toml / env vars — never in ConfigStore.
end note
@enduml
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c89628ed917dfd576c1af75c68fe5fed9beadaaee9dcea7aa7a1643867c4f1b9
size 344323
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:83c0e6aad3eb19f6bc475a30a77215e801da3da5930f0462417fe7eb6eda6be2
size 347144
+1 -1
View File
@@ -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).
+569
View File
@@ -0,0 +1,569 @@
# Structured Memory
> See also: [Memory Architecture diagram](diagrams/png/23-memory-architecture.png)
The structured memory system gives the AI persistent, typed, scoped memories
that survive across sessions and workstreams. Memories are automatically
surfaced in the system message via BM25 relevance scoring, so the model has
contextual recall without explicit search.
## Overview
Each memory has three dimensions:
- **Type** -- categorizes the memory's purpose
- **Scope** -- controls visibility boundaries
- **Name** -- unique identifier within a scope (snake_case, normalized)
### Memory types
| Type | Purpose |
|-------------|------------------------------------------------------------|
| `user` | User preferences, conventions, working style |
| `project` | Project-specific knowledge, architecture, patterns |
| `feedback` | Corrections, lessons learned, things to avoid |
| `reference` | Reference material, documentation, specifications |
### Memory scopes
| Scope | Visibility |
|--------------|-----------------------------------------------------------|
| `global` | Visible to all workstreams and users |
| `workstream` | Visible only within the originating workstream |
| `user` | Follows the authenticated user across workstreams |
A memory's identity is the tuple `(name, scope, scope_id)`. Saving a memory
with the same identity upserts -- updating content while preserving the ID.
### BM25 relevance injection
On every conversation turn, the system:
1. Fetches up to `fetch_limit` memories visible in the current scope
2. Extracts context from the last 3 user messages
3. Scores memories against that context using a BM25 index
4. Injects the top `relevance_k` memories into the system message as
`<memories>` XML tags
5. Appends a hint telling the model how many memories are in scope
This means the model always has its most relevant memories available without
explicit recall -- but can still use `memory(action='search')` for deeper
lookup.
### Nudges
The metacognition layer can nudge the model to save memories at appropriate
moments (e.g., after a correction or when resuming a workstream). Nudges are
rate-limited by `nudge_cooldown` and can be disabled entirely.
---
## Configuration
### config.toml
```toml
[memory]
relevance_k = 5 # top-k memories injected per turn
fetch_limit = 50 # max memories fetched from storage for scoring
max_content = 32768 # max content length per memory (characters)
nudge_cooldown = 300 # minimum seconds between memory nudges
nudges = true # enable/disable metacognitive nudges
```
All fields are optional. Defaults are shown above.
---
## Tool Usage
The `memory` tool supports four actions:
### save
Store or update a memory.
```json
{
"action": "save",
"name": "project_architecture",
"content": "The project uses a hexagonal architecture with...",
"description": "Core architecture patterns",
"type": "project",
"scope": "global"
}
```
| Parameter | Required | Default | Description |
|---------------|----------|-------------|------------------------------------------|
| `name` | yes | -- | Snake_case identifier (max 256 chars) |
| `content` | yes | -- | Memory content (max `max_content` chars) |
| `description` | no | `""` | Short description for relevance matching |
| `type` | no | `"project"` | One of: user, project, feedback, reference |
| `scope` | no | `"global"` | One of: global, workstream, user |
### search
Find memories by query (BM25 full-text search).
```json
{
"action": "search",
"query": "authentication patterns",
"type": "project",
"limit": 10
}
```
| Parameter | Required | Default | Description |
|-----------|----------|---------|--------------------------------------|
| `query` | yes | -- | Search query |
| `type` | no | `""` | Filter by type |
| `scope` | no | `""` | Filter by scope |
| `limit` | no | `20` | Max results (capped at 50) |
### delete
Remove a memory by name.
```json
{
"action": "delete",
"name": "outdated_pattern",
"scope": "global"
}
```
| Parameter | Required | Default | Description |
|------------|----------|------------|--------------------------|
| `name` | yes | -- | Memory name to delete |
| `scope` | no | `"global"` | Scope of the memory |
### list
List all memories with optional filters.
```json
{
"action": "list",
"type": "feedback",
"limit": 50
}
```
| Parameter | Required | Default | Description |
|-----------|----------|---------|----------------------------|
| `type` | no | `""` | Filter by type |
| `scope` | no | `""` | Filter by scope |
| `limit` | no | `20` | Max results (capped at 50) |
---
## Server API
Four endpoints on the server for programmatic memory access.
### `GET /v1/api/memories`
List memories with optional filters.
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|------------|--------|----------|---------|------------------------------|
| `type` | string | no | `""` | Filter by memory type |
| `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `100` | Max results (capped at 200) |
When `scope=user` and `scope_id` is omitted, the authenticated user's ID is
used automatically.
**Response:** `200`
```json
{
"memories": [
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "The project uses a hexagonal architecture...",
"created": "2026-03-10T10:00:00",
"updated": "2026-03-12T14:30:00"
}
],
"total": 1
}
```
---
### `POST /v1/api/memories`
Save or upsert a structured memory.
**Request body:**
```json
{
"name": "deployment_process",
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
"description": "CI/CD deployment workflow",
"type": "project",
"scope": "global",
"scope_id": ""
}
```
| Field | Type | Required | Default | Description |
|--------------|--------|----------|-------------|--------------------------------------|
| `name` | string | yes | -- | Memory name (max 256 chars) |
| `content` | string | yes | -- | Memory content (max 65536 chars) |
| `description`| string | no | `""` | Short description for search ranking |
| `type` | string | no | `"project"` | One of: user, project, feedback, reference |
| `scope` | string | no | `"global"` | One of: global, workstream, user |
| `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) |
**Response (created):** `201`
```json
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "deployment_process",
"description": "CI/CD deployment workflow",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "Deploy via GitHub Actions...",
"created": "2026-03-14T10:00:00",
"updated": "2026-03-14T10:00:00"
}
```
**Response (updated):** `200` -- same schema, returned when a memory with the
same `(name, scope, scope_id)` already existed.
**Errors:**
| Status | Condition |
|--------|------------------------------------|
| 400 | Missing name, empty content, invalid type/scope, content too long |
---
### `POST /v1/api/memories/search`
Search memories by query. Uses POST for the request body but is non-mutating
(requires only `read` scope).
**Request body:**
```json
{
"query": "authentication",
"type": "project",
"scope": "",
"scope_id": "",
"limit": 20
}
```
| Field | Type | Required | Default | Description |
|------------|--------|----------|---------|--------------------------------|
| `query` | string | yes | -- | Search query |
| `type` | string | no | `""` | Filter by type |
| `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `20` | Max results (capped at 50) |
**Response:** `200`
```json
{
"memories": [
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "auth_patterns",
"description": "Authentication architecture",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "JWT tokens with HS256...",
"created": "2026-03-10T10:00:00",
"updated": "2026-03-12T14:30:00"
}
],
"total": 1
}
```
---
### `DELETE /v1/api/memories/{name}`
Delete a memory by name and scope.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `name` | string | Memory name |
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|------------|--------|----------|------------|---------------------|
| `scope` | string | no | `"global"` | Scope of the memory |
| `scope_id` | string | no | `""` | Scope qualifier |
**Response (success):** `200`
```json
{"status": "ok", "name": "deployment_process"}
```
**Response (not found):** `404`
```json
{"error": "Memory 'deployment_process' not found"}
```
---
## Console Admin API
Four admin endpoints for cross-workstream memory management. All require the
`admin.memories` permission.
### `GET /v1/api/admin/memories`
List memories across all scopes (no automatic scope resolution).
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|------------|--------|----------|---------|------------------------------|
| `type` | string | no | `""` | Filter by type |
| `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `100` | Max results (capped at 200) |
**Response:** `200`
```json
{
"memories": [
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "The project uses...",
"created": "2026-03-10T10:00:00",
"updated": "2026-03-12T14:30:00"
}
],
"total": 1
}
```
---
### `GET /v1/api/admin/memories/search`
Search memories by query (uses query parameters, not POST body).
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|------------|--------|----------|---------|-------------------------------|
| `q` | string | yes | -- | Search query |
| `type` | string | no | `""` | Filter by type |
| `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `20` | Max results (capped at 50) |
**Response:** `200` -- same schema as `GET /v1/api/admin/memories`.
---
### `GET /v1/api/admin/memories/{memory_id}`
Get a single memory by ID.
**Path parameters:**
| Parameter | Type | Description |
|-------------|--------|------------------------|
| `memory_id` | string | Memory UUID |
**Response (success):** `200`
```json
{
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"scope": "global",
"scope_id": "",
"content": "The project uses...",
"created": "2026-03-10T10:00:00",
"updated": "2026-03-12T14:30:00"
}
```
**Response (not found):** `404`
```json
{"error": "Memory not found"}
```
---
### `DELETE /v1/api/admin/memories/{memory_id}`
Delete a memory by ID. Records an audit event (`memory.delete`).
**Path parameters:**
| Parameter | Type | Description |
|-------------|--------|------------------------|
| `memory_id` | string | Memory UUID |
**Response (success):** `200`
```json
{"status": "ok"}
```
**Response (not found):** `404`
```json
{"error": "Memory not found"}
```
---
## SDK
### Python
The server SDK uses `mem_type` (not `type`) to avoid shadowing the Python
builtin.
```python
from turnstone.sdk import TurnstoneServer
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
# Save a memory
mem = client.save_memory(
"api_conventions",
"All endpoints use /v1/ prefix. JSON responses.",
description="API design patterns",
mem_type="project",
scope="global",
)
print(mem.memory_id)
# Search memories
results = client.search_memories("authentication", mem_type="project", limit=10)
for m in results.memories:
print(f"{m['name']}: {m['description']}")
# List memories
all_mems = client.list_memories(mem_type="feedback", limit=50)
# Delete a memory
client.delete_memory("api_conventions", scope="global")
```
Console admin SDK:
```python
from turnstone.sdk import TurnstoneConsole
with TurnstoneConsole("http://localhost:9090", token="tok_xxx") as admin:
# List all memories (admin view, no scope auto-resolution)
result = admin.list_memories(scope="global", limit=100)
# Search
result = admin.search_memories("architecture", mem_type="project")
# Get by ID
mem = admin.get_memory("a1b2c3d4-e5f6-...")
# Delete by ID
admin.delete_memory("a1b2c3d4-e5f6-...")
```
### TypeScript
```typescript
import { TurnstoneServer } from "@turnstone/sdk";
const client = new TurnstoneServer({
baseUrl: "http://localhost:8080",
token: "tok_xxx",
});
// Save a memory
const mem = await client.saveMemory({
name: "api_conventions",
content: "All endpoints use /v1/ prefix. JSON responses.",
description: "API design patterns",
type: "project",
scope: "global",
});
// Search memories
const results = await client.searchMemories({
query: "authentication",
type: "project",
limit: 10,
});
// List memories
const all = await client.listMemories({ type: "feedback", limit: 50 });
// Delete a memory
await client.deleteMemory("api_conventions", { scope: "global" });
```
Console admin SDK:
```typescript
import { TurnstoneConsole } from "@turnstone/sdk";
const admin = new TurnstoneConsole({
baseUrl: "http://localhost:9090",
token: "tok_xxx",
});
// List, search, get, delete by ID
const mems = await admin.listMemories({ scope: "global" });
const found = await admin.searchMemories({ q: "auth", limit: 20 });
const one = await admin.getMemory("a1b2c3d4-e5f6-...");
await admin.deleteMemory("a1b2c3d4-e5f6-...");
```
---
## Storage
Memories are stored in the `structured_memories` table (migration 013).
The unique constraint on `(name, scope, scope_id)` ensures upsert semantics.
The name is normalized on save: lowercased, hyphens and spaces replaced with
underscores.
## Architecture
See [Memory Architecture diagram](diagrams/png/23-memory-architecture.png) for
the full data flow covering the session tool path, API path, admin path, and
BM25 relevance injection.
+353
View File
@@ -0,0 +1,353 @@
# System Settings
> See also: [Settings Architecture diagram](diagrams/png/24-settings-architecture.png)
The system settings feature provides database-backed configuration for server
nodes. Settings are stored in the `system_settings` table and managed through
the admin API or console Settings tab. This replaces `config.toml` for
non-bootstrap settings on server entry points, while the CLI continues to read
`config.toml` directly.
## Overview
Settings follow a typed registry pattern: every storable setting has a
`SettingDef` entry in `settings_registry.py` with type, default, description,
validation constraints, and a `restart_required` flag. Unknown keys are rejected
at the API boundary.
At runtime, `ConfigStore` loads all settings from storage into an in-memory
cache. Reads are lock-free dict lookups on an immutable snapshot. Writes acquire
a lock, persist to storage, and swap the cache atomically.
---
## Precedence
Settings resolution differs between entry points:
| Entry point | Chain |
|-------------|-------|
| **Server** (`turnstone-server`, `turnstone-bridge`) | CLI flag > ConfigStore > registry default |
| **CLI** (`turnstone`) | CLI flag > config.toml > argparse default |
The server's `apply_config()` ignores config.toml sections that overlap with
ConfigStore. A startup warning is logged for each overlapping key, directing
users to the admin Settings API.
---
## Bootstrap vs ConfigStore
**Bootstrap settings** are required before storage is available (database
connection, Redis, auth secrets, server bind address). These stay in
`config.toml` and environment variables.
| Category | Section | Where |
|----------|---------|-------|
| API credentials | `[api]` | config.toml / env |
| Database | `[database]` | config.toml / env |
| Redis | `[redis]` | config.toml / env |
| Auth | `[auth]` | config.toml / env |
| Bridge identity | `[bridge]` | config.toml / env |
| Console bind | `[console]` | config.toml / env |
**ConfigStore settings** (~40 settings) are loaded from the database after
storage initialization:
| Section | Settings |
|---------|----------|
| `model` | name, temperature, max_tokens, reasoning_effort, context_window |
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
| `server` | workstream_idle_timeout, max_workstreams |
| `mcp` | config_path, refresh_interval |
| `ratelimit` | enabled, requests_per_second, burst |
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools |
| `memory` | relevance_k, fetch_limit, max_content, nudge_cooldown, nudges |
Settings are addressed by dotted key (e.g. `memory.relevance_k`). Each has a
declared type (`int`, `float`, `str`, `bool`), optional `min_value`/`max_value`
range, optional `choices` list, and an `is_secret` flag.
---
## Storage
The `system_settings` table (migration 015) stores settings as JSON-encoded
values with a composite primary key of `(key, node_id)`:
| Column | Type | Description |
|--------|------|-------------|
| `key` | text | Dotted setting key (e.g. `model.temperature`) |
| `value` | text | JSON-encoded value |
| `node_id` | text | Node ID for per-node overrides (empty string = global) |
| `is_secret` | int | 1 if the setting contains secrets |
| `changed_by` | text | Username of last editor |
| `created` | text | ISO timestamp |
| `updated` | text | ISO timestamp |
Per-node overrides layer on top of global settings. When `ConfigStore` loads,
it fetches global settings first, then overlays per-node values.
---
## Admin API
Four endpoints on the **console** server, all requiring the `admin.settings`
permission.
### `GET /v1/api/admin/settings`
List all settings with their effective values, defaults, and metadata.
**Response:** `200`
```json
{
"settings": [
{
"key": "model.temperature",
"value": 0.7,
"source": "storage",
"type": "float",
"description": "Sampling temperature",
"section": "model",
"is_secret": false,
"node_id": "",
"changed_by": "admin",
"updated": "2026-03-14T10:00:00",
"restart_required": false
}
]
}
```
---
### `GET /v1/api/admin/settings/schema`
Return the full registry catalog (all defined settings with metadata). Useful
for building dynamic admin UIs.
**Response:** `200`
```json
{
"schema": [
{
"key": "model.temperature",
"type": "float",
"default": 0.5,
"description": "Sampling temperature",
"section": "model",
"is_secret": false,
"min_value": 0.0,
"max_value": 2.0,
"choices": null,
"restart_required": false
}
]
}
```
---
### `PUT /v1/api/admin/settings/{key}`
Update a setting. The value is validated against the registry (type coercion,
range, choices). Secret settings (`is_secret=true`) cannot be written via the
API -- they must be configured via config.toml or environment variables.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|-------------|
| `key` | string | Dotted setting key (e.g. `model.temperature`) |
**Request body:**
```json
{
"value": 0.7,
"node_id": ""
}
```
| Field | Type | Required | Default | Description |
|-----------|--------|----------|---------|-------------|
| `value` | any | yes | -- | New value (type-coerced against registry) |
| `node_id` | string | no | `""` | Node ID for per-node override |
**Response (success):** `200`
```json
{
"key": "model.temperature",
"value": 0.7,
"source": "storage",
"type": "float",
"description": "Sampling temperature",
"section": "model",
"is_secret": false,
"node_id": "",
"changed_by": "admin",
"updated": "",
"restart_required": false
}
```
**Errors:**
| Status | Condition |
|--------|-----------|
| 400 | Unknown key, invalid value, type mismatch, out of range |
| 403 | Secret setting (must use config.toml or env) |
---
### `DELETE /v1/api/admin/settings/{key}`
Reset a setting to its registry default by removing it from storage.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|-------------|
| `key` | string | Dotted setting key |
**Query parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|--------|----------|---------|-------------|
| `node_id` | string | no | `""` | Node ID (empty = global) |
**Response (success):** `200`
```json
{"status": "ok", "key": "model.temperature", "default": 0.5}
```
**Response (not found):** `404`
```json
{"error": "Setting 'model.temperature' has no stored value"}
```
---
## Secret Settings
Settings with `is_secret=True` (currently only `judge.api_key`) are blocked
from the write API with a `403` response. This prevents accidental exposure
through the admin UI or audit logs. Secret settings must be configured via
`config.toml` or environment variables.
The list endpoint masks secret values: stored secrets appear as `"***"`
rather than their actual value.
---
## Hot Reload
`ConfigStore` caches all settings in memory for fast, lock-free reads. To
refresh the cache after external changes (e.g. direct database edits or
cluster-wide propagation):
```
POST /v1/api/_internal/config-reload
```
This triggers `ConfigStore.reload()`, which re-reads all settings from storage
and atomically swaps the cache. The `version` counter increments on every
reload.
**Behavior after reload:**
- New workstreams pick up updated values immediately (via `session_factory`)
- Existing sessions keep their frozen configuration (settings are captured at
workstream creation time, not read on every turn)
- Settings marked `restart_required=True` need a server restart to take effect
---
## Migration from config.toml
On startup, `warn_migrated_settings()` scans `config.toml` for keys that are
now managed by ConfigStore. Each overlap produces a warning:
```
WARNING config.toml [model] temperature is now managed via Settings API —
this value will be ignored. Use the admin Settings tab or
PUT /v1/api/admin/settings/model.temperature to configure.
```
To migrate:
1. Note the values from `config.toml` for sections that overlap with ConfigStore
2. Use `PUT /v1/api/admin/settings/{key}` or the console Settings tab to set
each value
3. Remove the migrated sections from `config.toml`
4. Restart the server to verify no warnings
---
## SDK
### Python
```python
from turnstone.sdk import TurnstoneConsole
with TurnstoneConsole("http://localhost:9090", token="tok_xxx") as admin:
# List all settings with effective values
result = admin.list_settings()
for s in result["settings"]:
print(f"{s['key']} = {s['value']} (source: {s['source']})")
# Get the schema catalog
schema = admin.get_settings_schema()
# Update a setting
admin.update_setting("model.temperature", value=0.7)
# Update with per-node override
admin.update_setting("model.temperature", value=0.3, node_id="node-2")
# Reset to default
admin.delete_setting("model.temperature")
```
### TypeScript
```typescript
import { TurnstoneConsole } from "@turnstone/sdk";
const admin = new TurnstoneConsole({
baseUrl: "http://localhost:9090",
token: "tok_xxx",
});
// List all settings
const result = await admin.listSettings();
for (const s of result.settings) {
console.log(`${s.key} = ${s.value} (source: ${s.source})`);
}
// Get schema catalog
const schema = await admin.getSettingsSchema();
// Update a setting
await admin.updateSetting("model.temperature", { value: 0.7 });
// Reset to default
await admin.deleteSetting("model.temperature");
```
---
## Architecture
See [Settings Architecture diagram](diagrams/png/24-settings-architecture.png)
for the full data flow covering server startup, admin API writes, hot reload,
and settings precedence.
+27 -38
View File
@@ -1,6 +1,6 @@
# Tools Reference
turnstone exposes 18 built-in tools plus any number of external MCP tools to the
turnstone exposes 17 built-in tools plus any number of external MCP tools to the
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
MCP tools are discovered from configured MCP servers at startup by
@@ -46,12 +46,12 @@ schema plus turnstone-specific metadata keys:
| Name | Description |
|---------------------|-------------|
| `TOOLS` | All 18 tool definitions (sent to the model). |
| `TOOLS` | All 17 tool definitions (sent to the model). |
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 18 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 17 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
---
@@ -69,7 +69,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
- Parses the JSON arguments (with fallback for malformed JSON).
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
to the correct parameter.
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 18
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
the generic `_prepare_mcp_tool()` handler for MCP tools.
- Validates arguments and builds a preview dict containing:
@@ -114,9 +114,8 @@ Each item's `execute` callable is invoked:
- `read_file` -- reads files, no side effects
- `search` -- grep-style search, no side effects
- `man` -- reads man pages, no side effects
- `remember` -- writes to persistent memory database (lightweight, always auto-approved)
- `recall` -- reads from persistent memory database
- `forget` -- deletes from persistent memory database (lightweight, always auto-approved)
- `memory` -- structured persistent memory (save/search/delete/list)
- `recall` -- searches conversation history
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
**Requires user confirmation** (write operations, network access, side effects):
@@ -164,9 +163,8 @@ Every tool defines a `primary_key`. The mapping is:
| `web_search` | `query` |
| `task` | `prompt` |
| `plan` | `prompt` |
| `remember` | `key` |
| `memory` | `name` |
| `recall` | `query` |
| `forget` | `key` |
| `notify` | `message` |
| `read_resource` | `uri` |
| `use_prompt` | `name` |
@@ -353,16 +351,22 @@ Plan before implementing -- an autonomous agent explores the codebase and writes
## Memory
### remember
### memory
Save a persistent memory that persists across sessions.
Structured persistent memory across sessions with typed, scoped entries.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `key` | string | yes | Short identifier (e.g. `user_name`). |
| `value` | string | yes | Content to remember. |
| Parameter | Type | Required | Description |
|---------------|---------|----------|-------------|
| `action` | string | yes | `save`, `search`, `delete`, or `list`. |
| `name` | string | save/delete | Short snake_case identifier for the memory. |
| `content` | string | save | Memory content to store. |
| `description` | string | no | Short description for relevance matching (recommended for `save`). |
| `type` | string | no | Memory type: `user`, `project`, `feedback`, or `reference`. Default: `project`. |
| `scope` | string | no | Memory scope: `global`, `workstream`, or `user`. Default: `global`. |
| `query` | string | search | Search query for finding memories. |
| `limit` | integer | no | Max results for `search` or `list`. Default: 20. |
- **What it does**: Stores a key-value pair in the SQLite memory database. Memories persist across sessions and are included in the system prompt on startup.
- **What it does**: Manages structured persistent memories in the database. Memories persist across sessions, have a type classification (user preferences, project knowledge, feedback, reference material) and a scope (global across all workstreams, private to a workstream, or following a user). Relevant memories are included in the system prompt on startup.
- **Auto-approve**: Yes.
- **Agent availability**: Not available to sub-agents (top-level only).
@@ -370,28 +374,14 @@ Save a persistent memory that persists across sessions.
### recall
Search memories and past conversations.
Search conversation history for past messages and tool results.
| Parameter | Type | Required | Description |
|-----------|---------|----------|-------------|
| `query` | string | no | Search term or phrase. Omit to list all memories. |
| `limit` | integer | no | Max conversation results to return (default 20). |
| `query` | string | yes | Search term or phrase to find in conversation history. |
| `limit` | integer | no | Max results to return (default 20). |
- **What it does**: With no query, lists all saved memories. With a query, searches both the memory store and conversation history using FTS5 full-text search.
- **Auto-approve**: Yes.
- **Agent availability**: Not available to sub-agents (top-level only).
---
### forget
Remove a persistent memory by key.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `key` | string | yes | The memory key to remove (e.g. `user_name`). |
- **What it does**: Deletes the memory entry with the given key from the SQLite database.
- **What it does**: Searches conversation history across sessions using FTS5 full-text search. Returns matching messages, tool calls, and tool results with timestamps and workstream context.
- **Auto-approve**: Yes.
- **Agent availability**: Not available to sub-agents (top-level only).
@@ -514,9 +504,8 @@ data.get("mergedAt") is not None
| `web_search` | Info | No | Yes | Yes | `query` |
| `task` | Agent | No | No | No | `prompt` |
| `plan` | Agent | No | No | No | `prompt` |
| `remember` | Memory | Yes | No | No | `key` |
| `memory` | Memory | Yes | No | No | `name` |
| `recall` | Memory | Yes | No | No | `query` |
| `forget` | Memory | Yes | No | No | `key` |
| `notify` | Notify | Yes | Yes | Yes | `message` |
| `watch` | Monitor | No (create) | No | No | `command` |
| `read_resource`| MCP | No | Yes | Yes | `uri` |
@@ -573,7 +562,7 @@ CLI flags override the config file:
search stays off and all tools are sent to the model directly.
2. **Partitioning**: When active, tools are split into two sets:
- **Always-on** -- the 18 built-in tools (members of `BUILTIN_TOOL_NAMES`).
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
These are always visible to the model.
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
the model searches for them.
@@ -616,7 +605,7 @@ MCP-compatible service.
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
4. **Merging**: MCP tools are appended after the 18 built-in tools via
4. **Merging**: MCP tools are appended after the 17 built-in tools via
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
When dynamic tool search is active, MCP tools are deferred rather than directly
visible -- the model discovers them via search as needed (see
+123
View File
@@ -0,0 +1,123 @@
# MCP Cluster Ops
An MCP server that exposes tools for executing commands across a [Turnstone](https://github.com/turnstonelabs/turnstone) cluster. Serves as a reference implementation for both MCP server patterns and Turnstone MQ client SDK usage.
## How it works
This server uses Turnstone's MQ client (`TurnstoneClient`) to dispatch shell commands to specific nodes via Redis. Remote agents execute the command and the raw bash output is captured directly from the `ToolResultEvent` stream — bypassing the costly "agent reads output → re-generates output as completion tokens" round-trip.
Multi-node dispatches run in parallel via `asyncio.gather`, so total wall time is bounded by the slowest node rather than the sum.
## Tools
| Tool | Description |
|------|-------------|
| `list_nodes` | Discover active nodes in the cluster |
| `run_on_node` | Execute a command on a specific node |
| `run_on_nodes` | Execute a command on selected nodes in parallel |
| `run_on_all_nodes` | Execute a command on ALL active nodes in parallel |
## Prerequisites
- A running Turnstone cluster (at least one `turnstone-server` + `turnstone-bridge`)
- Redis accessible from wherever this MCP server runs
- Python 3.11+
## Installation
```bash
# From the turnstone repo root:
pip install -e ./examples/mcp-cluster-ops
# Or install turnstone with MQ support first, then the example:
pip install -e ".[mq]"
pip install -e ./examples/mcp-cluster-ops
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `REDIS_HOST` | `localhost` | Redis host |
| `REDIS_PORT` | `6379` | Redis port |
| `REDIS_PASSWORD` | _(none)_ | Redis password (use env vars, not config files) |
| `MCP_CLUSTER_OPS_TIMEOUT` | `120` | Default command timeout (seconds, clamped 5-3600) |
| `MCP_CLUSTER_OPS_MAX_OUTPUT` | `8192` | Max output bytes per node (0 = unlimited) |
| `MCP_CLUSTER_OPS_MAX_NODES` | `32` | Max concurrent node dispatches |
| `MCP_CLUSTER_OPS_MAX_COMMAND` | `65536` | Max command string length |
### Register with Turnstone
**TOML** (`~/.config/turnstone/config.toml`):
```toml
[mcp.servers.cluster-ops]
command = "mcp-cluster-ops"
[mcp.servers.cluster-ops.env]
REDIS_HOST = "redis.example.com"
```
**JSON** (via `--mcp-config`):
```json
{
"mcpServers": {
"cluster-ops": {
"command": "mcp-cluster-ops",
"env": {
"REDIS_HOST": "redis.example.com"
}
}
}
}
```
## Usage Examples
Once registered, the tools appear in any Turnstone session. The model can:
```
> Check disk usage across the cluster
[calls list_nodes → discovers node-1, node-2, node-3]
[calls run_on_all_nodes with "df -h /"]
node-1: /dev/sda1 500G 320G 180G 64% /
node-2: /dev/sda1 500G 410G 90G 82% /
node-3: /dev/sda1 1.0T 200G 800G 20% /
```
## Why MQ client instead of HTTP SDK?
The HTTP SDK (`TurnstoneServer`) talks to a single server instance. The MQ client (`TurnstoneClient`) routes through Redis with `target_node` support, which is the entire point of cross-node cluster operations.
## Security Considerations
**This MCP server grants the calling agent shell access to cluster nodes.**
- Commands are executed with `auto_approve=True` and the privileges of the
Turnstone server process on the target node.
- Command output (which may contain secrets, credentials, or sensitive data)
is returned through the MCP tool result and becomes part of the LLM context.
- The security boundary is at the MCP host layer -- use Turnstone's tool
policy system to restrict which agents can invoke these tools.
- Set `REDIS_PASSWORD` via your environment or a secrets manager -- avoid
hardcoding passwords in config files.
## Development
```bash
cd examples/mcp-cluster-ops
# Run tests
pip install -e ".[test]"
pytest
# Lint
pip install -e ".[dev]"
ruff check mcp_cluster_ops/
mypy --strict mcp_cluster_ops/
```
@@ -0,0 +1,3 @@
"""MCP server for Turnstone cluster operations."""
__version__ = "0.1.0"
@@ -0,0 +1,4 @@
from mcp_cluster_ops.server import main
if __name__ == "__main__":
main()
@@ -0,0 +1,404 @@
"""MCP server for Turnstone cluster operations.
Exposes tools to execute commands on specific nodes in a Turnstone cluster.
Uses the MQ client (``TurnstoneClient``) for direct node targeting via Redis.
Usage::
mcp-cluster-ops # via entry point
python -m mcp_cluster_ops # via module
Configure in ``~/.config/turnstone/config.toml``::
[mcp.servers.cluster-ops]
command = "mcp-cluster-ops"
[mcp.servers.cluster-ops.env]
REDIS_HOST = "redis.example.com"
Environment variables
---------------------
REDIS_HOST Redis host (default: localhost)
REDIS_PORT Redis port (default: 6379)
REDIS_PASSWORD Redis password (default: none)
MCP_CLUSTER_OPS_TIMEOUT Default command timeout in seconds (default: 120)
MCP_CLUSTER_OPS_MAX_OUTPUT Max output bytes per node (default: 8192, 0=unlimited)
Performance notes
-----------------
Remote agents are told to reply with only "ok" or "failed" the raw bash
output is captured directly from the ToolResultEvent that already flows
through Redis, bypassing the costly "agent reads output then re-generates
output as completion tokens" round-trip.
All multi-node dispatches run in parallel via ``asyncio.gather`` so total
wall time is bounded by the slowest node, not the sum of all nodes.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from mcp.server.fastmcp import Context, FastMCP
from turnstone.mq.client import TurnResult, TurnstoneClient
if TYPE_CHECKING:
from collections.abc import AsyncIterator
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
_DEFAULT_TIMEOUT = int(os.environ.get("MCP_CLUSTER_OPS_TIMEOUT", "120"))
_DEFAULT_MAX_OUTPUT = int(os.environ.get("MCP_CLUSTER_OPS_MAX_OUTPUT", "8192"))
_MAX_CONCURRENT_NODES = int(os.environ.get("MCP_CLUSTER_OPS_MAX_NODES", "32"))
_MAX_COMMAND_LEN = int(os.environ.get("MCP_CLUSTER_OPS_MAX_COMMAND", "65536"))
_MIN_TIMEOUT = 5
_MAX_TIMEOUT = 3600
# ---------------------------------------------------------------------------
# Helpers (pure functions, easily testable)
# ---------------------------------------------------------------------------
def _redis_kwargs() -> dict[str, Any]:
"""Build Redis connection kwargs from environment variables.
Follows the same env var convention as ``turnstone.mq.broker.add_redis_args``:
``REDIS_HOST``, ``REDIS_PORT``, ``REDIS_PASSWORD``.
"""
kwargs: dict[str, Any] = {"host": os.environ.get("REDIS_HOST", "localhost")}
port = os.environ.get("REDIS_PORT")
if port is not None:
kwargs["port"] = int(port)
password = os.environ.get("REDIS_PASSWORD")
if password:
kwargs["password"] = password
return kwargs
def _exec_prompt(command: str) -> str:
"""Build the prompt sent to the remote agent.
Instructs it to run the command and reply minimally so that the raw
bash output (captured via ToolResultEvent) is the primary result,
avoiding token waste from re-transcription.
"""
return (
"Execute this shell command using the bash tool:\n"
f" {command}\n\n"
"After the tool completes, reply with only 'ok' or 'failed'.\n"
"Do NOT repeat, quote, or summarise the command output in your reply."
)
def _extract_output(result: TurnResult) -> str:
"""Extract useful output from a TurnResult.
Prefers raw bash ToolResultEvent output (zero LLM re-transcription cost)
over agent content. Falls back through tool results and content.
"""
bash_outputs = [out for name, out in result.tool_results if name == "bash"]
if bash_outputs:
return "\n".join(bash_outputs)
content: str = result.content
if content:
return content
if result.tool_results:
return str(result.tool_results[0][1])
return ""
def _truncate(text: str, max_bytes: int) -> str:
"""Truncate *text* to at most *max_bytes* UTF-8 bytes.
Appends a marker when truncation occurs. Handles multi-byte characters
safely by decoding with ``errors='ignore'``.
Pass ``max_bytes=0`` to disable truncation.
"""
if max_bytes <= 0:
return text
encoded = text.encode("utf-8")
if len(encoded) <= max_bytes:
return text
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
omitted = len(encoded) - len(truncated.encode("utf-8"))
return truncated + f"\n... [truncated: {omitted} bytes omitted]"
def _clamp_timeout(timeout: int) -> float:
"""Clamp timeout to a safe range."""
return float(max(_MIN_TIMEOUT, min(timeout, _MAX_TIMEOUT)))
def _validate_command(command: str) -> str | None:
"""Validate a command string. Returns an error message or None."""
if not command.strip():
return "command must be a non-empty string"
if len(command) > _MAX_COMMAND_LEN:
return f"command too long ({len(command)} chars, max {_MAX_COMMAND_LEN})"
return None
def _format_node_result(
node_id: str,
result: TurnResult,
max_output: int,
) -> dict[str, Any]:
"""Format a single node's TurnResult for JSON output."""
raw = _extract_output(result)
output = _truncate(raw, max_output)
entry: dict[str, Any] = {
"node": node_id,
"ok": result.ok,
}
if result.timed_out:
entry["timed_out"] = True
if result.ok:
entry["output"] = output
else:
entry["output"] = output or None
if result.errors:
entry["error"] = "; ".join(result.errors)
return entry
# ---------------------------------------------------------------------------
# Core dispatch functions (testable with mocked TurnstoneClient)
# ---------------------------------------------------------------------------
def _exec_on_node_sync(
redis_kw: dict[str, Any],
node_id: str,
command: str,
timeout: float,
) -> tuple[str, TurnResult]:
"""Dispatch *command* to *node_id* and block until complete.
Runs inside ``asyncio.to_thread`` so it does not block the event loop.
Each call creates its own ``TurnstoneClient`` to avoid Redis pub/sub
subscription conflicts between concurrent dispatches.
"""
prompt = _exec_prompt(command)
with TurnstoneClient(**redis_kw) as client:
result = client.send_and_wait(
message=prompt,
target_node=node_id,
auto_approve=True,
timeout=timeout,
)
return node_id, result
async def _dispatch_parallel(
redis_kw: dict[str, Any],
node_ids: list[str],
command: str,
timeout: float,
max_output: int,
) -> list[dict[str, Any]]:
"""Dispatch *command* to all *node_ids* concurrently.
Total wall time is bounded by the slowest node.
"""
tasks = [
asyncio.to_thread(_exec_on_node_sync, redis_kw, nid, command, timeout) for nid in node_ids
]
outcomes = await asyncio.gather(*tasks, return_exceptions=True)
results: list[dict[str, Any]] = []
for nid, outcome in zip(node_ids, outcomes, strict=True):
if isinstance(outcome, BaseException):
if not isinstance(outcome, Exception):
raise outcome # propagate KeyboardInterrupt, SystemExit, etc.
results.append({"node": nid, "ok": False, "error": str(outcome)})
else:
_, turn_result = outcome
results.append(_format_node_result(nid, turn_result, max_output))
return results
def _list_nodes_sync(redis_kw: dict[str, Any]) -> list[dict[str, Any]]:
"""List active cluster nodes (blocking)."""
with TurnstoneClient(**redis_kw) as client:
nodes: list[dict[str, Any]] = client.list_nodes()
return nodes
async def _list_nodes_impl(redis_kw: dict[str, Any]) -> list[dict[str, Any]]:
"""List active cluster nodes."""
return await asyncio.to_thread(_list_nodes_sync, redis_kw)
# ---------------------------------------------------------------------------
# MCP server
# ---------------------------------------------------------------------------
@asynccontextmanager
async def _lifespan(server: FastMCP[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
"""Lifespan context — stores Redis kwargs for tool handlers."""
kw = _redis_kwargs()
yield {"redis_kwargs": kw}
mcp = FastMCP(
"turnstone-cluster-ops",
instructions=(
"Tools for executing commands across a Turnstone AI cluster. "
"Use list_nodes first to discover available nodes, then run_on_node "
"to execute commands on specific nodes or run_on_all_nodes for "
"cluster-wide operations."
),
lifespan=_lifespan,
)
@mcp.tool()
async def list_nodes(ctx: Context[Any, Any, Any]) -> str:
"""List all active nodes in the Turnstone cluster.
Call this before dispatching work to discover available node IDs.
Returns a JSON array of node metadata objects.
"""
redis_kw: dict[str, Any] = ctx.request_context.lifespan_context["redis_kwargs"]
nodes = await _list_nodes_impl(redis_kw)
return json.dumps(nodes, indent=2)
@mcp.tool()
async def run_on_node(
node_id: str,
command: str,
ctx: Context[Any, Any, Any],
timeout: int = _DEFAULT_TIMEOUT,
) -> str:
"""Execute a shell command on a specific node and return the raw output.
Use list_nodes first to discover available node IDs.
Args:
node_id: Target node ID (e.g. 'worker-1.example.com').
command: Shell command to execute on the target node.
timeout: Timeout in seconds (default: 120).
"""
node_id = node_id.strip()
if not node_id:
return json.dumps({"error": "node_id must be a non-empty string"})
cmd_err = _validate_command(command)
if cmd_err:
return json.dumps({"error": cmd_err})
redis_kw: dict[str, Any] = ctx.request_context.lifespan_context["redis_kwargs"]
max_output = _DEFAULT_MAX_OUTPUT
log.info("run_on_node node=%s cmd=%r", node_id, command)
_, result = await asyncio.to_thread(
_exec_on_node_sync, redis_kw, node_id, command, _clamp_timeout(timeout)
)
formatted = _format_node_result(node_id, result, max_output)
return json.dumps(formatted, indent=2)
@mcp.tool()
async def run_on_nodes(
node_ids: list[str],
command: str,
ctx: Context[Any, Any, Any],
timeout: int = _DEFAULT_TIMEOUT,
) -> str:
"""Execute a shell command on specific nodes in parallel.
Results are collected from each node. Total wall time is bounded by
the slowest node rather than the sum.
Args:
node_ids: List of node IDs to target.
command: Shell command to execute.
timeout: Timeout per node in seconds (default: 120).
"""
cmd_err = _validate_command(command)
if cmd_err:
return json.dumps({"error": cmd_err})
redis_kw: dict[str, Any] = ctx.request_context.lifespan_context["redis_kwargs"]
max_output = _DEFAULT_MAX_OUTPUT
clean_ids = list(dict.fromkeys(nid.strip() for nid in node_ids if nid.strip()))
if not clean_ids:
return json.dumps({"error": "node_ids must be a non-empty list"})
if len(clean_ids) > _MAX_CONCURRENT_NODES:
return json.dumps(
{"error": f"Too many nodes ({len(clean_ids)}), max is {_MAX_CONCURRENT_NODES}"}
)
log.info("run_on_nodes nodes=%s cmd=%r", clean_ids, command)
results = await _dispatch_parallel(
redis_kw, clean_ids, command, _clamp_timeout(timeout), max_output
)
return json.dumps(results, indent=2)
@mcp.tool()
async def run_on_all_nodes(
command: str,
ctx: Context[Any, Any, Any],
timeout: int = _DEFAULT_TIMEOUT,
) -> str:
"""Execute a shell command on ALL active nodes in parallel.
Discovers nodes automatically, then dispatches in parallel. Useful for
cluster-wide operations like checking disk usage, GPU status, or
running processes.
Args:
command: Shell command to execute on every node.
timeout: Timeout per node in seconds (default: 120).
"""
cmd_err = _validate_command(command)
if cmd_err:
return json.dumps({"error": cmd_err})
redis_kw: dict[str, Any] = ctx.request_context.lifespan_context["redis_kwargs"]
max_output = _DEFAULT_MAX_OUTPUT
nodes = await _list_nodes_impl(redis_kw)
if not nodes:
return json.dumps({"error": "No active nodes found in cluster"})
node_ids = list(
dict.fromkeys(
nid.strip() for n in nodes if (nid := n.get("node_id") or n.get("id")) and nid.strip()
)
)
if not node_ids:
return json.dumps({"error": "No nodes with identifiable IDs found"})
if len(node_ids) > _MAX_CONCURRENT_NODES:
return json.dumps(
{"error": f"Too many nodes ({len(node_ids)}), max is {_MAX_CONCURRENT_NODES}"}
)
log.info("run_on_all_nodes nodes=%s cmd=%r", node_ids, command)
results = await _dispatch_parallel(
redis_kw, node_ids, command, _clamp_timeout(timeout), max_output
)
return json.dumps(results, indent=2)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Run the MCP cluster-ops server via stdio transport."""
logging.basicConfig(level=logging.INFO)
mcp.run(transport="stdio")
+57
View File
@@ -0,0 +1,57 @@
[build-system]
requires = ["hatchling>=1.29"]
build-backend = "hatchling.build"
[project]
name = "mcp-cluster-ops"
version = "0.1.0"
description = "MCP server for Turnstone cluster operations — reference implementation."
requires-python = ">=3.11"
license = "BUSL-1.1"
dependencies = [
"turnstone[mq]",
"mcp>=1.6",
]
[project.scripts]
mcp-cluster-ops = "mcp_cluster_ops.server:main"
[project.optional-dependencies]
test = ["pytest>=9.0"]
dev = ["ruff>=0.9", "mypy>=1.14", "types-redis>=4.6"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
ignore = ["E501"]
[tool.ruff.format]
quote-style = "double"
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = ["mcp", "mcp.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["turnstone", "turnstone.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
@@ -0,0 +1,192 @@
"""Tests for pure helper functions in mcp_cluster_ops.server."""
from __future__ import annotations
from turnstone.mq.client import TurnResult
from mcp_cluster_ops.server import (
_clamp_timeout,
_exec_prompt,
_extract_output,
_format_node_result,
_truncate,
_validate_command,
)
# ---------------------------------------------------------------------------
# _truncate
# ---------------------------------------------------------------------------
class TestTruncate:
def test_empty_string(self):
assert _truncate("", 100) == ""
def test_under_limit(self):
assert _truncate("hello", 100) == "hello"
def test_at_limit(self):
text = "x" * 50
assert _truncate(text, 50) == text
def test_over_limit(self):
text = "x" * 200
result = _truncate(text, 50)
assert result.startswith("x" * 50)
assert "truncated" in result
assert "150 bytes omitted" in result
def test_unicode_boundary(self):
# U+00E9 (é) is 2 bytes in UTF-8 (0xC3 0xA9), so 5 chars = 10 bytes
text = "\u00e9\u00e9\u00e9\u00e9\u00e9"
result = _truncate(text, 5)
# Should not crash, should truncate cleanly
assert "truncated" in result
def test_zero_disables(self):
text = "x" * 10000
assert _truncate(text, 0) == text
def test_custom_max(self):
text = "abcdefghij" # 10 bytes
result = _truncate(text, 5)
assert result.startswith("abcde")
assert "truncated" in result
# ---------------------------------------------------------------------------
# _extract_output
# ---------------------------------------------------------------------------
class TestExtractOutput:
def test_bash_result_preferred(self):
r = TurnResult(
content_parts=["agent said something"],
tool_results=[("bash", "raw output")],
)
assert _extract_output(r) == "raw output"
def test_multiple_bash_results_joined(self):
r = TurnResult(
tool_results=[("bash", "line1"), ("bash", "line2")],
)
assert _extract_output(r) == "line1\nline2"
def test_content_fallback(self):
r = TurnResult(
content_parts=["agent response"],
tool_results=[("read_file", "file contents")],
)
assert _extract_output(r) == "agent response"
def test_any_tool_fallback(self):
r = TurnResult(
tool_results=[("read_file", "file contents")],
)
assert _extract_output(r) == "file contents"
def test_empty_result(self):
r = TurnResult()
assert _extract_output(r) == ""
def test_bash_preferred_over_content(self):
r = TurnResult(
content_parts=["I ran the command"],
tool_results=[("read_file", "data"), ("bash", "output")],
)
assert _extract_output(r) == "output"
# ---------------------------------------------------------------------------
# _exec_prompt
# ---------------------------------------------------------------------------
class TestExecPrompt:
def test_contains_command(self):
result = _exec_prompt("ls -la /tmp")
assert "ls -la /tmp" in result
def test_suppression_instruction(self):
result = _exec_prompt("echo hello")
assert "Do NOT repeat" in result
assert "ok" in result.lower() or "failed" in result.lower()
# ---------------------------------------------------------------------------
# _format_node_result
# ---------------------------------------------------------------------------
class TestFormatNodeResult:
def test_success(self):
r = TurnResult(tool_results=[("bash", "output data")])
fmt = _format_node_result("node-1", r, 8192)
assert fmt["node"] == "node-1"
assert fmt["ok"] is True
assert fmt["output"] == "output data"
assert "timed_out" not in fmt
def test_timeout(self):
r = TurnResult(timed_out=True)
fmt = _format_node_result("node-1", r, 8192)
assert fmt["ok"] is False
assert fmt["timed_out"] is True
def test_error(self):
r = TurnResult(errors=["connection refused"])
fmt = _format_node_result("node-1", r, 8192)
assert fmt["ok"] is False
assert fmt["error"] == "connection refused"
def test_truncation_applied(self):
r = TurnResult(tool_results=[("bash", "x" * 200)])
fmt = _format_node_result("node-1", r, 50)
assert "truncated" in fmt["output"]
def test_unlimited_output(self):
big = "x" * 100000
r = TurnResult(tool_results=[("bash", big)])
fmt = _format_node_result("node-1", r, 0)
assert fmt["output"] == big
# ---------------------------------------------------------------------------
# _validate_command
# ---------------------------------------------------------------------------
class TestValidateCommand:
def test_valid(self):
assert _validate_command("ls -la") is None
def test_empty(self):
assert _validate_command("") is not None
def test_whitespace_only(self):
assert _validate_command(" ") is not None
def test_too_long(self):
err = _validate_command("x" * 100000)
assert err is not None
assert "too long" in err
# ---------------------------------------------------------------------------
# _clamp_timeout
# ---------------------------------------------------------------------------
class TestClampTimeout:
def test_normal(self):
assert _clamp_timeout(60) == 60.0
def test_too_low(self):
assert _clamp_timeout(1) == 5.0
def test_too_high(self):
assert _clamp_timeout(99999) == 3600.0
def test_negative(self):
assert _clamp_timeout(-1) == 5.0
@@ -0,0 +1,149 @@
"""Tests for MCP tool handlers with mocked TurnstoneClient."""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import MagicMock, patch
from turnstone.mq.client import TurnResult
from mcp_cluster_ops.server import (
_dispatch_parallel,
_exec_on_node_sync,
_list_nodes_impl,
)
# ---------------------------------------------------------------------------
# _list_nodes_impl
# ---------------------------------------------------------------------------
class TestListNodesImpl:
def test_returns_nodes(self):
nodes = [{"node_id": "a", "model": "gpt-5"}, {"node_id": "b", "model": "gpt-5"}]
with patch("mcp_cluster_ops.server.TurnstoneClient") as mock_cls:
mock_client = MagicMock()
mock_client.list_nodes.return_value = nodes
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
assert result == nodes
def test_empty_cluster(self):
with patch("mcp_cluster_ops.server.TurnstoneClient") as mock_cls:
mock_client = MagicMock()
mock_client.list_nodes.return_value = []
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
assert result == []
# ---------------------------------------------------------------------------
# _exec_on_node_sync
# ---------------------------------------------------------------------------
class TestExecOnNodeSync:
def test_success(self):
turn_result = TurnResult(
tool_results=[("bash", "hello world")],
)
with patch("mcp_cluster_ops.server.TurnstoneClient") as mock_cls:
mock_client = MagicMock()
mock_client.send_and_wait.return_value = turn_result
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
node_id, result = _exec_on_node_sync(
{"host": "localhost"}, "node-1", "echo hello", 60.0
)
assert node_id == "node-1"
assert result.ok
mock_client.send_and_wait.assert_called_once()
call_kwargs = mock_client.send_and_wait.call_args
assert call_kwargs.kwargs["target_node"] == "node-1"
assert call_kwargs.kwargs["auto_approve"] is True
def test_timeout(self):
turn_result = TurnResult(timed_out=True)
with patch("mcp_cluster_ops.server.TurnstoneClient") as mock_cls:
mock_client = MagicMock()
mock_client.send_and_wait.return_value = turn_result
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
_, result = _exec_on_node_sync({"host": "localhost"}, "node-1", "sleep 9999", 1.0)
assert result.timed_out
assert not result.ok
# ---------------------------------------------------------------------------
# _dispatch_parallel
# ---------------------------------------------------------------------------
class TestDispatchParallel:
def test_parallel_success(self):
def fake_exec(redis_kw: Any, node_id: str, command: str, timeout: float) -> Any:
return (node_id, TurnResult(tool_results=[("bash", f"output-{node_id}")]))
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
results = asyncio.run(
_dispatch_parallel(
{"host": "localhost"},
["a", "b", "c"],
"echo hi",
60.0,
8192,
)
)
assert len(results) == 3
assert all(r["ok"] for r in results)
outputs = {r["node"]: r["output"] for r in results}
assert outputs["a"] == "output-a"
assert outputs["b"] == "output-b"
def test_partial_failure(self):
def fake_exec(redis_kw: Any, node_id: str, command: str, timeout: float) -> Any:
if node_id == "bad":
raise ConnectionError("Redis down")
return (node_id, TurnResult(tool_results=[("bash", "ok")]))
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
results = asyncio.run(
_dispatch_parallel(
{"host": "localhost"},
["good", "bad"],
"echo hi",
60.0,
8192,
)
)
assert len(results) == 2
good = next(r for r in results if r["node"] == "good")
bad = next(r for r in results if r["node"] == "bad")
assert good["ok"] is True
assert bad["ok"] is False
assert "Redis down" in bad["error"]
def test_all_fail(self):
def fake_exec(redis_kw: Any, node_id: str, command: str, timeout: float) -> Any:
raise RuntimeError(f"fail-{node_id}")
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
results = asyncio.run(
_dispatch_parallel(
{"host": "localhost"},
["a", "b"],
"echo hi",
60.0,
8192,
)
)
assert all(not r["ok"] for r in results)
assert "fail-a" in results[0]["error"]
assert "fail-b" in results[1]["error"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.6.0"
version = "0.6.1"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
File diff suppressed because it is too large Load Diff
+559 -61
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "0.4.2",
"version": "0.6.0",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -10,7 +10,9 @@
"get": {
"summary": "List active workstreams",
"operationId": "v1_api_workstreams_get",
"tags": ["Workstreams"],
"tags": [
"Workstreams"
],
"responses": {
"200": {
"description": "Success",
@@ -29,7 +31,9 @@
"get": {
"summary": "Dashboard with workstream details and aggregates",
"operationId": "v1_api_dashboard_get",
"tags": ["Workstreams"],
"tags": [
"Workstreams"
],
"responses": {
"200": {
"description": "Success",
@@ -48,7 +52,9 @@
"post": {
"summary": "Create a new workstream",
"operationId": "v1_api_workstreams_new_post",
"tags": ["Workstreams"],
"tags": [
"Workstreams"
],
"requestBody": {
"required": true,
"content": {
@@ -87,7 +93,9 @@
"post": {
"summary": "Close a workstream",
"operationId": "v1_api_workstreams_close_post",
"tags": ["Workstreams"],
"tags": [
"Workstreams"
],
"requestBody": {
"required": true,
"content": {
@@ -126,7 +134,9 @@
"post": {
"summary": "Send a user message",
"operationId": "v1_api_send_post",
"tags": ["Chat"],
"tags": [
"Chat"
],
"requestBody": {
"required": true,
"content": {
@@ -175,7 +185,9 @@
"post": {
"summary": "Approve or deny a tool call",
"operationId": "v1_api_approve_post",
"tags": ["Chat"],
"tags": [
"Chat"
],
"requestBody": {
"required": true,
"content": {
@@ -214,7 +226,9 @@
"post": {
"summary": "Respond to a plan review",
"operationId": "v1_api_plan_post",
"tags": ["Chat"],
"tags": [
"Chat"
],
"requestBody": {
"required": true,
"content": {
@@ -253,7 +267,9 @@
"post": {
"summary": "Execute a slash command",
"operationId": "v1_api_command_post",
"tags": ["Chat"],
"tags": [
"Chat"
],
"requestBody": {
"required": true,
"content": {
@@ -302,7 +318,9 @@
"post": {
"summary": "Cancel the active generation in a workstream",
"operationId": "v1_api_cancel_post",
"tags": ["Chat"],
"tags": [
"Chat"
],
"requestBody": {
"required": true,
"content": {
@@ -351,7 +369,9 @@
"get": {
"summary": "Per-workstream SSE event stream",
"operationId": "v1_api_events_get",
"tags": ["Streaming"],
"tags": [
"Streaming"
],
"description": "Opens a Server-Sent Events stream scoped to a single workstream. Returns text/event-stream. See API reference for event types.",
"parameters": [
{
@@ -385,7 +405,9 @@
"get": {
"summary": "Global SSE event stream",
"operationId": "v1_api_events_global_get",
"tags": ["Streaming"],
"tags": [
"Streaming"
],
"description": "Global Server-Sent Events stream for state-change broadcasts across all workstreams. Returns text/event-stream.",
"responses": {
"200": {
@@ -398,7 +420,9 @@
"get": {
"summary": "List saved workstreams",
"operationId": "v1_api_workstreams_saved_get",
"tags": ["Workstreams"],
"tags": [
"Workstreams"
],
"responses": {
"200": {
"description": "Success",
@@ -417,7 +441,9 @@
"post": {
"summary": "Authenticate with a token",
"operationId": "v1_api_auth_login_post",
"tags": ["Auth"],
"tags": [
"Auth"
],
"requestBody": {
"required": true,
"content": {
@@ -456,7 +482,9 @@
"post": {
"summary": "Create first admin user",
"operationId": "v1_api_auth_setup_post",
"tags": ["Auth"],
"tags": [
"Auth"
],
"requestBody": {
"required": true,
"content": {
@@ -515,7 +543,9 @@
"get": {
"summary": "Return auth state",
"operationId": "v1_api_auth_status_get",
"tags": ["Auth"],
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success",
@@ -534,7 +564,9 @@
"post": {
"summary": "Clear auth cookie",
"operationId": "v1_api_auth_logout_post",
"tags": ["Auth"],
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success",
@@ -549,11 +581,202 @@
}
}
},
"/v1/api/memories": {
"get": {
"summary": "List structured memories",
"operationId": "v1_api_memories_get",
"tags": [
"Memories"
],
"parameters": [
{
"name": "type",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Filter by memory type"
},
{
"name": "scope",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Filter by scope"
},
{
"name": "scope_id",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Filter by scope identifier"
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 100
},
"description": "Max results (default 100, max 200)"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListMemoriesResponse"
}
}
}
}
}
},
"post": {
"summary": "Save (upsert) a structured memory",
"operationId": "v1_api_memories_post",
"tags": [
"Memories"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SaveMemoryRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemoryInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/memories/search": {
"post": {
"summary": "Search structured memories by query",
"operationId": "v1_api_memories_search_post",
"tags": [
"Memories"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchMemoriesRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListMemoriesResponse"
}
}
}
}
}
}
},
"/v1/api/memories/{name}": {
"delete": {
"summary": "Delete a structured memory by name and scope",
"operationId": "v1_api_memories_{name}_delete",
"tags": [
"Memories"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "scope",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Scope (default: global)"
},
{
"name": "scope_id",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Scope identifier"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/health": {
"get": {
"summary": "Server health check",
"operationId": "health_get",
"tags": ["Observability"],
"tags": [
"Observability"
],
"responses": {
"200": {
"description": "Success",
@@ -580,7 +803,9 @@
"type": "string"
}
},
"required": ["error"],
"required": [
"error"
],
"title": "ErrorResponse",
"type": "object"
},
@@ -589,7 +814,9 @@
"properties": {
"status": {
"default": "ok",
"examples": ["ok"],
"examples": [
"ok"
],
"title": "Status",
"type": "string"
}
@@ -638,14 +865,19 @@
},
"role": {
"description": "Legacy role",
"examples": ["full", "read"],
"examples": [
"full",
"read"
],
"title": "Role",
"type": "string"
},
"scopes": {
"default": "",
"description": "Comma-separated scopes",
"examples": ["read,write,approve"],
"examples": [
"read,write,approve"
],
"title": "Scopes",
"type": "string"
},
@@ -656,7 +888,9 @@
"type": "string"
}
},
"required": ["role"],
"required": [
"role"
],
"title": "AuthLoginResponse",
"type": "object"
},
@@ -679,7 +913,11 @@
"type": "string"
}
},
"required": ["username", "display_name", "password"],
"required": [
"username",
"display_name",
"password"
],
"title": "AuthSetupRequest",
"type": "object"
},
@@ -716,7 +954,10 @@
"type": "string"
}
},
"required": ["user_id", "username"],
"required": [
"user_id",
"username"
],
"title": "AuthSetupResponse",
"type": "object"
},
@@ -736,7 +977,11 @@
"type": "boolean"
}
},
"required": ["auth_enabled", "has_users", "setup_required"],
"required": [
"auth_enabled",
"has_users",
"setup_required"
],
"title": "AuthStatusResponse",
"type": "object"
},
@@ -753,7 +998,10 @@
"type": "string"
}
},
"required": ["message", "ws_id"],
"required": [
"message",
"ws_id"
],
"title": "SendRequest",
"type": "object"
},
@@ -761,12 +1009,17 @@
"properties": {
"status": {
"description": "'ok' or 'busy'",
"examples": ["ok", "busy"],
"examples": [
"ok",
"busy"
],
"title": "Status",
"type": "string"
}
},
"required": ["status"],
"required": [
"status"
],
"title": "SendResponse",
"type": "object"
},
@@ -802,7 +1055,10 @@
"type": "string"
}
},
"required": ["approved", "ws_id"],
"required": [
"approved",
"ws_id"
],
"title": "ApproveRequest",
"type": "object"
},
@@ -819,7 +1075,10 @@
"type": "string"
}
},
"required": ["feedback", "ws_id"],
"required": [
"feedback",
"ws_id"
],
"title": "PlanFeedbackRequest",
"type": "object"
},
@@ -836,7 +1095,10 @@
"type": "string"
}
},
"required": ["command", "ws_id"],
"required": [
"command",
"ws_id"
],
"title": "CommandRequest",
"type": "object"
},
@@ -848,7 +1110,9 @@
"type": "string"
}
},
"required": ["ws_id"],
"required": [
"ws_id"
],
"title": "CancelRequest",
"type": "object"
},
@@ -886,7 +1150,7 @@
},
"ws_template": {
"default": "",
"description": "Workstream template name (behavioral profile applied at creation)",
"description": "Workstream template name to apply defaults from",
"title": "Ws Template",
"type": "string"
}
@@ -919,7 +1183,10 @@
"type": "integer"
}
},
"required": ["ws_id", "name"],
"required": [
"ws_id",
"name"
],
"title": "CreateWorkstreamResponse",
"type": "object"
},
@@ -931,7 +1198,9 @@
"type": "string"
}
},
"required": ["ws_id"],
"required": [
"ws_id"
],
"title": "CloseWorkstreamRequest",
"type": "object"
},
@@ -945,7 +1214,9 @@
"type": "array"
}
},
"required": ["workstreams"],
"required": [
"workstreams"
],
"title": "ListWorkstreamsResponse",
"type": "object"
},
@@ -964,7 +1235,11 @@
"type": "string"
}
},
"required": ["id", "name", "state"],
"required": [
"id",
"name",
"state"
],
"title": "WorkstreamInfo",
"type": "object"
},
@@ -981,7 +1256,10 @@
"$ref": "#/components/schemas/DashboardAggregate"
}
},
"required": ["workstreams", "aggregate"],
"required": [
"workstreams",
"aggregate"
],
"title": "DashboardResponse",
"type": "object"
},
@@ -1081,7 +1359,11 @@
"type": "string"
}
},
"required": ["id", "name", "state"],
"required": [
"id",
"name",
"state"
],
"title": "DashboardWorkstream",
"type": "object"
},
@@ -1095,7 +1377,9 @@
"type": "array"
}
},
"required": ["workstreams"],
"required": [
"workstreams"
],
"title": "ListSavedWorkstreamsResponse",
"type": "object"
},
@@ -1142,14 +1426,22 @@
"type": "integer"
}
},
"required": ["ws_id", "created", "updated", "message_count"],
"required": [
"ws_id",
"created",
"updated",
"message_count"
],
"title": "SavedWorkstreamInfo",
"type": "object"
},
"HealthResponse": {
"properties": {
"status": {
"examples": ["ok", "degraded"],
"examples": [
"ok",
"degraded"
],
"title": "Status",
"type": "string"
},
@@ -1202,10 +1494,39 @@
"default": null
}
},
"required": ["status"],
"required": [
"status"
],
"title": "HealthResponse",
"type": "object"
},
"BackendStatus": {
"properties": {
"status": {
"examples": [
"up",
"down"
],
"title": "Status",
"type": "string"
},
"circuit_state": {
"examples": [
"closed",
"open",
"half_open"
],
"title": "Circuit State",
"type": "string"
}
},
"required": [
"status",
"circuit_state"
],
"title": "BackendStatus",
"type": "object"
},
"McpStatus": {
"properties": {
"servers": {
@@ -1227,23 +1548,6 @@
"title": "McpStatus",
"type": "object"
},
"BackendStatus": {
"properties": {
"status": {
"examples": ["up", "down"],
"title": "Status",
"type": "string"
},
"circuit_state": {
"examples": ["closed", "open", "half_open"],
"title": "Circuit State",
"type": "string"
}
},
"required": ["status", "circuit_state"],
"title": "BackendStatus",
"type": "object"
},
"WorkstreamCounts": {
"properties": {
"total": {
@@ -1279,6 +1583,200 @@
},
"title": "WorkstreamCounts",
"type": "object"
},
"SaveMemoryRequest": {
"properties": {
"name": {
"description": "Memory identifier (normalized to snake_case)",
"title": "Name",
"type": "string"
},
"content": {
"description": "Memory content",
"maxLength": 65536,
"title": "Content",
"type": "string"
},
"description": {
"default": "",
"description": "Short description for relevance matching",
"title": "Description",
"type": "string"
},
"type": {
"default": "project",
"description": "Memory type",
"enum": [
"user",
"project",
"feedback",
"reference"
],
"title": "Type",
"type": "string"
},
"scope": {
"default": "global",
"description": "Memory scope",
"enum": [
"global",
"workstream",
"user"
],
"title": "Scope",
"type": "string"
},
"scope_id": {
"default": "",
"description": "Scope identifier (ws_id for workstream, user_id for user scope)",
"title": "Scope Id",
"type": "string"
}
},
"required": [
"name",
"content"
],
"title": "SaveMemoryRequest",
"type": "object"
},
"MemoryInfo": {
"properties": {
"memory_id": {
"title": "Memory Id",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"description": {
"default": "",
"title": "Description",
"type": "string"
},
"type": {
"enum": [
"user",
"project",
"feedback",
"reference"
],
"title": "Type",
"type": "string"
},
"scope": {
"enum": [
"global",
"workstream",
"user"
],
"title": "Scope",
"type": "string"
},
"scope_id": {
"default": "",
"title": "Scope Id",
"type": "string"
},
"content": {
"title": "Content",
"type": "string"
},
"created": {
"title": "Created",
"type": "string"
},
"updated": {
"title": "Updated",
"type": "string"
}
},
"required": [
"memory_id",
"name",
"type",
"scope",
"content",
"created",
"updated"
],
"title": "MemoryInfo",
"type": "object"
},
"ListMemoriesResponse": {
"properties": {
"memories": {
"items": {
"$ref": "#/components/schemas/MemoryInfo"
},
"title": "Memories",
"type": "array"
},
"total": {
"default": 0,
"title": "Total",
"type": "integer"
}
},
"required": [
"memories"
],
"title": "ListMemoriesResponse",
"type": "object"
},
"SearchMemoriesRequest": {
"properties": {
"query": {
"description": "Search query text",
"title": "Query",
"type": "string"
},
"type": {
"default": "",
"description": "Filter by memory type",
"enum": [
"",
"user",
"project",
"feedback",
"reference"
],
"title": "Type",
"type": "string"
},
"scope": {
"default": "",
"description": "Filter by scope",
"enum": [
"",
"global",
"workstream",
"user"
],
"title": "Scope",
"type": "string"
},
"scope_id": {
"default": "",
"description": "Filter by scope_id",
"title": "Scope Id",
"type": "string"
},
"limit": {
"default": 20,
"description": "Max results (1-50)",
"maximum": 50,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
"required": [
"query"
],
"title": "SearchMemoriesRequest",
"type": "object"
}
}
}
+67
View File
@@ -1,6 +1,9 @@
import { BaseClient, type ClientOptions } from "./base.js";
import type { ClusterEvent } from "./events.js";
import type {
AdminListMemoriesOptions,
AdminMemoryInfo,
AdminSearchMemoriesOptions,
AuditQueryOptions,
AuditResponse,
AuthLoginResponse,
@@ -18,20 +21,25 @@ import type {
CreateScheduleRequest,
CreateTemplateOptions,
CreateWsTemplateOptions,
ListAdminMemoriesResponse,
ListScheduleRunsResponse,
ListSchedulesResponse,
ListSettingSchemaResponse,
ListSettingsResponse,
NodeDetailResponse,
NodesOptions,
OrgInfo,
PromptTemplateInfo,
RoleInfo,
ScheduleInfo,
SettingInfo,
StatusResponse,
ToolPolicyInfo,
UpdateOrgOptions,
UpdatePolicyOptions,
UpdateRoleOptions,
UpdateScheduleRequest,
UpdateSettingOptions,
UpdateTemplateOptions,
UpdateWsTemplateOptions,
UsageQueryOptions,
@@ -343,4 +351,63 @@ export class TurnstoneConsole extends BaseClient {
if (opts?.offset !== undefined) params.offset = String(opts.offset);
return this.request("GET", "/v1/api/admin/audit", { params });
}
// -- Admin: Memories ------------------------------------------------------
async listMemories(
opts?: AdminListMemoriesOptions,
): Promise<ListAdminMemoriesResponse> {
const params: Record<string, string | number> = {};
if (opts?.type) params.type = opts.type;
if (opts?.scope) params.scope = opts.scope;
if (opts?.scope_id) params.scope_id = opts.scope_id;
if (opts?.limit !== undefined) params.limit = opts.limit;
return this.request("GET", "/v1/api/admin/memories", { params });
}
async searchMemories(
opts: AdminSearchMemoriesOptions,
): Promise<ListAdminMemoriesResponse> {
const params: Record<string, string | number> = { q: opts.q };
if (opts.type) params.type = opts.type;
if (opts.scope) params.scope = opts.scope;
if (opts.scope_id) params.scope_id = opts.scope_id;
if (opts.limit !== undefined) params.limit = opts.limit;
return this.request("GET", "/v1/api/admin/memories/search", { params });
}
async getMemory(memoryId: string): Promise<AdminMemoryInfo> {
return this.request("GET", `/v1/api/admin/memories/${memoryId}`);
}
async deleteMemory(memoryId: string): Promise<StatusResponse> {
return this.request("DELETE", `/v1/api/admin/memories/${memoryId}`);
}
// -- System: Settings -------------------------------------------------------
async listSettings(): Promise<ListSettingsResponse> {
return this.request("GET", "/v1/api/admin/settings");
}
async getSettingsSchema(): Promise<ListSettingSchemaResponse> {
return this.request("GET", "/v1/api/admin/settings/schema");
}
async updateSetting(
key: string,
opts: UpdateSettingOptions,
): Promise<SettingInfo> {
return this.request("PUT", `/v1/api/admin/settings/${key}`, {
json: opts,
});
}
async deleteSetting(key: string, nodeId?: string): Promise<StatusResponse> {
const params: Record<string, string> = {};
if (nodeId) params.node_id = nodeId;
return this.request("DELETE", `/v1/api/admin/settings/${key}`, {
params,
});
}
}
+17
View File
@@ -143,6 +143,23 @@ export type {
SendAndWaitOptions,
NodesOptions,
WorkstreamsOptions,
// Memory types
SaveMemoryRequest,
MemoryInfo,
ListMemoriesResponse,
SearchMemoriesRequest,
ListMemoriesOptions,
DeleteMemoryOptions,
AdminMemoryInfo,
ListAdminMemoriesResponse,
AdminListMemoriesOptions,
AdminSearchMemoriesOptions,
// Settings types
SettingInfo,
ListSettingsResponse,
SettingSchemaInfo,
ListSettingSchemaResponse,
UpdateSettingOptions,
} from "./types.js";
// SSE parser (for advanced usage)
+39
View File
@@ -7,9 +7,15 @@ import type {
CreateWorkstreamRequest,
CreateWorkstreamResponse,
DashboardResponse,
DeleteMemoryOptions,
HealthResponse,
ListMemoriesOptions,
ListMemoriesResponse,
ListSavedWorkstreamsResponse,
ListWorkstreamsResponse,
MemoryInfo,
SaveMemoryRequest,
SearchMemoriesRequest,
SendAndWaitOptions,
SendResponse,
StatusResponse,
@@ -190,6 +196,39 @@ export class TurnstoneServer extends BaseClient {
return this.request("GET", "/v1/api/workstreams/saved");
}
// -- Memories -------------------------------------------------------------
async listMemories(
opts?: ListMemoriesOptions,
): Promise<ListMemoriesResponse> {
const params: Record<string, string | number> = {};
if (opts?.type) params.type = opts.type;
if (opts?.scope) params.scope = opts.scope;
if (opts?.scope_id) params.scope_id = opts.scope_id;
if (opts?.limit !== undefined) params.limit = opts.limit;
return this.request("GET", "/v1/api/memories", { params });
}
async saveMemory(opts: SaveMemoryRequest): Promise<MemoryInfo> {
return this.request("POST", "/v1/api/memories", { json: opts });
}
async searchMemories(
opts: SearchMemoriesRequest,
): Promise<ListMemoriesResponse> {
return this.request("POST", "/v1/api/memories/search", { json: opts });
}
async deleteMemory(
name: string,
opts?: DeleteMemoryOptions,
): Promise<StatusResponse> {
const params: Record<string, string> = {};
if (opts?.scope) params.scope = opts.scope;
if (opts?.scope_id) params.scope_id = opts.scope_id;
return this.request("DELETE", `/v1/api/memories/${name}`, { params });
}
// -- Auth -----------------------------------------------------------------
async login(opts: {
+126
View File
@@ -644,5 +644,131 @@ export interface WorkstreamsOptions {
per_page?: number;
}
// -- Server API: Memories ---------------------------------------------------
export interface SaveMemoryRequest {
name: string;
content: string;
description?: string;
type?: "user" | "project" | "feedback" | "reference";
scope?: "global" | "workstream" | "user";
scope_id?: string;
}
export interface MemoryInfo {
memory_id: string;
name: string;
description: string;
type: string;
scope: string;
scope_id: string;
content: string;
created: string;
updated: string;
}
export interface ListMemoriesResponse {
memories: MemoryInfo[];
total: number;
}
export interface SearchMemoriesRequest {
query: string;
type?: string;
scope?: string;
scope_id?: string;
limit?: number;
}
export interface ListMemoriesOptions {
type?: string;
scope?: string;
scope_id?: string;
limit?: number;
}
export interface DeleteMemoryOptions {
scope?: string;
scope_id?: string;
}
// -- Console API: Admin Memories --------------------------------------------
export interface AdminMemoryInfo {
memory_id: string;
name: string;
description: string;
type: string;
scope: string;
scope_id: string;
content: string;
created: string;
updated: string;
last_accessed: string;
access_count: number;
}
export interface ListAdminMemoriesResponse {
memories: AdminMemoryInfo[];
total: number;
}
export interface AdminListMemoriesOptions {
type?: string;
scope?: string;
scope_id?: string;
limit?: number;
}
export interface AdminSearchMemoriesOptions {
q: string;
type?: string;
scope?: string;
scope_id?: string;
limit?: number;
}
// -- Console API: System Settings -------------------------------------------
export interface SettingInfo {
key: string;
value: unknown;
source: string;
type: string;
description: string;
section: string;
is_secret: boolean;
node_id: string;
changed_by: string;
updated: string;
restart_required: boolean;
}
export interface ListSettingsResponse {
settings: SettingInfo[];
}
export interface SettingSchemaInfo {
key: string;
type: string;
default: unknown;
description: string;
section: string;
is_secret: boolean;
min_value: number | null;
max_value: number | null;
choices: string[] | null;
restart_required: boolean;
}
export interface ListSettingSchemaResponse {
schema: SettingSchemaInfo[];
}
export interface UpdateSettingOptions {
value: unknown;
node_id?: string;
}
// Re-export event types for convenience
export type { ServerEvent, ClusterEvent } from "./events.js";
+26
View File
@@ -143,6 +143,32 @@ class TestRequiredScope:
def test_proxy_v1_read_endpoint_needs_read(self):
assert required_scope("GET", "/node/node-a/v1/api/workstreams") == "read"
# Memory endpoints
def test_get_memories_needs_read(self):
assert required_scope("GET", "/api/memories") == "read"
def test_post_memories_needs_write(self):
assert required_scope("POST", "/api/memories") == "write"
def test_post_memories_search_needs_read(self):
"""Search via POST is non-mutating — requires only read scope."""
assert required_scope("POST", "/api/memories/search") == "read"
def test_delete_memory_needs_write(self):
assert required_scope("DELETE", "/api/memories/my_key") == "write"
def test_v1_post_memories_needs_write(self):
assert required_scope("POST", "/v1/api/memories") == "write"
def test_v1_delete_memory_needs_write(self):
assert required_scope("DELETE", "/v1/api/memories/test_key") == "write"
def test_admin_memories_needs_approve(self):
assert required_scope("GET", "/api/admin/memories") == "approve"
def test_admin_memory_delete_needs_approve(self):
assert required_scope("DELETE", "/api/admin/memories/some-id") == "approve"
# ---------------------------------------------------------------------------
# TestAuthConfig
+71
View File
@@ -0,0 +1,71 @@
"""Tests for turnstone.core.bm25 — tokenizer and BM25 index."""
from turnstone.core.bm25 import BM25Index, _tokenize
class TestTokenize:
def test_simple_words(self):
assert _tokenize("hello world") == ["hello", "world"]
def test_underscores(self):
assert _tokenize("read_file") == ["read", "file"]
def test_hyphens(self):
assert _tokenize("web-search") == ["web", "search"]
def test_dots(self):
assert _tokenize("foo.bar.baz") == ["foo", "bar", "baz"]
def test_mixed_separators(self):
assert _tokenize("mcp__server__read_file") == ["mcp", "server", "read", "file"]
def test_empty_string(self):
assert _tokenize("") == []
def test_case_folding(self):
assert _tokenize("Hello World") == ["hello", "world"]
class TestBM25Index:
def test_search_returns_relevant(self):
docs = ["read a file from disk", "search for file in directory", "execute a bash command"]
index = BM25Index(docs)
results = index.search("file", k=2)
assert 0 in results
assert 1 in results
def test_search_empty_query(self):
docs = ["hello world"]
index = BM25Index(docs)
assert index.search("") == []
def test_search_no_match(self):
docs = ["hello world", "foo bar"]
index = BM25Index(docs)
assert index.search("zzzznotfound") == []
def test_search_respects_k(self):
docs = [f"document {i} with common word" for i in range(20)]
index = BM25Index(docs)
results = index.search("common", k=3)
assert len(results) <= 3
def test_empty_corpus(self):
index = BM25Index([])
assert index.search("anything") == []
def test_single_document(self):
index = BM25Index(["the only document about turnstone"])
results = index.search("turnstone")
assert results == [0]
def test_ordering_by_relevance(self):
docs = [
"unrelated content about cooking recipes",
"python programming with file operations",
"read file write file file operations disk io",
]
index = BM25Index(docs)
results = index.search("file operations", k=3)
# Doc 2 has more file/operations mentions, should rank higher
assert results[0] == 2
+193
View File
@@ -0,0 +1,193 @@
"""Tests for ConfigStore database-backed configuration."""
from __future__ import annotations
import pytest
from turnstone.core.config_store import ConfigStore
from turnstone.core.settings_registry import SETTINGS
from turnstone.core.storage._sqlite import SQLiteBackend
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
@pytest.fixture
def store(storage):
return ConfigStore(storage)
# ---------------------------------------------------------------------------
# get()
# ---------------------------------------------------------------------------
class TestGet:
def test_returns_registry_default_when_nothing_stored(self, store):
defn = SETTINGS["tools.timeout"]
assert store.get("tools.timeout") == defn.default
def test_returns_stored_value_after_set(self, store):
store.set("tools.timeout", 60)
assert store.get("tools.timeout") == 60
def test_explicit_default_for_unknown_key(self, store):
# Unknown keys fall back to explicit default
assert store.get("nonexistent.key", 42) == 42
def test_none_for_unknown_key_without_default(self, store):
assert store.get("nonexistent.key") is None
# ---------------------------------------------------------------------------
# set() — validation
# ---------------------------------------------------------------------------
class TestSet:
def test_rejects_unknown_key(self, store):
with pytest.raises(ValueError, match="Unknown setting"):
store.set("bogus.key", "value")
def test_rejects_out_of_range(self, store):
with pytest.raises(ValueError, match="minimum"):
store.set("tools.timeout", 0)
def test_rejects_above_max(self, store):
with pytest.raises(ValueError, match="maximum"):
store.set("tools.timeout", 9999)
# ---------------------------------------------------------------------------
# set() + get() round-trips
# ---------------------------------------------------------------------------
class TestSetGetRoundTrip:
def test_int(self, store):
store.set("tools.timeout", 30)
assert store.get("tools.timeout") == 30
assert isinstance(store.get("tools.timeout"), int)
def test_float(self, store):
store.set("model.temperature", 0.42)
assert store.get("model.temperature") == 0.42
assert isinstance(store.get("model.temperature"), float)
def test_bool(self, store):
store.set("tools.skip_permissions", True)
assert store.get("tools.skip_permissions") is True
store.set("tools.skip_permissions", False)
assert store.get("tools.skip_permissions") is False
def test_str(self, store):
store.set("model.name", "gpt-5")
assert store.get("model.name") == "gpt-5"
# ---------------------------------------------------------------------------
# delete()
# ---------------------------------------------------------------------------
class TestDelete:
def test_reverts_to_default(self, store):
store.set("tools.timeout", 30)
assert store.get("tools.timeout") == 30
store.delete("tools.timeout")
defn = SETTINGS["tools.timeout"]
assert store.get("tools.timeout") == defn.default
def test_returns_false_for_non_existent(self, store):
assert store.delete("tools.timeout") is False
def test_rejects_unknown_key(self, store):
with pytest.raises(ValueError, match="Unknown setting"):
store.delete("nonexistent.key")
# ---------------------------------------------------------------------------
# reload()
# ---------------------------------------------------------------------------
class TestReload:
def test_picks_up_external_storage_changes(self, storage, store):
# Write directly to storage, bypassing ConfigStore
from turnstone.core.settings_registry import serialize_value
storage.upsert_system_setting(
key="tools.timeout",
value=serialize_value(99),
node_id="",
is_secret=False,
changed_by="external",
)
# Not visible yet (cached)
defn = SETTINGS["tools.timeout"]
assert store.get("tools.timeout") == defn.default
# Reload and verify
store.reload()
assert store.get("tools.timeout") == 99
# ---------------------------------------------------------------------------
# all_effective()
# ---------------------------------------------------------------------------
class TestAllEffective:
def test_merges_stored_with_defaults(self, store):
store.set("tools.timeout", 30)
effective = store.all_effective()
# Stored value
assert effective["tools.timeout"] == 30
# Default for unstored
assert effective["memory.relevance_k"] == SETTINGS["memory.relevance_k"].default
# All registry keys present
assert set(effective.keys()) == set(SETTINGS.keys())
# ---------------------------------------------------------------------------
# stored_keys()
# ---------------------------------------------------------------------------
class TestStoredKeys:
def test_returns_correct_set(self, store):
assert store.stored_keys() == frozenset()
store.set("tools.timeout", 30)
assert store.stored_keys() == frozenset({"tools.timeout"})
store.set("model.name", "gpt-5")
assert store.stored_keys() == frozenset({"tools.timeout", "model.name"})
store.delete("tools.timeout")
assert store.stored_keys() == frozenset({"model.name"})
# ---------------------------------------------------------------------------
# version
# ---------------------------------------------------------------------------
class TestVersion:
def test_increments_on_set(self, store):
v0 = store.version
store.set("tools.timeout", 30)
assert store.version == v0 + 1
def test_increments_on_delete(self, store):
store.set("tools.timeout", 30)
v0 = store.version
store.delete("tools.timeout")
assert store.version == v0 + 1
def test_increments_on_reload(self, store):
v0 = store.version
store.reload()
assert store.version == v0 + 1
+4 -1
View File
@@ -16,7 +16,10 @@ class TestSchemaCreation:
engine = get_storage()._engine # noqa: SLF001
with engine.connect() as conn:
rows = conn.execute(
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='memories'")
sa.text(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='structured_memories'"
)
).fetchall()
assert len(rows) == 1
rows = conn.execute(
+421
View File
@@ -0,0 +1,421 @@
"""Tests for memory API endpoints (server + console admin)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import (
admin_delete_memory,
admin_get_memory,
admin_list_memories,
admin_search_memories,
)
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
from turnstone.server import (
delete_memory_endpoint,
list_memories,
save_memory,
search_memories,
)
# ---------------------------------------------------------------------------
# Auth bypass middleware
# ---------------------------------------------------------------------------
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset(
{
"read",
"write",
"approve",
"admin.memories",
}
),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
@pytest.fixture
def server_client(storage):
"""TestClient wired to server memory endpoints."""
import turnstone.core.storage._registry as reg
old = reg._storage
reg._storage = storage
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route("/api/memories", list_memories),
Route("/api/memories", save_memory, methods=["POST"]),
Route("/api/memories/search", search_memories, methods=["POST"]),
Route("/api/memories/{name}", delete_memory_endpoint, methods=["DELETE"]),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
yield TestClient(app)
reg._storage = old
@pytest.fixture
def admin_client(storage):
"""TestClient wired to console admin memory endpoints."""
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route("/api/admin/memories", admin_list_memories),
Route("/api/admin/memories/search", admin_search_memories),
Route("/api/admin/memories/{memory_id}", admin_get_memory),
Route(
"/api/admin/memories/{memory_id}",
admin_delete_memory,
methods=["DELETE"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
return TestClient(app)
def _seed_memory(storage, name="test_key", content="test content", **kw):
"""Helper to insert a memory directly into storage."""
import uuid
mid = kw.pop("memory_id", str(uuid.uuid4()))
storage.create_structured_memory(
mid,
name,
kw.get("description", ""),
kw.get("mem_type", "project"),
kw.get("scope", "global"),
kw.get("scope_id", ""),
content,
)
return mid
# ===========================================================================
# Server endpoint tests
# ===========================================================================
class TestServerListMemories:
def test_empty(self, server_client):
r = server_client.get("/v1/api/memories")
assert r.status_code == 200
data = r.json()
assert data["memories"] == []
assert data["total"] == 0
def test_with_data(self, server_client, storage):
_seed_memory(storage, "key_a", "content a")
_seed_memory(storage, "key_b", "content b")
r = server_client.get("/v1/api/memories")
assert r.status_code == 200
assert r.json()["total"] == 2
def test_filter_by_type(self, server_client, storage):
_seed_memory(storage, "a", "x", mem_type="user")
_seed_memory(storage, "b", "y", mem_type="project")
r = server_client.get("/v1/api/memories?type=user")
assert r.json()["total"] == 1
assert r.json()["memories"][0]["name"] == "a"
def test_filter_by_scope(self, server_client, storage):
_seed_memory(storage, "a", "x", scope="global")
_seed_memory(storage, "b", "y", scope="workstream", scope_id="ws1")
r = server_client.get("/v1/api/memories?scope=workstream&scope_id=ws1")
assert r.json()["total"] == 1
assert r.json()["memories"][0]["name"] == "b"
def test_limit(self, server_client, storage):
for i in range(5):
_seed_memory(storage, f"k{i}", f"v{i}")
r = server_client.get("/v1/api/memories?limit=2")
assert r.json()["total"] == 2
def test_invalid_limit(self, server_client):
r = server_client.get("/v1/api/memories?limit=abc")
assert r.status_code == 400
class TestServerSaveMemory:
def test_create(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "my_key", "content": "my content"},
)
assert r.status_code == 201
data = r.json()
assert data["name"] == "my_key"
assert data["content"] == "my content"
assert data["type"] == "project"
assert data["scope"] == "global"
def test_upsert(self, server_client):
server_client.post(
"/v1/api/memories",
json={"name": "key", "content": "v1"},
)
r = server_client.post(
"/v1/api/memories",
json={"name": "key", "content": "v2"},
)
assert r.status_code == 200
assert r.json()["content"] == "v2"
def test_with_type_and_scope(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={
"name": "feedback_key",
"content": "data",
"type": "feedback",
"scope": "workstream",
"scope_id": "ws1",
},
)
assert r.status_code == 201
assert r.json()["type"] == "feedback"
assert r.json()["scope"] == "workstream"
def test_missing_name(self, server_client):
r = server_client.post("/v1/api/memories", json={"content": "data"})
assert r.status_code == 400
def test_missing_content(self, server_client):
r = server_client.post("/v1/api/memories", json={"name": "k"})
assert r.status_code == 400
def test_invalid_type(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "k", "content": "c", "type": "bogus"},
)
assert r.status_code == 400
assert "invalid type" in r.json()["error"]
def test_invalid_scope(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "k", "content": "c", "scope": "bogus"},
)
assert r.status_code == 400
assert "invalid scope" in r.json()["error"]
def test_content_too_large(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "k", "content": "x" * 70000},
)
assert r.status_code == 400
assert "limit" in r.json()["error"]
def test_name_normalisation(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "My-Key Name", "content": "data"},
)
assert r.status_code == 201
assert r.json()["name"] == "my_key_name"
class TestServerUserScopeSecurity:
def test_user_scope_binds_to_auth(self, server_client):
"""User scope auto-resolves scope_id from authenticated user."""
r = server_client.post(
"/v1/api/memories",
json={"name": "priv", "content": "secret", "scope": "user"},
)
assert r.status_code == 201
assert r.json()["scope_id"] == "test-user"
def test_user_scope_rejects_cross_user(self, server_client):
"""Cannot access another user's memories via scope_id."""
r = server_client.post(
"/v1/api/memories",
json={"name": "x", "content": "y", "scope": "user", "scope_id": "other-user"},
)
assert r.status_code == 403
def test_user_scope_allows_own_scope_id(self, server_client):
"""Passing own user_id as scope_id is allowed."""
r = server_client.post(
"/v1/api/memories",
json={"name": "x", "content": "y", "scope": "user", "scope_id": "test-user"},
)
assert r.status_code == 201
def test_list_rejects_cross_user(self, server_client):
r = server_client.get("/v1/api/memories?scope=user&scope_id=other-user")
assert r.status_code == 403
def test_delete_rejects_cross_user(self, server_client, storage):
_seed_memory(storage, "k", "v", scope="user", scope_id="other-user")
r = server_client.delete("/v1/api/memories/k?scope=user&scope_id=other-user")
assert r.status_code == 403
class TestServerSearchMemories:
def test_search(self, server_client, storage):
_seed_memory(storage, "db_config", "postgresql host", description="database")
_seed_memory(storage, "api_key", "secret_value")
r = server_client.post(
"/v1/api/memories/search",
json={"query": "database"},
)
assert r.status_code == 200
assert r.json()["total"] == 1
assert r.json()["memories"][0]["name"] == "db_config"
def test_no_results(self, server_client, storage):
_seed_memory(storage, "a", "b")
r = server_client.post(
"/v1/api/memories/search",
json={"query": "nonexistent_xyz"},
)
assert r.status_code == 200
assert r.json()["total"] == 0
def test_missing_query(self, server_client):
r = server_client.post("/v1/api/memories/search", json={})
assert r.status_code == 400
class TestServerDeleteMemory:
def test_delete(self, server_client, storage):
_seed_memory(storage, "doomed")
r = server_client.delete("/v1/api/memories/doomed")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_not_found(self, server_client):
r = server_client.delete("/v1/api/memories/nope")
assert r.status_code == 404
def test_delete_scoped(self, server_client, storage):
_seed_memory(storage, "k", "data", scope="workstream", scope_id="ws1")
# Wrong scope → not found
r = server_client.delete("/v1/api/memories/k")
assert r.status_code == 404
# Correct scope → success
r = server_client.delete("/v1/api/memories/k?scope=workstream&scope_id=ws1")
assert r.status_code == 200
def test_invalid_scope(self, server_client):
r = server_client.delete("/v1/api/memories/k?scope=bogus")
assert r.status_code == 400
# ===========================================================================
# Console admin endpoint tests
# ===========================================================================
class TestAdminListMemories:
def test_empty(self, admin_client):
r = admin_client.get("/v1/api/admin/memories")
assert r.status_code == 200
assert r.json()["memories"] == []
def test_with_data(self, admin_client, storage):
_seed_memory(storage, "a", "1")
_seed_memory(storage, "b", "2")
r = admin_client.get("/v1/api/admin/memories")
assert r.json()["total"] == 2
def test_filter(self, admin_client, storage):
_seed_memory(storage, "a", "1", mem_type="user")
_seed_memory(storage, "b", "2", mem_type="project")
r = admin_client.get("/v1/api/admin/memories?type=user")
assert r.json()["total"] == 1
class TestAdminSearchMemories:
def test_search(self, admin_client, storage):
_seed_memory(storage, "db_config", "pg host", description="database")
_seed_memory(storage, "other", "unrelated")
r = admin_client.get("/v1/api/admin/memories/search?q=database")
assert r.status_code == 200
assert r.json()["total"] == 1
def test_missing_query(self, admin_client):
r = admin_client.get("/v1/api/admin/memories/search")
assert r.status_code == 400
class TestAdminGetMemory:
def test_found(self, admin_client, storage):
mid = _seed_memory(storage, "k", "content")
r = admin_client.get(f"/v1/api/admin/memories/{mid}")
assert r.status_code == 200
assert r.json()["name"] == "k"
def test_not_found(self, admin_client):
r = admin_client.get("/v1/api/admin/memories/nonexistent-id")
assert r.status_code == 404
class TestAdminDeleteMemory:
def test_delete(self, admin_client, storage):
mid = _seed_memory(storage, "doomed", "data")
r = admin_client.delete(f"/v1/api/admin/memories/{mid}")
assert r.status_code == 200
assert r.json()["status"] == "ok"
# Verify it's gone
assert storage.get_structured_memory(mid) is None
def test_not_found(self, admin_client):
r = admin_client.delete("/v1/api/admin/memories/nonexistent-id")
assert r.status_code == 404
# ===========================================================================
# Storage: delete_structured_memory_by_id
# ===========================================================================
class TestDeleteByIdStorage:
def test_delete_existing(self, storage):
storage.create_structured_memory("m1", "k", "d", "project", "global", "", "data")
assert storage.delete_structured_memory_by_id("m1")
assert storage.get_structured_memory("m1") is None
def test_delete_nonexistent(self, storage):
assert not storage.delete_structured_memory_by_id("nope")
+194
View File
@@ -0,0 +1,194 @@
"""Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction."""
from turnstone.core.memory_relevance import (
build_memory_context,
extract_recent_context,
score_memories,
)
# ---------------------------------------------------------------------------
# score_memories
# ---------------------------------------------------------------------------
class TestScoreMemories:
def test_empty_memories(self):
assert score_memories([], "query") == []
def test_empty_query_returns_recent(self):
mems = [
{"name": "a", "description": "", "content": "alpha"},
{"name": "b", "description": "", "content": "beta"},
{"name": "c", "description": "", "content": "gamma"},
]
result = score_memories(mems, "", k=2)
assert len(result) == 2
assert result[0]["name"] == "a"
def test_whitespace_query_returns_recent(self):
mems = [{"name": "a", "description": "", "content": "alpha"}]
assert score_memories(mems, " ", k=5) == mems
def test_relevance_ranking(self):
mems = [
{"name": "cooking", "description": "recipes", "content": "pasta sauce tomato"},
{"name": "python", "description": "programming", "content": "python file io disk"},
{
"name": "disk_io",
"description": "file operations",
"content": "read write file disk",
},
]
result = score_memories(mems, "file disk", k=2)
names = [m["name"] for m in result]
assert "disk_io" in names
assert "python" in names
def test_k_limits_results(self):
mems = [{"name": f"m{i}", "description": "", "content": f"word{i}"} for i in range(10)]
result = score_memories(mems, "word0 word1 word2", k=2)
assert len(result) <= 2
def test_no_match_returns_empty(self):
mems = [{"name": "a", "description": "", "content": "hello world"}]
result = score_memories(mems, "zzzznotfound")
assert result == []
def test_uses_name_for_scoring(self):
mems = [
{"name": "database_config", "description": "", "content": "host=localhost"},
{"name": "unrelated", "description": "", "content": "nothing here"},
]
result = score_memories(mems, "database", k=1)
assert len(result) == 1
assert result[0]["name"] == "database_config"
def test_uses_description_for_scoring(self):
mems = [
{"name": "x", "description": "postgresql connection settings", "content": "host=db"},
{"name": "y", "description": "unrelated", "content": "nothing"},
]
result = score_memories(mems, "postgresql", k=1)
assert result[0]["name"] == "x"
# ---------------------------------------------------------------------------
# build_memory_context
# ---------------------------------------------------------------------------
class TestBuildMemoryContext:
def test_empty_memories(self):
assert build_memory_context([]) == ""
def test_single_memory(self):
mems = [{"name": "test", "type": "project", "scope": "global", "content": "hello"}]
ctx = build_memory_context(mems)
assert "<memories>" in ctx
assert "</memories>" in ctx
assert 'name="test"' in ctx
assert "hello" in ctx
def test_html_escaping(self):
mems = [
{
"name": "a<b",
"type": "project",
"scope": "global",
"content": "x & y",
"description": 'say "hi"',
}
]
ctx = build_memory_context(mems)
assert "&lt;" in ctx
assert "&amp;" in ctx
assert "&quot;" in ctx
def test_truncates_long_content(self):
mems = [
{
"name": "long",
"type": "project",
"scope": "global",
"content": "x" * 600,
}
]
ctx = build_memory_context(mems)
assert "..." in ctx
# Content should be truncated to 500 chars + "..."
assert "x" * 501 not in ctx
def test_description_attribute(self):
mems = [
{
"name": "test",
"type": "project",
"scope": "global",
"content": "data",
"description": "some desc",
}
]
ctx = build_memory_context(mems)
assert 'description="some desc"' in ctx
def test_no_description_attribute_when_empty(self):
mems = [{"name": "test", "type": "project", "scope": "global", "content": "data"}]
ctx = build_memory_context(mems)
assert "description=" not in ctx
# ---------------------------------------------------------------------------
# extract_recent_context
# ---------------------------------------------------------------------------
class TestExtractRecentContext:
def test_extracts_user_messages(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "world"},
]
ctx = extract_recent_context(msgs, max_messages=2)
assert "world" in ctx
assert "hello" in ctx
def test_skips_non_user(self):
msgs = [
{"role": "assistant", "content": "ignored"},
{"role": "user", "content": "included"},
]
ctx = extract_recent_context(msgs, max_messages=5)
assert "included" in ctx
assert "ignored" not in ctx
def test_respects_max_messages(self):
msgs = [
{"role": "user", "content": "first"},
{"role": "user", "content": "second"},
{"role": "user", "content": "third"},
]
ctx = extract_recent_context(msgs, max_messages=1)
assert "third" in ctx
assert "first" not in ctx
def test_handles_list_content(self):
msgs = [
{
"role": "user",
"content": [
{"type": "text", "text": "multi-part"},
{"type": "image_url", "image_url": {"url": "http://example.com"}},
],
}
]
ctx = extract_recent_context(msgs, max_messages=1)
assert "multi-part" in ctx
def test_handles_string_parts_in_list(self):
msgs = [{"role": "user", "content": ["plain string part"]}]
ctx = extract_recent_context(msgs, max_messages=1)
assert "plain string part" in ctx
def test_empty_messages(self):
assert extract_recent_context([]) == ""
+160
View File
@@ -0,0 +1,160 @@
"""Tests for turnstone.core.metacognition — detection, nudging, formatting."""
from turnstone.core.metacognition import (
NUDGE_COMPLETION,
NUDGE_CORRECTION,
NUDGE_DENIAL,
NUDGE_RESUME,
NUDGE_START,
detect_completion,
detect_correction,
format_nudge,
should_nudge,
)
class TestDetectCorrection:
def test_no_comma(self):
assert detect_correction("no, that's wrong") is True
def test_no_period(self):
assert detect_correction("no. do it differently") is True
def test_no_space(self):
assert detect_correction("no I meant the other one") is True
def test_dont(self):
assert detect_correction("don't use tabs") is True
def test_stop(self):
assert detect_correction("stop adding comments") is True
def test_actually(self):
assert detect_correction("actually, use pytest instead") is True
def test_instead(self):
assert detect_correction("instead, try this approach") is True
def test_wrong(self):
assert detect_correction("wrong, the port is 8080") is True
def test_i_said(self):
assert detect_correction("I said use snake_case") is True
def test_i_meant(self):
assert detect_correction("I meant the other file") is True
def test_please_dont(self):
assert detect_correction("please don't mock the database") is True
def test_negative_notice(self):
assert detect_correction("I noticed the test passes") is False
def test_negative_nobody(self):
assert detect_correction("nobody knows the answer") is False
def test_negative_innovation(self):
assert detect_correction("innovation in AI is exciting") is False
def test_negative_normal(self):
assert detect_correction("can you refactor this function?") is False
def test_negative_empty(self):
assert detect_correction("") is False
def test_negative_note(self):
assert detect_correction("note that this requires Python 3.11") is False
def test_negative_nonstop(self):
assert detect_correction("nonstop improvements to the codebase") is False
class TestDetectCompletion:
def test_thanks(self):
assert detect_completion("thanks, that's perfect") is True
def test_thats_all(self):
assert detect_completion("that's all for now") is True
def test_looks_good(self):
assert detect_completion("looks good to me") is True
def test_perfect(self):
assert detect_completion("perfect") is True
def test_lgtm(self):
assert detect_completion("lgtm") is True
def test_done(self):
assert detect_completion("done") is True
def test_negative_normal(self):
assert detect_completion("can you add error handling?") is False
def test_negative_empty(self):
assert detect_completion("") is False
class TestShouldNudge:
def test_basic_fires(self):
state: dict[str, float] = {}
assert should_nudge("correction", state, message_count=3, memory_count=0) is True
def test_cooldown(self):
state: dict[str, float] = {}
should_nudge("correction", state, message_count=3, memory_count=0)
assert should_nudge("correction", state, message_count=3, memory_count=0) is False
def test_different_types_independent(self):
state: dict[str, float] = {}
should_nudge("correction", state, message_count=3, memory_count=0)
assert should_nudge("denial", state, message_count=3, memory_count=0) is True
def test_no_nudge_first_message(self):
state: dict[str, float] = {}
assert should_nudge("correction", state, message_count=1, memory_count=0) is False
def test_resume_requires_memories(self):
state: dict[str, float] = {}
assert should_nudge("resume", state, message_count=5, memory_count=0) is False
assert should_nudge("resume", state, message_count=5, memory_count=3) is True
def test_resume_allowed_on_first_message(self):
state: dict[str, float] = {}
assert should_nudge("resume", state, message_count=1, memory_count=3) is True
def test_start_fires_on_first_message_with_memories(self):
state: dict[str, float] = {}
assert should_nudge("start", state, message_count=1, memory_count=3) is True
def test_start_requires_memories(self):
state: dict[str, float] = {}
assert should_nudge("start", state, message_count=1, memory_count=0) is False
def test_start_only_on_first_message(self):
state: dict[str, float] = {}
assert should_nudge("start", state, message_count=2, memory_count=3) is False
def test_invalid_type(self):
state: dict[str, float] = {}
assert should_nudge("invalid", state, message_count=3, memory_count=0) is False
class TestFormatNudge:
def test_correction(self):
assert format_nudge("correction") == NUDGE_CORRECTION
def test_denial(self):
assert format_nudge("denial") == NUDGE_DENIAL
def test_resume(self):
assert format_nudge("resume") == NUDGE_RESUME
def test_completion(self):
assert format_nudge("completion") == NUDGE_COMPLETION
def test_start(self):
assert format_nudge("start") == NUDGE_START
def test_invalid(self):
assert format_nudge("invalid") == ""
+1 -1
View File
@@ -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(
+291
View File
@@ -0,0 +1,291 @@
"""Tests for system settings admin API endpoints."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import (
admin_delete_setting,
admin_list_settings,
admin_settings_schema,
admin_update_setting,
)
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
# ---------------------------------------------------------------------------
# Auth bypass middleware
# ---------------------------------------------------------------------------
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset(
{
"read",
"write",
"approve",
"admin.settings",
}
),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
@pytest.fixture
def client(storage):
"""TestClient wired to console admin settings endpoints."""
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route("/api/admin/settings", admin_list_settings),
Route("/api/admin/settings/schema", admin_settings_schema),
Route(
"/api/admin/settings/{key:path}",
admin_update_setting,
methods=["PUT"],
),
Route(
"/api/admin/settings/{key:path}",
admin_delete_setting,
methods=["DELETE"],
),
],
),
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
return TestClient(app)
# ---------------------------------------------------------------------------
# List settings
# ---------------------------------------------------------------------------
class TestListSettings:
def test_returns_all_registry_entries(self, client):
from turnstone.core.settings_registry import SETTINGS
r = client.get("/v1/api/admin/settings")
assert r.status_code == 200
data = r.json()
assert len(data["settings"]) == len(SETTINGS)
# Every entry has source "default" when nothing stored
for entry in data["settings"]:
assert entry["source"] == "default"
def test_stored_value_shows_source_storage(self, client, storage):
from turnstone.core.settings_registry import serialize_value
storage.upsert_system_setting(
key="tools.timeout",
value=serialize_value(60),
node_id="",
is_secret=False,
changed_by="admin",
)
r = client.get("/v1/api/admin/settings")
assert r.status_code == 200
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["tools.timeout"]["source"] == "storage"
assert by_key["tools.timeout"]["value"] == 60
# ---------------------------------------------------------------------------
# Update setting
# ---------------------------------------------------------------------------
class TestUpdateSetting:
def test_update_valid(self, client):
r = client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 30},
)
assert r.status_code == 200
data = r.json()
assert data["key"] == "tools.timeout"
assert data["value"] == 30
assert data["source"] == "storage"
def test_update_invalid_key(self, client):
r = client.put(
"/v1/api/admin/settings/bogus.nonexistent",
json={"value": "x"},
)
assert r.status_code == 400
assert "Unknown setting" in r.json()["error"]
def test_update_invalid_value_out_of_range(self, client):
r = client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 0},
)
assert r.status_code == 400
assert "minimum" in r.json()["error"]
def test_update_then_list_shows_storage(self, client):
client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 42},
)
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["tools.timeout"]["source"] == "storage"
assert by_key["tools.timeout"]["value"] == 42
# ---------------------------------------------------------------------------
# Delete setting
# ---------------------------------------------------------------------------
class TestDeleteSetting:
def test_delete_stored(self, client):
# First store a value
client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 30},
)
# Delete it
r = client.delete("/v1/api/admin/settings/tools.timeout")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_delete_then_list_shows_default(self, client):
client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 30},
)
client.delete("/v1/api/admin/settings/tools.timeout")
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["tools.timeout"]["source"] == "default"
def test_delete_non_existent(self, client):
r = client.delete("/v1/api/admin/settings/tools.timeout")
assert r.status_code == 404
assert "not found" in r.json()["error"]
# ---------------------------------------------------------------------------
# Schema endpoint
# ---------------------------------------------------------------------------
class TestSettingsSchema:
def test_returns_registry(self, client):
from turnstone.core.settings_registry import SETTINGS
r = client.get("/v1/api/admin/settings/schema")
assert r.status_code == 200
data = r.json()
assert len(data["schema"]) == len(SETTINGS)
# Spot-check a few fields
by_key = {s["key"]: s for s in data["schema"]}
timeout = by_key["tools.timeout"]
assert timeout["type"] == "int"
assert timeout["default"] == 120
assert timeout["min_value"] == 1
assert timeout["max_value"] == 3600
assert timeout["description"]
def test_choices_present(self, client):
r = client.get("/v1/api/admin/settings/schema")
by_key = {s["key"]: s for s in r.json()["schema"]}
assert by_key["tools.search"]["choices"] == ["auto", "on", "off"]
def test_secret_flag(self, client):
r = client.get("/v1/api/admin/settings/schema")
by_key = {s["key"]: s for s in r.json()["schema"]}
assert by_key["judge.api_key"]["is_secret"] is True
assert by_key["tools.timeout"]["is_secret"] is False
# ---------------------------------------------------------------------------
# Secret masking
# ---------------------------------------------------------------------------
class TestSecretMasking:
def test_secret_masked_in_list(self, client, storage):
from turnstone.core.settings_registry import serialize_value
storage.upsert_system_setting(
key="judge.api_key",
value=serialize_value("sk-real-secret"),
node_id="",
is_secret=True,
changed_by="admin",
)
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["judge.api_key"]["value"] == "***"
def test_secret_write_blocked(self, client):
"""Secret settings cannot be modified via API."""
r = client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "sk-secret-123"},
)
assert r.status_code == 403
assert "config.toml" in r.json()["error"]
def test_secret_shows_managed_label(self, client):
"""Secret settings show a label instead of a value."""
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert "managed via" in by_key["judge.api_key"]["value"]
# ---------------------------------------------------------------------------
# Audit trail (verify endpoint returns 200, confirming record_audit call)
# ---------------------------------------------------------------------------
class TestAuditTrail:
def test_update_returns_200(self, client):
"""Update succeeds — audit recording did not raise."""
r = client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 45},
)
assert r.status_code == 200
def test_delete_returns_200(self, client):
"""Delete succeeds — audit recording did not raise."""
client.put(
"/v1/api/admin/settings/tools.timeout",
json={"value": 45},
)
r = client.delete("/v1/api/admin/settings/tools.timeout")
assert r.status_code == 200
+171
View File
@@ -0,0 +1,171 @@
"""Tests for settings registry validation."""
from __future__ import annotations
import pytest
from turnstone.core.settings_registry import (
BOOTSTRAP_SECTIONS,
SETTINGS,
deserialize_value,
serialize_value,
validate_key,
validate_value,
)
# ---------------------------------------------------------------------------
# validate_key
# ---------------------------------------------------------------------------
class TestValidateKey:
def test_known_key(self):
defn = validate_key("memory.relevance_k")
assert defn.key == "memory.relevance_k"
assert defn.type == "int"
def test_unknown_key(self):
with pytest.raises(ValueError, match="Unknown setting"):
validate_key("nonexistent.key")
# ---------------------------------------------------------------------------
# validate_value — type coercion
# ---------------------------------------------------------------------------
class TestValidateValueCoercion:
def test_int(self):
assert validate_value("tools.timeout", "60") == 60
assert validate_value("tools.timeout", 60) == 60
assert isinstance(validate_value("tools.timeout", "60"), int)
def test_float(self):
assert validate_value("model.temperature", "0.7") == 0.7
assert validate_value("model.temperature", 1.5) == 1.5
assert isinstance(validate_value("model.temperature", "0.7"), float)
def test_bool_native(self):
assert validate_value("tools.skip_permissions", True) is True
assert validate_value("tools.skip_permissions", False) is False
def test_bool_string_true(self):
for s in ("true", "True", "1", "yes"):
assert validate_value("tools.skip_permissions", s) is True
def test_bool_string_false(self):
for s in ("false", "False", "0", "no"):
assert validate_value("tools.skip_permissions", s) is False
def test_bool_garbage_string(self):
with pytest.raises(ValueError, match="Cannot convert"):
validate_value("tools.skip_permissions", "banana")
def test_none_rejected_for_numeric(self):
"""None is not a valid value for numeric settings."""
with pytest.raises((ValueError, TypeError)):
validate_value("model.temperature", None)
with pytest.raises((ValueError, TypeError)):
validate_value("tools.timeout", None)
def test_str(self):
assert validate_value("model.name", "gpt-5") == "gpt-5"
assert validate_value("session.instructions", "be nice") == "be nice"
# ---------------------------------------------------------------------------
# validate_value — range constraints
# ---------------------------------------------------------------------------
class TestValidateValueRange:
def test_min_value(self):
with pytest.raises(ValueError, match="minimum"):
validate_value("tools.timeout", 0) # min_value=1
def test_max_value(self):
with pytest.raises(ValueError, match="maximum"):
validate_value("tools.timeout", 9999) # max_value=3600
def test_min_value_float(self):
with pytest.raises(ValueError, match="minimum"):
validate_value("model.temperature", -0.1) # min_value=0.0
def test_max_value_float(self):
with pytest.raises(ValueError, match="maximum"):
validate_value("model.temperature", 2.1) # max_value=2.0
def test_boundary_ok(self):
# Exact boundary values should pass
assert validate_value("tools.timeout", 1) == 1
assert validate_value("tools.timeout", 3600) == 3600
assert validate_value("model.temperature", 0.0) == 0.0
assert validate_value("model.temperature", 2.0) == 2.0
# ---------------------------------------------------------------------------
# validate_value — choices
# ---------------------------------------------------------------------------
class TestValidateValueChoices:
def test_valid_choice(self):
assert validate_value("tools.search", "auto") == "auto"
assert validate_value("tools.search", "on") == "on"
assert validate_value("tools.search", "off") == "off"
def test_invalid_choice(self):
with pytest.raises(ValueError, match="not in"):
validate_value("tools.search", "maybe")
def test_reasoning_effort_choices(self):
for ch in ("", "none", "low", "medium", "high", "max"):
assert validate_value("model.reasoning_effort", ch) == ch
# ---------------------------------------------------------------------------
# serialize / deserialize round-trip
# ---------------------------------------------------------------------------
class TestSerializeDeserialize:
def test_int_round_trip(self):
v = 42
assert deserialize_value("tools.timeout", serialize_value(v)) == v
def test_float_round_trip(self):
v = 0.75
assert deserialize_value("model.temperature", serialize_value(v)) == v
def test_bool_round_trip(self):
for v in (True, False):
assert deserialize_value("tools.skip_permissions", serialize_value(v)) is v
def test_str_round_trip(self):
v = "hello world"
assert deserialize_value("model.name", serialize_value(v)) == v
def test_str_round_trip_empty(self):
assert deserialize_value("model.name", serialize_value("")) == ""
# ---------------------------------------------------------------------------
# Registry integrity
# ---------------------------------------------------------------------------
class TestRegistryIntegrity:
def test_all_keys_have_valid_types(self):
valid_types = {"int", "float", "str", "bool"}
for key, defn in SETTINGS.items():
assert defn.type in valid_types, f"{key} has invalid type {defn.type!r}"
def test_no_bootstrap_section_keys(self):
for key, defn in SETTINGS.items():
assert defn.section not in BOOTSTRAP_SECTIONS, (
f"{key} in bootstrap section {defn.section!r}"
)
def test_all_entries_have_descriptions(self):
for key, defn in SETTINGS.items():
assert defn.description, f"{key} has empty description"
-40
View File
@@ -215,46 +215,6 @@ class TestWorkstreamMetadata:
assert backend.get_workstream_display_name("s1") == "Alias"
# -- Key-value store -----------------------------------------------------------
class TestKVStore:
def test_set_and_get(self, backend):
assert backend.kv_set("key1", "value1") is None # no previous
assert backend.kv_get("key1") == "value1"
def test_set_returns_old_value(self, backend):
backend.kv_set("key1", "v1")
old = backend.kv_set("key1", "v2")
assert old == "v1"
assert backend.kv_get("key1") == "v2"
def test_delete(self, backend):
backend.kv_set("key1", "v1")
assert backend.kv_delete("key1")
assert backend.kv_get("key1") is None
def test_delete_nonexistent(self, backend):
assert not backend.kv_delete("nope")
def test_list(self, backend):
backend.kv_set("b", "2")
backend.kv_set("a", "1")
assert backend.kv_list() == [("a", "1"), ("b", "2")]
def test_search(self, backend):
backend.kv_set("project_name", "turnstone")
backend.kv_set("version", "0.3")
results = backend.kv_search("turnstone")
assert len(results) == 1
assert results[0] == ("project_name", "turnstone")
def test_search_empty_lists_all(self, backend):
backend.kv_set("a", "1")
backend.kv_set("b", "2")
assert len(backend.kv_search("")) == 2
# -- Conversation search -------------------------------------------------------
+82
View File
@@ -0,0 +1,82 @@
"""Tests for turnstone.core.memory — structured memory facade functions."""
from turnstone.core.memory import (
count_structured_memories,
delete_structured_memory,
list_structured_memories,
normalize_key,
save_structured_memory,
search_structured_memories,
)
class TestSaveStructuredMemory:
def test_save_new(self, tmp_db):
mid, old = save_structured_memory("test_key", "hello world")
assert mid != ""
assert old is None
def test_save_upsert(self, tmp_db):
save_structured_memory("test_key", "first")
mid, old = save_structured_memory("test_key", "second")
assert old == "first"
assert mid != ""
def test_save_normalizes_key(self, tmp_db):
save_structured_memory("My-Key", "value")
mems = list_structured_memories()
assert any(m["name"] == "my_key" for m in mems)
def test_save_with_type_and_scope(self, tmp_db):
save_structured_memory("k", "v", mem_type="user", scope="workstream", scope_id="ws1")
mems = list_structured_memories(scope="workstream", scope_id="ws1")
assert len(mems) == 1
assert mems[0]["type"] == "user"
class TestDeleteStructuredMemory:
def test_delete_existing(self, tmp_db):
save_structured_memory("mykey", "val")
assert delete_structured_memory("mykey")
def test_delete_nonexistent(self, tmp_db):
assert not delete_structured_memory("nope")
def test_delete_normalizes_key(self, tmp_db):
save_structured_memory("my_key", "val")
assert delete_structured_memory("My-Key")
class TestListStructuredMemories:
def test_list_empty(self, tmp_db):
assert list_structured_memories() == []
def test_list_returns_saved(self, tmp_db):
save_structured_memory("a", "alpha")
save_structured_memory("b", "beta")
mems = list_structured_memories()
assert len(mems) == 2
class TestSearchStructuredMemories:
def test_search_finds_match(self, tmp_db):
save_structured_memory("db_host", "localhost", description="database hostname")
save_structured_memory("api_url", "http://example.com")
results = search_structured_memories("database")
assert len(results) >= 1
assert any(r["name"] == "db_host" for r in results)
class TestCountStructuredMemories:
def test_count_zero(self, tmp_db):
assert count_structured_memories() == 0
def test_count_after_save(self, tmp_db):
save_structured_memory("a", "1")
save_structured_memory("b", "2")
assert count_structured_memories() == 2
class TestNormalizeKey:
def test_basic(self):
assert normalize_key("My-Key Name") == "my_key_name"
+137
View File
@@ -0,0 +1,137 @@
"""Tests for structured memory storage backend operations."""
import pytest
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def backend(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
class TestCreateAndGet:
def test_create_and_get_by_id(self, backend):
backend.create_structured_memory("m1", "test_key", "desc", "project", "global", "", "data")
mem = backend.get_structured_memory("m1")
assert mem is not None
assert mem["name"] == "test_key"
assert mem["content"] == "data"
assert mem["type"] == "project"
def test_get_nonexistent(self, backend):
assert backend.get_structured_memory("nope") is None
def test_get_by_name(self, backend):
backend.create_structured_memory("m1", "mykey", "d", "project", "global", "", "val")
mem = backend.get_structured_memory_by_name("mykey", "global", "")
assert mem is not None
assert mem["memory_id"] == "m1"
def test_get_by_name_scoped(self, backend):
backend.create_structured_memory("m1", "key", "d", "project", "global", "", "g")
backend.create_structured_memory("m2", "key", "d", "project", "workstream", "ws1", "w")
g = backend.get_structured_memory_by_name("key", "global", "")
w = backend.get_structured_memory_by_name("key", "workstream", "ws1")
assert g["content"] == "g"
assert w["content"] == "w"
class TestUpdate:
def test_update_content(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "global", "", "old")
assert backend.update_structured_memory("m1", content="new")
mem = backend.get_structured_memory("m1")
assert mem["content"] == "new"
def test_update_nonexistent(self, backend):
assert not backend.update_structured_memory("nope", content="x")
def test_update_no_fields(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "global", "", "data")
assert not backend.update_structured_memory("m1", bogus="val")
def test_update_bumps_timestamp(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "global", "", "data")
old = backend.get_structured_memory("m1")["updated"]
import time
time.sleep(0.01)
backend.update_structured_memory("m1", content="new")
new = backend.get_structured_memory("m1")["updated"]
assert new >= old
class TestDelete:
def test_delete_existing(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "global", "", "data")
assert backend.delete_structured_memory("k", "global", "")
assert backend.get_structured_memory("m1") is None
def test_delete_nonexistent(self, backend):
assert not backend.delete_structured_memory("nope", "global", "")
def test_delete_scoped(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "workstream", "ws1", "data")
assert not backend.delete_structured_memory("k", "global", "")
assert backend.delete_structured_memory("k", "workstream", "ws1")
class TestList:
def test_list_all(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2")
mems = backend.list_structured_memories()
assert len(mems) == 2
def test_list_by_type(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2")
mems = backend.list_structured_memories(mem_type="user")
assert len(mems) == 1
assert mems[0]["name"] == "b"
def test_list_by_scope(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "project", "workstream", "ws1", "2")
mems = backend.list_structured_memories(scope="workstream")
assert len(mems) == 1
def test_list_respects_limit(self, backend):
for i in range(10):
backend.create_structured_memory(f"m{i}", f"k{i}", "", "project", "global", "", f"{i}")
mems = backend.list_structured_memories(limit=3)
assert len(mems) == 3
class TestSearch:
def test_search_by_name(self, backend):
backend.create_structured_memory("m1", "database_config", "", "project", "global", "", "pg")
backend.create_structured_memory("m2", "api_key", "", "project", "global", "", "secret")
results = backend.search_structured_memories("database")
assert len(results) == 1
assert results[0]["name"] == "database_config"
def test_search_by_content(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "postgresql host")
results = backend.search_structured_memories("postgresql")
assert len(results) == 1
def test_search_empty_lists_all(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "project", "global", "", "2")
results = backend.search_structured_memories("")
assert len(results) == 2
class TestCount:
def test_count_all(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "project", "global", "", "2")
assert backend.count_structured_memories() == 2
def test_count_by_scope(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "project", "workstream", "ws1", "2")
assert backend.count_structured_memories(scope="global") == 1
assert backend.count_structured_memories(scope="workstream") == 1
+2 -3
View File
@@ -72,7 +72,7 @@ class TestToolsMetadata:
"""Validate the metadata extracted from JSON files."""
def test_tool_count(self):
assert len(TOOLS) == 18
assert len(TOOLS) == 17
def test_agent_tools_count(self):
assert len(AGENT_TOOLS) == 9
@@ -106,9 +106,8 @@ class TestToolsMetadata:
"web_search": "query",
"task": "prompt",
"create_plan": "goal",
"remember": "key",
"memory": "name",
"recall": "query",
"forget": "key",
"notify": "message",
"watch": "command",
"read_resource": "uri",
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.6.0"
__version__ = "0.6.1"
+69 -1
View File
@@ -501,4 +501,72 @@ class ListChannelUsersResponse(BaseModel):
class CreateChannelUserRequest(BaseModel):
channel_type: str = Field(..., description="Channel type (e.g. discord, slack)")
channel_user_id: str = Field(..., description="External channel user identifier")
total: int
# ---------------------------------------------------------------------------
# Admin: Memories
# ---------------------------------------------------------------------------
class AdminMemoryInfo(BaseModel):
memory_id: str
name: str
description: str = ""
type: str
scope: str
scope_id: str = ""
content: str
created: str
updated: str
last_accessed: str = ""
access_count: int = 0
class ListAdminMemoriesResponse(BaseModel):
memories: list[AdminMemoryInfo]
total: int = 0
# ---------------------------------------------------------------------------
# Admin: System Settings
# ---------------------------------------------------------------------------
class SettingInfo(BaseModel):
key: str
value: Any = None
source: str = "default" # "storage" | "default"
type: str = "str"
description: str = ""
section: str = ""
is_secret: bool = False
node_id: str = ""
changed_by: str = ""
updated: str = ""
restart_required: bool = False
class ListSettingsResponse(BaseModel):
settings: list[SettingInfo]
class SettingSchemaInfo(BaseModel):
key: str
type: str
default: Any = None
description: str = ""
section: str = ""
is_secret: bool = False
min_value: float | None = None
max_value: float | None = None
choices: list[str] | None = None
restart_required: bool = False
class ListSettingSchemaResponse(BaseModel):
settings_schema: list[SettingSchemaInfo] = Field(alias="schema")
class UpdateSettingRequest(BaseModel):
value: Any
node_id: str = ""
+96
View File
@@ -8,6 +8,7 @@ if TYPE_CHECKING:
from pydantic import BaseModel
from turnstone.api.console_schemas import (
AdminMemoryInfo,
AssignRoleRequest,
AuditEventInfo,
ChannelUserInfo,
@@ -23,11 +24,14 @@ from turnstone.api.console_schemas import (
CreateRoleRequest,
CreateToolPolicyRequest,
CreateWsTemplateRequest,
ListAdminMemoriesResponse,
ListAuditEventsResponse,
ListChannelUsersResponse,
ListOrgsResponse,
ListPromptTemplatesResponse,
ListRolesResponse,
ListSettingSchemaResponse,
ListSettingsResponse,
ListToolPoliciesResponse,
ListUserRolesResponse,
ListVerdictsResponse,
@@ -38,10 +42,13 @@ from turnstone.api.console_schemas import (
OrgInfo,
PromptTemplateInfo,
RoleInfo,
SettingInfo,
SettingSchemaInfo,
ToolPolicyInfo,
UpdateOrgRequest,
UpdatePromptTemplateRequest,
UpdateRoleRequest,
UpdateSettingRequest,
UpdateToolPolicyRequest,
UpdateWsTemplateRequest,
UsageBreakdownItem,
@@ -572,6 +579,88 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
],
tags=["Admin"],
),
# --- Admin: Memories ---
EndpointSpec(
"/v1/api/admin/memories",
"GET",
"List structured memories",
response_model=ListAdminMemoriesResponse,
query_params=[
QueryParam("type", "Filter by memory type"),
QueryParam("scope", "Filter by scope"),
QueryParam("scope_id", "Filter by scope identifier"),
QueryParam("limit", "Page size (max 200)", schema_type="integer", default=100),
],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/memories/search",
"GET",
"Search memories by query",
response_model=ListAdminMemoriesResponse,
query_params=[
QueryParam("q", "Search query", required=True),
QueryParam("type", "Filter by memory type"),
QueryParam("scope", "Filter by scope"),
QueryParam("scope_id", "Filter by scope identifier"),
QueryParam("limit", "Max results", schema_type="integer", default=20),
],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/memories/{memory_id}",
"GET",
"Get a single memory by ID",
response_model=AdminMemoryInfo,
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/memories/{memory_id}",
"DELETE",
"Delete a memory by ID",
response_model=StatusResponse,
error_codes=[404],
tags=["Admin"],
),
# --- Admin: System Settings ---
EndpointSpec(
"/v1/api/admin/settings",
"GET",
"List all settings with effective values",
response_model=ListSettingsResponse,
query_params=[
QueryParam("reveal", "Show secret values in plaintext", schema_type="boolean"),
],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/settings/schema",
"GET",
"Return the full settings registry schema",
response_model=ListSettingSchemaResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/settings/{key}",
"PUT",
"Set a configuration setting value",
request_model=UpdateSettingRequest,
response_model=SettingInfo,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/settings/{key}",
"DELETE",
"Reset a setting to its default value",
response_model=StatusResponse,
query_params=[
QueryParam("node_id", "Node ID for node-scoped settings"),
],
error_codes=[400, 404],
tags=["Admin"],
),
# --- Observability ---
EndpointSpec(
"/health",
@@ -636,6 +725,13 @@ _ALL_MODELS: list[type[BaseModel]] = [
ListAuditEventsResponse,
VerdictInfo,
ListVerdictsResponse,
AdminMemoryInfo,
ListAdminMemoriesResponse,
SettingInfo,
ListSettingsResponse,
SettingSchemaInfo,
ListSettingSchemaResponse,
UpdateSettingRequest,
]
+51
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
@@ -163,3 +165,52 @@ class HealthResponse(BaseModel):
workstreams: WorkstreamCounts = WorkstreamCounts()
backend: BackendStatus | None = None
mcp: McpStatus | None = None
# ---------------------------------------------------------------------------
# Memories
# ---------------------------------------------------------------------------
MemoryType = Literal["user", "project", "feedback", "reference"]
MemoryScope = Literal["global", "workstream", "user"]
class SaveMemoryRequest(BaseModel):
name: str = Field(description="Memory identifier (normalized to snake_case)")
content: str = Field(description="Memory content", max_length=65536)
description: str = Field(default="", description="Short description for relevance matching")
type: MemoryType = Field(default="project", description="Memory type")
scope: MemoryScope = Field(default="global", description="Memory scope")
scope_id: str = Field(
default="",
description="Scope identifier (ws_id for workstream, user_id for user scope)",
)
class MemoryInfo(BaseModel):
memory_id: str
name: str
description: str = ""
type: MemoryType
scope: MemoryScope
scope_id: str = ""
content: str
created: str
updated: str
class ListMemoriesResponse(BaseModel):
memories: list[MemoryInfo]
total: int = 0
MemoryTypeFilter = Literal["", "user", "project", "feedback", "reference"]
MemoryScopeFilter = Literal["", "global", "workstream", "user"]
class SearchMemoriesRequest(BaseModel):
query: str = Field(description="Search query text")
type: MemoryTypeFilter = Field(default="", description="Filter by memory type")
scope: MemoryScopeFilter = Field(default="", description="Filter by scope")
scope_id: str = Field(default="", description="Filter by scope_id")
limit: int = Field(default=20, description="Max results (1-50)", ge=1, le=50)
+53
View File
@@ -26,9 +26,13 @@ from turnstone.api.server_schemas import (
CreateWorkstreamResponse,
DashboardResponse,
HealthResponse,
ListMemoriesResponse,
ListSavedWorkstreamsResponse,
ListWorkstreamsResponse,
MemoryInfo,
PlanFeedbackRequest,
SaveMemoryRequest,
SearchMemoriesRequest,
SendRequest,
SendResponse,
)
@@ -173,6 +177,51 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
response_model=StatusResponse,
tags=["Auth"],
),
# --- Memories ---
EndpointSpec(
"/v1/api/memories",
"GET",
"List structured memories",
response_model=ListMemoriesResponse,
query_params=[
QueryParam("type", "Filter by memory type"),
QueryParam("scope", "Filter by scope"),
QueryParam("scope_id", "Filter by scope identifier"),
QueryParam(
"limit", "Max results (default 100, max 200)", schema_type="integer", default=100
),
],
tags=["Memories"],
),
EndpointSpec(
"/v1/api/memories",
"POST",
"Save (upsert) a structured memory",
request_model=SaveMemoryRequest,
response_model=MemoryInfo,
error_codes=[400],
tags=["Memories"],
),
EndpointSpec(
"/v1/api/memories/search",
"POST",
"Search structured memories by query",
request_model=SearchMemoriesRequest,
response_model=ListMemoriesResponse,
tags=["Memories"],
),
EndpointSpec(
"/v1/api/memories/{name}",
"DELETE",
"Delete a structured memory by name and scope",
response_model=StatusResponse,
query_params=[
QueryParam("scope", "Scope (default: global)"),
QueryParam("scope_id", "Scope identifier"),
],
error_codes=[404],
tags=["Memories"],
),
# --- Observability ---
EndpointSpec(
"/health",
@@ -204,6 +253,10 @@ _ALL_MODELS: list[type[BaseModel]] = [
DashboardResponse,
ListSavedWorkstreamsResponse,
HealthResponse,
SaveMemoryRequest,
MemoryInfo,
ListMemoriesResponse,
SearchMemoriesRequest,
]
+9
View File
@@ -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
+1 -1
View File
@@ -7,7 +7,7 @@ All functionality has been moved to submodules:
- turnstone.core.sandbox: validate_math_code, execute_math_sandboxed
- turnstone.core.safety: is_command_blocked, sanitize_command
- turnstone.core.web: strip_html, check_ssrf
- turnstone.core.memory: open_db, load_memories, save_message, etc.
- turnstone.core.memory: save_message, structured memory facade, etc.
- turnstone.ui.colors: ANSI constants and helpers
- turnstone.ui.markdown: MarkdownRenderer
- turnstone.ui.spinner: Spinner
+6 -4
View File
@@ -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
+383
View File
@@ -1511,6 +1511,8 @@ _VALID_PERMISSIONS = frozenset(
"admin.watches",
"admin.ws_templates",
"admin.judge",
"admin.memories",
"admin.settings",
"tools.approve",
"workstreams.create",
"workstreams.close",
@@ -2607,6 +2609,365 @@ async def admin_list_verdicts(request: Request) -> JSONResponse:
return JSONResponse({"verdicts": verdicts, "total": total})
# ---------------------------------------------------------------------------
# Admin: Memories
# ---------------------------------------------------------------------------
async def admin_list_memories(request: Request) -> JSONResponse:
"""GET /v1/api/admin/memories — list structured memories with filters."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.memories")
if err:
return err
mem_type = request.query_params.get("type", "")
scope = request.query_params.get("scope", "")
scope_id = request.query_params.get("scope_id", "")
try:
limit = min(int(request.query_params.get("limit", "100")), 200)
except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
rows = storage.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
total = storage.count_structured_memories(mem_type=mem_type, scope=scope, scope_id=scope_id)
return JSONResponse({"memories": rows, "total": total})
async def admin_search_memories(request: Request) -> JSONResponse:
"""GET /v1/api/admin/memories/search — search memories by query."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.memories")
if err:
return err
query = request.query_params.get("q", "").strip()
if not query:
return JSONResponse({"error": "q is required"}, status_code=400)
mem_type = request.query_params.get("type", "")
scope = request.query_params.get("scope", "")
scope_id = request.query_params.get("scope_id", "")
try:
limit = min(int(request.query_params.get("limit", "20")), 50)
except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
rows = storage.search_structured_memories(
query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
return JSONResponse({"memories": rows, "total": len(rows)})
async def admin_get_memory(request: Request) -> JSONResponse:
"""GET /v1/api/admin/memories/{memory_id} — get a single memory."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.memories")
if err:
return err
memory_id = request.path_params["memory_id"]
mem = storage.get_structured_memory(memory_id)
if not mem:
return JSONResponse({"error": "Memory not found"}, status_code=404)
return JSONResponse(mem)
async def admin_delete_memory(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/memories/{memory_id} — delete a memory by ID."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.memories")
if err:
return err
memory_id = request.path_params["memory_id"]
existing = storage.get_structured_memory(memory_id)
if not existing:
return JSONResponse({"error": "Memory not found"}, status_code=404)
storage.delete_structured_memory_by_id(memory_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"memory.delete",
"memory",
memory_id,
{"name": existing.get("name", ""), "scope": existing.get("scope", "")},
ip,
)
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Admin: System Settings
# ---------------------------------------------------------------------------
def _publish_config_change(request: Request, *, key: str, node_id: str, action: str) -> None:
"""Fan out config-reload to all known server nodes (best-effort).
Uses the collector's node registry and the existing proxy auth
mechanism no MQ dependency.
"""
import contextlib
import httpx
collector = getattr(request.app.state, "collector", None)
if not collector:
return
headers = _proxy_auth_headers(request)
with contextlib.suppress(Exception):
nodes = collector.get_nodes()
for node in nodes.get("nodes", []):
url = node.get("url", "")
if url:
with contextlib.suppress(Exception):
httpx.post(
f"{url}/v1/api/_internal/config-reload",
headers=headers,
timeout=5.0,
)
async def admin_list_settings(request: Request) -> JSONResponse:
"""GET /v1/api/admin/settings — list all settings with effective values."""
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import SETTINGS, deserialize_value
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.settings")
if err:
return err
reveal = request.query_params.get("reveal") == "true"
stored = {r["key"]: r for r in storage.list_system_settings() if r.get("node_id", "") == ""}
settings: list[dict[str, Any]] = []
for key, defn in sorted(SETTINGS.items()):
row = stored.get(key)
if row:
try:
val = deserialize_value(key, row["value"])
except (ValueError, KeyError):
val = row["value"]
info = {
"key": key,
"value": "***" if defn.is_secret and not reveal else val,
"source": "storage",
"type": defn.type,
"description": defn.description,
"section": defn.section,
"is_secret": defn.is_secret,
"node_id": row.get("node_id", ""),
"changed_by": row.get("changed_by", ""),
"updated": row.get("updated", ""),
"restart_required": defn.restart_required,
}
else:
info = {
"key": key,
"value": "(managed via config file / env)" if defn.is_secret else defn.default,
"source": "default",
"type": defn.type,
"description": defn.description,
"section": defn.section,
"is_secret": defn.is_secret,
"node_id": "",
"changed_by": "",
"updated": "",
"restart_required": defn.restart_required,
}
settings.append(info)
return JSONResponse({"settings": settings})
async def admin_settings_schema(request: Request) -> JSONResponse:
"""GET /v1/api/admin/settings/schema — return the full settings registry."""
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import SETTINGS
err = require_permission(request, "admin.settings")
if err:
return err
schema: list[dict[str, Any]] = []
for key, defn in sorted(SETTINGS.items()):
schema.append(
{
"key": key,
"type": defn.type,
"default": defn.default,
"description": defn.description,
"section": defn.section,
"is_secret": defn.is_secret,
"min_value": defn.min_value,
"max_value": defn.max_value,
"choices": defn.choices,
"restart_required": defn.restart_required,
"help": defn.help,
"reference_url": defn.reference_url,
}
)
return JSONResponse({"schema": schema})
async def admin_update_setting(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/settings/{key} — set a setting value."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import (
serialize_value,
validate_key,
validate_value,
)
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.settings")
if err:
return err
key = request.path_params["key"]
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
try:
defn = validate_key(key)
except ValueError:
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=400)
if defn.is_secret:
return JSONResponse(
{
"error": "Secret settings cannot be modified via API — use config.toml or environment variables"
},
status_code=403,
)
if "value" not in body:
return JSONResponse({"error": "value is required"}, status_code=400)
raw_value = body.get("value")
try:
typed_value = validate_value(key, raw_value)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
node_id = str(body.get("node_id", ""))
audit_uid, ip = _audit_context(request)
storage.upsert_system_setting(
key=key,
value=serialize_value(typed_value),
node_id=node_id,
is_secret=defn.is_secret,
changed_by=audit_uid,
)
record_audit(
storage,
audit_uid,
"setting.update",
"setting",
key,
{"value": "***" if defn.is_secret else typed_value, "node_id": node_id},
ip,
)
_publish_config_change(request, key=key, node_id=node_id, action="set")
return JSONResponse(
{
"key": key,
"value": "***" if defn.is_secret else typed_value,
"source": "storage",
"type": defn.type,
"description": defn.description,
"section": defn.section,
"is_secret": defn.is_secret,
"node_id": node_id,
"changed_by": audit_uid,
"updated": "",
"restart_required": defn.restart_required,
}
)
async def admin_delete_setting(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/settings/{key} — reset a setting to default."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import validate_key
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.settings")
if err:
return err
key = request.path_params["key"]
try:
validate_key(key)
except ValueError:
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=400)
node_id = request.query_params.get("node_id", "")
deleted = storage.delete_system_setting(key, node_id=node_id)
if not deleted:
return JSONResponse({"error": f"Setting '{key}' not found in storage"}, status_code=404)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"setting.delete",
"setting",
key,
{"node_id": node_id},
ip,
)
_publish_config_change(request, key=key, node_id=node_id, action="delete")
return JSONResponse({"status": "ok", "key": key})
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
@@ -2748,6 +3109,28 @@ def create_app(
"/api/admin/ws-templates/{ws_template_id}/versions",
admin_list_ws_template_versions,
),
# Governance: Memories
Route("/api/admin/memories", admin_list_memories),
Route("/api/admin/memories/search", admin_search_memories),
Route("/api/admin/memories/{memory_id}", admin_get_memory),
Route(
"/api/admin/memories/{memory_id}",
admin_delete_memory,
methods=["DELETE"],
),
# System: Settings
Route("/api/admin/settings", admin_list_settings),
Route("/api/admin/settings/schema", admin_settings_schema),
Route(
"/api/admin/settings/{key:path}",
admin_update_setting,
methods=["PUT"],
),
Route(
"/api/admin/settings/{key:path}",
admin_delete_setting,
methods=["DELETE"],
),
# Governance: Usage & Audit
Route("/api/admin/usage", admin_usage),
Route("/api/admin/audit", admin_audit),
+739 -37
View File
@@ -13,13 +13,25 @@ var _cfTrapHandler = null;
var _adminWatches = [];
var _confirmCallbackFn = null;
var _confirmTriggerEl = null;
var _mobileSidebarOpen = false;
// ---------------------------------------------------------------------------
// View switching (called from app.js showOverview/drillDown pattern)
// ---------------------------------------------------------------------------
function showAdmin() {
/* global currentView */
/* global currentView, showOverview */
// Toggle: if already in admin view, go back to overview
if (currentView === "admin") {
var adminBtn = document.getElementById("admin-btn");
if (adminBtn) {
adminBtn.classList.remove("active");
adminBtn.setAttribute("aria-expanded", "false");
}
showOverview();
return;
}
currentView = "admin";
document.getElementById("view-overview").style.display = "none";
document.getElementById("view-node").style.display = "none";
@@ -28,9 +40,16 @@ function showAdmin() {
document.getElementById("breadcrumb").style.display = "";
document.getElementById("breadcrumb-label").textContent = "Admin";
document.getElementById("main").scrollTop = 0;
// Highlight admin button as active
var adminBtn = document.getElementById("admin-btn");
if (adminBtn) {
adminBtn.classList.add("active");
adminBtn.setAttribute("aria-expanded", "true");
}
history.pushState({ view: "admin" }, "");
// Permission gating: hide tabs the user cannot access
// Permission gating: hide nav items the user cannot access
var perms = sessionStorage.getItem("turnstone_permissions") || "";
var tabPerms = {
users: "admin.users",
@@ -44,29 +63,66 @@ function showAdmin() {
"ws-templates": "admin.ws_templates",
usage: "admin.usage",
audit: "admin.audit",
memories: "admin.memories",
settings: "admin.users",
};
if (perms) {
var permSet = perms.split(",");
var tabs = document.querySelectorAll(".admin-tab");
for (var i = 0; i < tabs.length; i++) {
var tabName = tabs[i].getAttribute("data-tab");
var navItems = document.querySelectorAll(".admin-nav");
for (var i = 0; i < navItems.length; i++) {
var tabName = navItems[i].getAttribute("data-tab");
var needed = tabPerms[tabName];
if (needed && permSet.indexOf(needed) < 0) {
tabs[i].style.display = "none";
navItems[i].style.display = "none";
} else {
tabs[i].style.display = "";
navItems[i].style.display = "";
}
}
}
// Switch to the first visible tab
var visibleTabs = document.querySelectorAll(
'.admin-tab:not([style*="display: none"])',
);
if (visibleTabs.length > 0) {
switchAdminTab(visibleTabs[0].getAttribute("data-tab"));
// Hide groups where all children are permission-hidden
var groups = document.querySelectorAll(".admin-sidebar-group");
for (var g = 0; g < groups.length; g++) {
var visibleInGroup = groups[g].querySelectorAll(
'.admin-nav:not([style*="display: none"])',
);
groups[g].style.display = visibleInGroup.length > 0 ? "" : "none";
}
// Mobile: ensure sidebar starts hidden + inert; desktop: ensure it's accessible
var sidebar = document.getElementById("admin-sidebar");
if (window.innerWidth <= 700) {
_mobileSidebarOpen = false;
sidebar.classList.add("collapsed");
sidebar.classList.remove("open");
sidebar.setAttribute("aria-hidden", "true");
sidebar.setAttribute("inert", "");
} else {
// No tabs visible — show empty state instead of loading an inaccessible tab
sidebar.removeAttribute("aria-hidden");
sidebar.removeAttribute("inert");
}
// Mobile backdrop listener (idempotent)
var backdrop = document.getElementById("admin-sidebar-backdrop");
if (backdrop && !backdrop._listenerAttached) {
backdrop.addEventListener("click", function () {
if (_mobileSidebarOpen) {
_toggleMobileSidebar();
var mt = document.getElementById("admin-mobile-toggle");
if (mt) mt.focus();
}
});
backdrop._listenerAttached = true;
}
// Switch to the first visible nav item
var visibleNavs = document.querySelectorAll(
'.admin-nav:not([style*="display: none"])',
);
if (visibleNavs.length > 0) {
switchAdminTab(visibleNavs[0].getAttribute("data-tab"));
} else {
// No tabs visible — show empty state
var panels = document.querySelectorAll(".admin-panel");
for (var j = 0; j < panels.length; j++) panels[j].style.display = "none";
var empty = document.getElementById("admin-no-permissions");
@@ -75,23 +131,54 @@ function showAdmin() {
empty.id = "admin-no-permissions";
empty.className = "dashboard-empty";
empty.textContent = "You do not have permissions to view any admin tabs.";
document.getElementById("view-admin").appendChild(empty);
document.getElementById("admin-content").appendChild(empty);
}
empty.style.display = "";
}
}
function _injectMobileToggle(tab) {
var toggle = document.getElementById("admin-mobile-toggle");
if (!toggle) {
toggle = document.createElement("button");
toggle.id = "admin-mobile-toggle";
toggle.className = "admin-mobile-toggle";
toggle.setAttribute("aria-label", "Open navigation");
toggle.onclick = function () {
_mobileSidebarOpen = false;
_toggleMobileSidebar();
};
}
var panel = document.getElementById("admin-" + tab);
if (panel) {
var toolbar = panel.querySelector(".admin-toolbar");
if (toolbar) toolbar.insertBefore(toggle, toolbar.firstChild);
}
}
function _toggleMobileSidebar() {
_mobileSidebarOpen = !_mobileSidebarOpen;
var sidebar = document.getElementById("admin-sidebar");
sidebar.classList.toggle("open", _mobileSidebarOpen);
sidebar.classList.toggle("collapsed", !_mobileSidebarOpen);
sidebar.setAttribute("aria-hidden", _mobileSidebarOpen ? "false" : "true");
if (_mobileSidebarOpen) sidebar.removeAttribute("inert");
else sidebar.setAttribute("inert", "");
var backdrop = document.getElementById("admin-sidebar-backdrop");
if (backdrop) backdrop.classList.toggle("visible", _mobileSidebarOpen);
}
function switchAdminTab(tab) {
_adminTab = tab;
// Hide no-permissions empty state if it was showing
var noPerms = document.getElementById("admin-no-permissions");
if (noPerms) noPerms.style.display = "none";
var tabs = document.querySelectorAll(".admin-tab");
for (var i = 0; i < tabs.length; i++) {
var isActive = tabs[i].getAttribute("data-tab") === tab;
tabs[i].classList.toggle("active", isActive);
tabs[i].setAttribute("aria-selected", isActive ? "true" : "false");
tabs[i].setAttribute("tabindex", isActive ? "0" : "-1");
var navItems = document.querySelectorAll(".admin-nav");
for (var i = 0; i < navItems.length; i++) {
var isActive = navItems[i].getAttribute("data-tab") === tab;
navItems[i].classList.toggle("active", isActive);
navItems[i].setAttribute("aria-selected", isActive ? "true" : "false");
navItems[i].setAttribute("tabindex", isActive ? "0" : "-1");
}
var panels = [
"users",
@@ -105,6 +192,8 @@ function switchAdminTab(tab) {
"ws-templates",
"usage",
"audit",
"memories",
"settings",
];
for (var p = 0; p < panels.length; p++) {
var el = document.getElementById("admin-" + panels[p]);
@@ -125,6 +214,22 @@ function switchAdminTab(tab) {
_populateAuditUserFilter();
loadGovAudit();
}
if (tab === "memories") loadAdminMemories();
if (tab === "settings") loadSettings();
// Update breadcrumb with active tab label
var activeNav = document.querySelector('.admin-nav[data-tab="' + tab + '"]');
var label = activeNav ? activeNav.textContent : tab;
var bcLabel = document.getElementById("breadcrumb-label");
if (bcLabel) bcLabel.textContent = "Admin / " + label;
// Inject mobile hamburger toggle into active panel's toolbar
_injectMobileToggle(tab);
// On mobile, auto-close sidebar after tab selection
if (window.innerWidth <= 700 && _mobileSidebarOpen) {
_toggleMobileSidebar();
}
}
// ---------------------------------------------------------------------------
@@ -1487,6 +1592,7 @@ function _installTrap(overlayId, boxId, trapRef) {
hideCreateWsTemplateModal();
else if (overlayId === "edit-wst-overlay") hideEditWsTemplateModal();
else if (overlayId === "wst-history-overlay") hideWstHistoryModal();
else if (overlayId === "memory-detail-overlay") hideMemoryDetailModal();
}
};
}
@@ -1505,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();
@@ -1565,6 +1678,7 @@ document.addEventListener("keydown", function (e) {
["create-wst-overlay", hideCreateWsTemplateModal],
["edit-wst-overlay", hideEditWsTemplateModal],
["wst-history-overlay", hideWstHistoryModal],
["memory-detail-overlay", hideMemoryDetailModal],
];
for (var gi = 0; gi < govOverlays.length; gi++) {
var govEl = document.getElementById(govOverlays[gi][0]);
@@ -1574,33 +1688,70 @@ document.addEventListener("keydown", function (e) {
return;
}
}
// Close mobile sidebar drawer on Escape
if (_mobileSidebarOpen && window.innerWidth <= 700) {
e.preventDefault();
_toggleMobileSidebar();
var mt = document.getElementById("admin-mobile-toggle");
if (mt) mt.focus();
return;
}
});
// Tab arrow key navigation
// Sidebar arrow key navigation (vertical)
(function () {
var tablist = document.querySelector(".admin-tabs");
if (!tablist) return;
tablist.addEventListener("keydown", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
var allTabs = document.querySelectorAll(
'.admin-tab:not([style*="display: none"])',
var sidebar = document.getElementById("admin-sidebar");
if (!sidebar) return;
sidebar.addEventListener("keydown", function (e) {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
e.preventDefault();
var allNavs = document.querySelectorAll(
'.admin-nav:not([style*="display: none"])',
);
var tabOrder = [];
for (var ti = 0; ti < allTabs.length; ti++) {
tabOrder.push(allTabs[ti].getAttribute("data-tab"));
var navOrder = [];
for (var ni = 0; ni < allNavs.length; ni++) {
navOrder.push(allNavs[ni].getAttribute("data-tab"));
}
if (tabOrder.length === 0) return;
var idx = tabOrder.indexOf(_adminTab);
if (e.key === "ArrowRight") idx = (idx + 1) % tabOrder.length;
else idx = (idx - 1 + tabOrder.length) % tabOrder.length;
switchAdminTab(tabOrder[idx]);
if (navOrder.length === 0) return;
var idx = navOrder.indexOf(_adminTab);
if (e.key === "ArrowDown") idx = (idx + 1) % navOrder.length;
else idx = (idx - 1 + navOrder.length) % navOrder.length;
switchAdminTab(navOrder[idx]);
var btn = document.querySelector(
'.admin-tab[data-tab="' + tabOrder[idx] + '"]',
'.admin-nav[data-tab="' + navOrder[idx] + '"]',
);
if (btn) btn.focus();
});
})();
// Sync sidebar aria-hidden/inert when crossing mobile/desktop breakpoint
(function () {
var resizeTimer;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
if (typeof currentView === "undefined" || currentView !== "admin") return;
var sidebar = document.getElementById("admin-sidebar");
if (!sidebar) return;
var isMobile = window.innerWidth <= 700;
var backdrop = document.getElementById("admin-sidebar-backdrop");
if (isMobile && !_mobileSidebarOpen) {
sidebar.setAttribute("aria-hidden", "true");
sidebar.setAttribute("inert", "");
sidebar.classList.add("collapsed");
sidebar.classList.remove("open");
if (backdrop) backdrop.classList.remove("visible");
} else if (!isMobile) {
sidebar.removeAttribute("aria-hidden");
sidebar.removeAttribute("inert");
sidebar.classList.remove("collapsed", "open");
if (backdrop) backdrop.classList.remove("visible");
_mobileSidebarOpen = false;
}
}, 150);
});
})();
// ---------------------------------------------------------------------------
// Confirm Modal (reusable styled replacement for confirm())
// ---------------------------------------------------------------------------
@@ -1644,6 +1795,557 @@ function _confirmCallback() {
hideConfirmModal();
}
// ---------------------------------------------------------------------------
// 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) 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 =
'<div class="dashboard-empty">Failed to load settings: ' +
escapeHtml(err.message || String(err)) +
"</div>";
});
}
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 +=
'<div class="settings-section" data-section="' +
sec +
'" data-collapsed>';
html +=
'<div class="settings-section-header" onclick="_toggleSettingsSection(this)" onkeydown="_onSettingsHeaderKey(event,this)" role="button" tabindex="0" aria-expanded="false" aria-controls="settings-body-' +
sec +
'">';
html += "<span>" + _settingsSectionLabel(sec) + "</span>";
html += "</div>";
html +=
'<div class="settings-section-body" id="settings-body-' + sec + '">';
for (var j = 0; j < items.length; j++) {
html += _renderSettingRow(items[j]);
}
html += "</div></div>";
}
// 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 +=
'<div class="settings-section" data-section="' +
allSections[s] +
'" data-collapsed>';
html +=
'<div class="settings-section-header" onclick="_toggleSettingsSection(this)" onkeydown="_onSettingsHeaderKey(event,this)" role="button" tabindex="0" aria-expanded="false" aria-controls="settings-body-' +
allSections[s] +
'">';
html += "<span>" + _settingsSectionLabel(allSections[s]) + "</span>";
html += "</div>";
html +=
'<div class="settings-section-body" id="settings-body-' +
allSections[s] +
'">';
for (var x = 0; x < extra.length; x++) {
html += _renderSettingRow(extra[x]);
}
html += "</div></div>";
}
}
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 = '<div class="settings-row" data-row-key="' + escapedKey + '">';
// Label column
html += '<div class="settings-label-col">';
html += '<div class="settings-label">';
html += escapeHtml(shortKey);
if (item.help) {
html +=
' <button class="settings-help-btn" onclick="_toggleSettingsHelp(event, this)" ' +
'aria-label="Help for ' +
escapedShort +
'" aria-expanded="false" title="More info">?</button>';
}
html += "</div>";
if (item.description) {
html += '<div class="settings-desc">' + escapedDesc + "</div>";
}
if (item.help) {
html += '<div class="settings-help-popover" style="display:none">';
html +=
'<span class="settings-help-text">' + escapeHtml(item.help) + "</span>";
if (item.reference_url) {
html +=
' <a href="' +
escapeHtml(item.reference_url) +
'" target="_blank" rel="noopener" class="settings-help-ref">learn more</a>';
}
html += "</div>";
}
html += "</div>";
// Input column
html += '<div class="settings-input">';
if (item.is_secret) {
html +=
'<span class="settings-secret" role="note" aria-label="' +
escapedShort +
': managed via config file or environment variable">(managed via config file / env)</span>';
} else if (item.type === "bool") {
var checked =
item.value === true || item.value === "true" ? " checked" : "";
html +=
'<label class="settings-toggle"><input type="checkbox" data-setting-key="' +
escapedKey +
'" aria-label="' +
escapedShort +
'"' +
checked +
" onchange=\"_onSettingChange('" +
escapedKey +
'\')"><span class="settings-toggle-slider"></span></label>';
} else if (item.choices && item.choices.length > 0) {
html +=
'<select data-setting-key="' +
escapedKey +
'" aria-label="' +
escapedShort +
'" onchange="_onSettingChange(\'' +
escapedKey +
"')\">";
for (var c = 0; c < item.choices.length; c++) {
var sel = item.choices[c] === String(item.value) ? " selected" : "";
var label =
item.choices[c] === "" ? "(none)" : escapeHtml(item.choices[c]);
html +=
'<option value="' +
escapeHtml(item.choices[c]) +
'"' +
sel +
">" +
label +
"</option>";
}
html += "</select>";
} 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 +=
'<input type="number" data-setting-key="' +
escapedKey +
'" aria-label="' +
escapedShort +
'" value="' +
escapeHtml(String(item.value != null ? item.value : "")) +
'" step="' +
step +
'"' +
minAttr +
maxAttr +
" oninput=\"_onSettingChange('" +
escapedKey +
"')\">";
} else {
// str
html +=
'<input type="text" data-setting-key="' +
escapedKey +
'" aria-label="' +
escapedShort +
'" value="' +
escapeHtml(String(item.value != null ? item.value : "")) +
'" oninput="_onSettingChange(\'' +
escapedKey +
"')\">";
}
html += "</div>";
// Actions column
html += '<div class="settings-actions">';
// Restart badge (left of source badge, hidden until dirty or post-save)
if (item.restart_required) {
html +=
'<span class="settings-restart-badge" data-restart-key="' +
escapedKey +
'">restart</span>';
}
// Source badge
if (item.source === "storage") {
html += '<span class="scope-badge scope-write">storage</span>';
} else {
html += '<span class="scope-badge settings-badge-default">default</span>';
}
// Save button (hidden until value changes)
if (!item.is_secret) {
html +=
'<button class="settings-save-btn" data-save-key="' +
escapedKey +
'" onclick="_saveSettingValue(\'' +
escapedKey +
"')\">save</button>";
}
// Reset link (when stored — including secrets, to clear legacy overrides)
if (item.source === "storage") {
html +=
'<button class="settings-reset-btn" data-reset-key="' +
escapedKey +
'" onclick="_resetSetting(\'' +
escapedKey +
"')\">reset</button>";
}
html += "</div>";
html += "</div>";
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));
});
},
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+20
View File
@@ -383,6 +383,11 @@ function showOverview() {
document.getElementById("view-filtered").style.display = "none";
var adminView = document.getElementById("view-admin");
if (adminView) adminView.style.display = "none";
var adminBtn = document.getElementById("admin-btn");
if (adminBtn) {
adminBtn.classList.remove("active");
adminBtn.setAttribute("aria-expanded", "false");
}
document.getElementById("breadcrumb").style.display = "none";
document.getElementById("main").scrollTop = 0;
if (clusterState) renderFromState();
@@ -924,6 +929,11 @@ function drillDownToNode(nodeId, serverUrl) {
document.getElementById("view-filtered").style.display = "none";
var adminView = document.getElementById("view-admin");
if (adminView) adminView.style.display = "none";
var adminBtn = document.getElementById("admin-btn");
if (adminBtn) {
adminBtn.classList.remove("active");
adminBtn.setAttribute("aria-expanded", "false");
}
document.getElementById("breadcrumb").style.display = "";
document.getElementById("breadcrumb-label").textContent = nodeId;
var link = document.getElementById("node-link");
@@ -973,6 +983,11 @@ function drillDownByState(state) {
document.getElementById("view-filtered").style.display = "";
var adminView = document.getElementById("view-admin");
if (adminView) adminView.style.display = "none";
var adminBtn = document.getElementById("admin-btn");
if (adminBtn) {
adminBtn.classList.remove("active");
adminBtn.setAttribute("aria-expanded", "false");
}
document.getElementById("breadcrumb").style.display = "";
var sd = STATE_DISPLAY[state] || STATE_DISPLAY.idle;
document.getElementById("breadcrumb-label").textContent =
@@ -995,6 +1010,11 @@ function drillDownByNode(nodeId) {
document.getElementById("view-filtered").style.display = "";
var adminView = document.getElementById("view-admin");
if (adminView) adminView.style.display = "none";
var adminBtn = document.getElementById("admin-btn");
if (adminBtn) {
adminBtn.classList.remove("active");
adminBtn.setAttribute("aria-expanded", "false");
}
document.getElementById("breadcrumb").style.display = "";
document.getElementById("breadcrumb-label").textContent = nodeId;
document.getElementById("filtered-title").textContent =
+248
View File
@@ -1636,3 +1636,251 @@ function _populateAuditUserFilter() {
}
sel.innerHTML = html;
}
// ---------------------------------------------------------------------------
// Memories tab
// ---------------------------------------------------------------------------
var _adminMemories = [];
var _memDetailTrap = null;
var _memDetailTrigger = null;
var _memSearchTimer = null;
var _memSearchBound = false;
function loadAdminMemories() {
clearTimeout(_memSearchTimer);
// Bind search debounce on first load
if (!_memSearchBound) {
var searchEl = document.getElementById("mem-search");
if (searchEl) {
searchEl.addEventListener("input", function () {
clearTimeout(_memSearchTimer);
_memSearchTimer = setTimeout(loadAdminMemories, 300);
});
}
_memSearchBound = true;
}
var memType = document.getElementById("mem-filter-type").value;
var scope = document.getElementById("mem-filter-scope").value;
var query = (document.getElementById("mem-search").value || "").trim();
var url;
if (query) {
url =
"/v1/api/admin/memories/search?q=" +
encodeURIComponent(query) +
(memType ? "&type=" + encodeURIComponent(memType) : "") +
(scope ? "&scope=" + encodeURIComponent(scope) : "");
} else {
url =
"/v1/api/admin/memories?limit=200" +
(memType ? "&type=" + encodeURIComponent(memType) : "") +
(scope ? "&scope=" + encodeURIComponent(scope) : "");
}
authFetch(url)
.then(function (r) {
if (!r.ok) throw new Error("Failed to load memories");
return r.json();
})
.then(function (data) {
_adminMemories = data.memories || [];
_renderAdminMemories(_adminMemories, data.total || _adminMemories.length);
})
.catch(function () {
document.getElementById("admin-memories-table").innerHTML =
'<div class="dashboard-empty">Failed to load memories</div>';
});
}
function _renderAdminMemories(items, total) {
var el = document.getElementById("admin-memories-table");
if (!items.length) {
el.innerHTML = '<div class="dashboard-empty">No memories found</div>';
return;
}
var html = "";
for (var i = 0; i < items.length; i++) {
var m = items[i];
// Type badge
var typeCls = "scope-badge mem-type-" + escapeHtml(m.type);
var typeBadge =
'<span class="' + typeCls + '">' + escapeHtml(m.type) + "</span>";
// Scope badge
var scopeLabel = m.scope;
if (m.scope_id) scopeLabel += ":" + m.scope_id;
var scopeCls = "scope-badge mem-scope-" + escapeHtml(m.scope);
var scopeBadge =
'<span class="' + scopeCls + '">' + escapeHtml(scopeLabel) + "</span>";
// Description (truncated)
var desc = m.description || "";
if (desc.length > 60) desc = desc.substring(0, 57) + "…";
html +=
'<div class="admin-row" role="listitem">' +
'<span class="admin-col admin-col-mname">' +
escapeHtml(m.name) +
"</span>" +
'<span class="admin-col admin-col-mtype">' +
typeBadge +
"</span>" +
'<span class="admin-col admin-col-mscope">' +
scopeBadge +
"</span>" +
'<span class="admin-col admin-col-mdesc">' +
escapeHtml(desc) +
"</span>" +
'<span class="admin-col admin-col-mupdated">' +
_relativeTime(m.updated) +
"</span>" +
'<span class="admin-col admin-col-actions">' +
'<button class="admin-btn-action" data-view-memory="' +
escapeHtml(m.memory_id) +
'">view</button>' +
'<button class="admin-btn-danger" data-delete-memory="' +
escapeHtml(m.memory_id) +
'" data-delete-name="' +
escapeHtml(m.name) +
'">delete</button>' +
"</span>" +
"</div>";
}
el.innerHTML = html;
// Bind view buttons
var viewBtns = el.querySelectorAll("[data-view-memory]");
for (var v = 0; v < viewBtns.length; v++) {
viewBtns[v].addEventListener("click", function () {
showMemoryDetailModal(this.getAttribute("data-view-memory"));
});
}
// Bind delete buttons
var delBtns = el.querySelectorAll("[data-delete-memory]");
for (var d = 0; d < delBtns.length; d++) {
delBtns[d].addEventListener("click", function () {
var mid = this.getAttribute("data-delete-memory");
var mname = this.getAttribute("data-delete-name");
deleteAdminMemory(mid, mname);
});
}
}
function showMemoryDetailModal(memoryId) {
_memDetailTrigger = document.activeElement;
var ov = document.getElementById("memory-detail-overlay");
ov.style.display = "flex";
document.getElementById("memory-detail-body").innerHTML =
'<div class="dashboard-empty">Loading…</div>';
// Disable delete button and clear stale handler while loading
var delBtn = document.getElementById("mem-detail-delete");
delBtn.disabled = true;
delBtn.onclick = null;
// Focus close button for keyboard accessibility
var closeBtn = ov.querySelector(".modal-cancel");
if (closeBtn) closeBtn.focus();
authFetch("/v1/api/admin/memories/" + encodeURIComponent(memoryId))
.then(function (r) {
if (!r.ok) throw new Error("Not found");
return r.json();
})
.then(function (m) {
var scopeLabel = m.scope;
if (m.scope_id) scopeLabel += ":" + m.scope_id;
var html =
'<div class="mem-detail-grid">' +
'<div class="mem-detail-field"><span class="mem-detail-label">Name</span>' +
escapeHtml(m.name) +
"</div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Type</span>' +
'<span class="scope-badge mem-type-' +
escapeHtml(m.type) +
'">' +
escapeHtml(m.type) +
"</span></div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Scope</span>' +
'<span class="scope-badge mem-scope-' +
escapeHtml(m.scope) +
'">' +
escapeHtml(scopeLabel) +
"</span></div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Created</span>' +
_relativeTime(m.created) +
"</div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Updated</span>' +
_relativeTime(m.updated) +
"</div>" +
'<div class="mem-detail-field"><span class="mem-detail-label">Accessed</span>' +
(m.access_count || 0) +
" times</div>" +
"</div>" +
'<div class="mem-detail-label" style="margin-top:12px">Description</div>' +
'<div class="mem-detail-desc">' +
escapeHtml(m.description || "(none)") +
"</div>" +
'<div class="mem-detail-label" style="margin-top:12px">Content</div>' +
'<pre class="memory-content-block">' +
escapeHtml(m.content) +
"</pre>";
document.getElementById("memory-detail-body").innerHTML = html;
// Wire delete button now that data is loaded
delBtn.disabled = false;
delBtn.onclick = function () {
deleteAdminMemory(m.memory_id, m.name);
};
})
.catch(function () {
document.getElementById("memory-detail-body").innerHTML =
'<div class="dashboard-empty">Failed to load memory</div>';
});
_memDetailTrap = _installTrap("memory-detail-overlay", "memory-detail-box");
}
function hideMemoryDetailModal() {
document.getElementById("memory-detail-overlay").style.display = "none";
_memDetailTrap = _removeTrap(_memDetailTrap);
if (_memDetailTrigger && _memDetailTrigger.focus) _memDetailTrigger.focus();
_memDetailTrigger = null;
}
function deleteAdminMemory(memoryId, memoryName) {
if (!confirm("Delete memory '" + memoryName + "'?")) return;
authFetch("/v1/api/admin/memories/" + encodeURIComponent(memoryId), {
method: "DELETE",
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Failed");
});
return r.json();
})
.then(function () {
showToast("Memory deleted");
// Close detail modal if open
if (
document.getElementById("memory-detail-overlay").style.display !==
"none"
) {
hideMemoryDetailModal();
}
loadAdminMemories();
})
.catch(function (e) {
showToast("Error: " + e.message);
});
}
+281 -167
View File
@@ -16,7 +16,7 @@
<span id="cluster-summary" aria-live="polite"></span>
<span id="status-bar" role="status" aria-live="polite"></span>
<button id="new-ws-btn" class="header-btn header-btn-accent" onclick="showNewWsModal()" title="Create workstream">+ new</button>
<button id="admin-btn" class="header-btn" onclick="showAdmin()" title="User &amp; token administration">admin</button>
<button id="admin-btn" class="header-btn" onclick="showAdmin()" title="User &amp; token administration" aria-expanded="false" aria-controls="view-admin">admin</button>
<button id="logout-btn" class="header-btn" onclick="logout()" style="display:none">logout</button>
<button id="theme-toggle" class="header-btn" onclick="toggleTheme()" aria-label="Toggle light/dark theme">&#9790;</button>
</div>
@@ -77,19 +77,42 @@
<!-- ADMIN PANEL -->
<div id="view-admin" style="display:none">
<div class="admin-tabs" role="tablist">
<button id="tab-users" class="admin-tab active" data-tab="users" role="tab" aria-selected="true" aria-controls="admin-users" tabindex="0" onclick="switchAdminTab('users')">Users</button>
<button id="tab-tokens" class="admin-tab" data-tab="tokens" role="tab" aria-selected="false" aria-controls="admin-tokens" tabindex="-1" onclick="switchAdminTab('tokens')">Tokens</button>
<button id="tab-channels" class="admin-tab" data-tab="channels" role="tab" aria-selected="false" aria-controls="admin-channels" tabindex="-1" onclick="switchAdminTab('channels')">Channels</button>
<button id="tab-schedules" class="admin-tab" data-tab="schedules" role="tab" aria-selected="false" aria-controls="admin-schedules" tabindex="-1" onclick="switchAdminTab('schedules')">Schedules</button>
<button id="tab-watches" class="admin-tab" data-tab="watches" role="tab" aria-selected="false" aria-controls="admin-watches" tabindex="-1" onclick="switchAdminTab('watches')">Watches</button>
<button id="tab-roles" class="admin-tab" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
<button id="tab-policies" class="admin-tab" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
<button id="tab-templates" class="admin-tab" data-tab="templates" role="tab" aria-selected="false" aria-controls="admin-templates" tabindex="-1" onclick="switchAdminTab('templates')">Templates</button>
<button id="tab-ws-templates" class="admin-tab" data-tab="ws-templates" role="tab" aria-selected="false" aria-controls="admin-ws-templates" tabindex="-1" onclick="switchAdminTab('ws-templates')">WS Templates</button>
<button id="tab-usage" class="admin-tab" data-tab="usage" role="tab" aria-selected="false" aria-controls="admin-usage" tabindex="-1" onclick="switchAdminTab('usage')">Usage</button>
<button id="tab-audit" class="admin-tab" data-tab="audit" role="tab" aria-selected="false" aria-controls="admin-audit" tabindex="-1" onclick="switchAdminTab('audit')">Audit</button>
</div>
<div id="admin-layout" class="admin-layout">
<!-- Sidebar navigation -->
<nav id="admin-sidebar" class="admin-sidebar" role="tablist" aria-label="Admin navigation" aria-orientation="vertical">
<div class="admin-sidebar-group" data-group="identity" role="group" aria-label="Identity">
<div class="admin-sidebar-group-label" aria-hidden="true">Identity</div>
<button id="tab-users" class="admin-nav active" data-tab="users" role="tab" aria-selected="true" aria-controls="admin-users" tabindex="0" onclick="switchAdminTab('users')">Users</button>
<button id="tab-tokens" class="admin-nav" data-tab="tokens" role="tab" aria-selected="false" aria-controls="admin-tokens" tabindex="-1" onclick="switchAdminTab('tokens')">API Tokens</button>
<button id="tab-channels" class="admin-nav" data-tab="channels" role="tab" aria-selected="false" aria-controls="admin-channels" tabindex="-1" onclick="switchAdminTab('channels')">Channels</button>
</div>
<div class="admin-sidebar-group" data-group="automation" role="group" aria-label="Automation">
<div class="admin-sidebar-group-label" aria-hidden="true">Automation</div>
<button id="tab-schedules" class="admin-nav" data-tab="schedules" role="tab" aria-selected="false" aria-controls="admin-schedules" tabindex="-1" onclick="switchAdminTab('schedules')">Schedules</button>
<button id="tab-watches" class="admin-nav" data-tab="watches" role="tab" aria-selected="false" aria-controls="admin-watches" tabindex="-1" onclick="switchAdminTab('watches')">Watches</button>
</div>
<div class="admin-sidebar-group" data-group="governance" role="group" aria-label="Governance">
<div class="admin-sidebar-group-label" aria-hidden="true">Governance</div>
<button id="tab-roles" class="admin-nav" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
<button id="tab-policies" class="admin-nav" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
<button id="tab-templates" class="admin-nav" data-tab="templates" role="tab" aria-selected="false" aria-controls="admin-templates" tabindex="-1" onclick="switchAdminTab('templates')">Templates</button>
<button id="tab-ws-templates" class="admin-nav" data-tab="ws-templates" role="tab" aria-selected="false" aria-controls="admin-ws-templates" tabindex="-1" onclick="switchAdminTab('ws-templates')">WS Templates</button>
</div>
<div class="admin-sidebar-group" data-group="observe" role="group" aria-label="Observe">
<div class="admin-sidebar-group-label" aria-hidden="true">Observe</div>
<button id="tab-usage" class="admin-nav" data-tab="usage" role="tab" aria-selected="false" aria-controls="admin-usage" tabindex="-1" onclick="switchAdminTab('usage')">Usage</button>
<button id="tab-audit" class="admin-nav" data-tab="audit" role="tab" aria-selected="false" aria-controls="admin-audit" tabindex="-1" onclick="switchAdminTab('audit')">Audit</button>
<button id="tab-memories" class="admin-nav" data-tab="memories" role="tab" aria-selected="false" aria-controls="admin-memories" tabindex="-1" onclick="switchAdminTab('memories')">Memories</button>
</div>
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
</div>
</nav>
<div id="admin-sidebar-backdrop" class="admin-sidebar-backdrop" aria-hidden="true"></div>
<!-- Content area -->
<div id="admin-content" class="admin-content">
<!-- Users Tab -->
<div id="admin-users" class="admin-panel" role="tabpanel" aria-labelledby="tab-users">
@@ -111,7 +134,7 @@
<!-- Tokens Tab -->
<div id="admin-tokens" class="admin-panel" role="tabpanel" aria-labelledby="tab-tokens" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">TOKENS</span>
<span class="section-header" style="margin:0">API TOKENS</span>
<label for="admin-token-user" class="sr-only">Filter tokens by user</label>
<select id="admin-token-user" onchange="loadAdminTokens()">
<option value="">Select user...</option>
@@ -324,6 +347,54 @@
<div class="dashboard-empty">Loading audit log...</div>
</div>
</div>
<!-- Memories Tab -->
<div id="admin-memories" class="admin-panel" role="tabpanel" aria-labelledby="tab-memories" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">MEMORIES</span>
<div class="admin-toolbar-filters">
<select id="mem-filter-type" onchange="loadAdminMemories()" aria-label="Filter by type">
<option value="">All types</option>
<option value="user">user</option>
<option value="project">project</option>
<option value="feedback">feedback</option>
<option value="reference">reference</option>
</select>
<select id="mem-filter-scope" onchange="loadAdminMemories()" aria-label="Filter by scope">
<option value="">All scopes</option>
<option value="global">global</option>
<option value="workstream">workstream</option>
<option value="user">user</option>
</select>
<input id="mem-search" type="search" placeholder="Search memories…" aria-label="Search memories" autocomplete="off">
</div>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-mname">NAME</span>
<span class="admin-col admin-col-mtype">TYPE</span>
<span class="admin-col admin-col-mscope">SCOPE</span>
<span class="admin-col admin-col-mdesc">DESCRIPTION</span>
<span class="admin-col admin-col-mupdated">UPDATED</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-memories-table" role="list" aria-label="Memories" aria-live="polite">
<div class="dashboard-empty">Loading…</div>
</div>
</div>
<!-- Settings Tab -->
<div id="admin-settings" class="admin-panel" role="tabpanel" aria-labelledby="tab-settings" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">SETTINGS</span>
<a href="/docs#/System:%20Settings" target="_blank" rel="noopener" class="settings-docs-link" title="Settings API reference">docs</a>
</div>
<div id="admin-settings-content">
<div class="dashboard-empty">Loading&hellip;</div>
</div>
</div>
</div><!-- /admin-content -->
</div><!-- /admin-layout -->
</div>
</div>
@@ -479,44 +550,52 @@ window.TURNSTONE_KB_SHORTCUTS = [
<div id="create-schedule-box" class="admin-modal admin-modal-wide">
<h2 id="create-schedule-title">New Schedule</h2>
<div id="create-schedule-error" role="alert" aria-live="assertive"></div>
<label for="cs-name">Name</label>
<input id="cs-name" type="text" placeholder="Daily health check" autocomplete="off">
<label for="cs-desc">Description <span class="label-hint">optional</span></label>
<input id="cs-desc" type="text" placeholder="" autocomplete="off">
<label for="cs-type">Schedule type</label>
<select id="cs-type" onchange="toggleScheduleTypeFields()">
<option value="cron">Cron (recurring)</option>
<option value="at">At (one-shot)</option>
</select>
<div id="cs-cron-group">
<label for="cs-cron">Cron expression</label>
<input id="cs-cron" type="text" placeholder="0 9 * * MON-FRI" autocomplete="off" spellcheck="false" aria-describedby="cs-cron-hint">
<span id="cs-cron-hint" class="label-hint" style="display:block;margin-top:3px">min hour day month weekday</span>
<div class="modal-columns">
<div class="modal-col">
<div class="modal-col-heading">Schedule</div>
<label for="cs-name">Name</label>
<input id="cs-name" type="text" placeholder="Daily health check" autocomplete="off">
<label for="cs-desc">Description <span class="label-hint">optional</span></label>
<input id="cs-desc" type="text" placeholder="" autocomplete="off">
<label for="cs-type">Schedule type</label>
<select id="cs-type" onchange="toggleScheduleTypeFields()">
<option value="cron">Cron (recurring)</option>
<option value="at">At (one-shot)</option>
</select>
<div id="cs-cron-group">
<label for="cs-cron">Cron expression</label>
<input id="cs-cron" type="text" placeholder="0 9 * * MON-FRI" autocomplete="off" spellcheck="false" aria-describedby="cs-cron-hint">
<span id="cs-cron-hint" class="label-hint" style="display:block;margin-top:3px">min hour day month weekday</span>
</div>
<div id="cs-at-group" style="display:none">
<label for="cs-at">Run at</label>
<input id="cs-at" type="datetime-local">
</div>
<label for="cs-target">Target</label>
<select id="cs-target" onchange="toggleScheduleNodeField()">
<option value="auto">Auto (best available)</option>
<option value="pool">Pool (any bridge)</option>
<option value="all">All nodes</option>
<option value="node">Specific node...</option>
</select>
<div id="cs-node-group" style="display:none">
<label for="cs-node">Node ID</label>
<input id="cs-node" type="text" placeholder="node-001" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="modal-col">
<div class="modal-col-heading">Execution</div>
<label for="cs-model">Model <span class="label-hint">optional</span></label>
<input id="cs-model" type="text" placeholder="Default model" autocomplete="off">
<label for="cs-template">Template <span class="label-hint">optional</span></label>
<input id="cs-template" type="text" placeholder="Prompt template name" autocomplete="off">
<label for="cs-ws-template">WS Template <span class="label-hint">optional</span></label>
<select id="cs-ws-template"><option value="">None</option></select>
<label for="cs-message">Initial message</label>
<textarea id="cs-message" rows="3" placeholder="What should the workstream do?"></textarea>
<label class="admin-checkbox"><input id="cs-autoapprove" type="checkbox"> Auto-approve tool calls</label>
</div>
</div>
<div id="cs-at-group" style="display:none">
<label for="cs-at">Run at</label>
<input id="cs-at" type="datetime-local">
</div>
<label for="cs-target">Target</label>
<select id="cs-target" onchange="toggleScheduleNodeField()">
<option value="auto">Auto (best available)</option>
<option value="pool">Pool (any bridge)</option>
<option value="all">All nodes</option>
<option value="node">Specific node...</option>
</select>
<div id="cs-node-group" style="display:none">
<label for="cs-node">Node ID</label>
<input id="cs-node" type="text" placeholder="node-001" autocomplete="off" spellcheck="false">
</div>
<label for="cs-model">Model <span class="label-hint">optional</span></label>
<input id="cs-model" type="text" placeholder="Default model" autocomplete="off">
<label for="cs-template">Template <span class="label-hint">optional</span></label>
<input id="cs-template" type="text" placeholder="Prompt template name" autocomplete="off">
<label for="cs-ws-template">WS Template <span class="label-hint">optional &mdash; workstream profile</span></label>
<select id="cs-ws-template"><option value="">None</option></select>
<label for="cs-message">Initial message</label>
<textarea id="cs-message" rows="3" placeholder="What should the workstream do?"></textarea>
<label class="admin-checkbox"><input id="cs-autoapprove" type="checkbox"> Auto-approve tool calls</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateScheduleModal()">Cancel</button>
<button id="cs-submit" class="modal-submit" onclick="submitCreateSchedule()">Create</button>
@@ -530,44 +609,52 @@ window.TURNSTONE_KB_SHORTCUTS = [
<h2 id="edit-schedule-title">Edit Schedule</h2>
<div id="edit-schedule-error" role="alert" aria-live="assertive"></div>
<input id="es-id" type="hidden">
<label for="es-name">Name</label>
<input id="es-name" type="text" autocomplete="off">
<label for="es-desc">Description</label>
<input id="es-desc" type="text" autocomplete="off">
<label for="es-type">Schedule type</label>
<select id="es-type" onchange="toggleEditScheduleTypeFields()">
<option value="cron">Cron (recurring)</option>
<option value="at">At (one-shot)</option>
</select>
<div id="es-cron-group">
<label for="es-cron">Cron expression</label>
<input id="es-cron" type="text" autocomplete="off" spellcheck="false">
<div class="modal-columns">
<div class="modal-col">
<div class="modal-col-heading">Schedule</div>
<label for="es-name">Name</label>
<input id="es-name" type="text" autocomplete="off">
<label for="es-desc">Description</label>
<input id="es-desc" type="text" autocomplete="off">
<label for="es-type">Schedule type</label>
<select id="es-type" onchange="toggleEditScheduleTypeFields()">
<option value="cron">Cron (recurring)</option>
<option value="at">At (one-shot)</option>
</select>
<div id="es-cron-group">
<label for="es-cron">Cron expression</label>
<input id="es-cron" type="text" autocomplete="off" spellcheck="false">
</div>
<div id="es-at-group" style="display:none">
<label for="es-at">Run at</label>
<input id="es-at" type="datetime-local">
</div>
<label for="es-target">Target</label>
<select id="es-target" onchange="toggleEditScheduleNodeField()">
<option value="auto">Auto (best available)</option>
<option value="pool">Pool (any bridge)</option>
<option value="all">All nodes</option>
<option value="node">Specific node...</option>
</select>
<div id="es-node-group" style="display:none">
<label for="es-node">Node ID</label>
<input id="es-node" type="text" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="modal-col">
<div class="modal-col-heading">Execution</div>
<label for="es-model">Model</label>
<input id="es-model" type="text" autocomplete="off">
<label for="es-template">Template <span class="label-hint">optional</span></label>
<input id="es-template" type="text" autocomplete="off">
<label for="es-ws-template">WS Template <span class="label-hint">optional</span></label>
<select id="es-ws-template"><option value="">None</option></select>
<label for="es-message">Initial message</label>
<textarea id="es-message" rows="3"></textarea>
<label class="admin-checkbox"><input id="es-autoapprove" type="checkbox"> Auto-approve tool calls</label>
<label class="admin-checkbox"><input id="es-enabled" type="checkbox"> Enabled</label>
</div>
</div>
<div id="es-at-group" style="display:none">
<label for="es-at">Run at</label>
<input id="es-at" type="datetime-local">
</div>
<label for="es-target">Target</label>
<select id="es-target" onchange="toggleEditScheduleNodeField()">
<option value="auto">Auto (best available)</option>
<option value="pool">Pool (any bridge)</option>
<option value="all">All nodes</option>
<option value="node">Specific node...</option>
</select>
<div id="es-node-group" style="display:none">
<label for="es-node">Node ID</label>
<input id="es-node" type="text" autocomplete="off" spellcheck="false">
</div>
<label for="es-model">Model</label>
<input id="es-model" type="text" autocomplete="off">
<label for="es-template">Template <span class="label-hint">optional</span></label>
<input id="es-template" type="text" autocomplete="off">
<label for="es-ws-template">WS Template <span class="label-hint">optional</span></label>
<select id="es-ws-template"><option value="">None</option></select>
<label for="es-message">Initial message</label>
<textarea id="es-message" rows="3"></textarea>
<label class="admin-checkbox"><input id="es-autoapprove" type="checkbox"> Auto-approve tool calls</label>
<label class="admin-checkbox"><input id="es-enabled" type="checkbox"> Enabled</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideEditScheduleModal()">Cancel</button>
<button id="es-submit" class="modal-submit" onclick="submitEditSchedule()">Save</button>
@@ -745,46 +832,54 @@ window.TURNSTONE_KB_SHORTCUTS = [
<div id="create-wst-box" class="admin-modal admin-modal-wide">
<h2 id="create-wst-title">Create Workstream Template</h2>
<div id="create-wst-error" role="alert" aria-live="assertive"></div>
<label for="cwst-name">Name</label>
<input id="cwst-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
<label for="cwst-description">Description <span class="label-hint">optional</span></label>
<input id="cwst-description" type="text" placeholder="Brief description" autocomplete="off">
<label>System Prompt Source</label>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<label class="admin-checkbox" style="margin-top:0"><input id="cwst-src-inline" type="radio" name="cwst-src" value="inline" checked onchange="toggleWstPromptSource()"> Inline</label>
<label class="admin-checkbox" style="margin-top:0"><input id="cwst-src-ref" type="radio" name="cwst-src" value="ref" onchange="toggleWstPromptSource()"> Prompt Template</label>
<div class="modal-columns">
<div class="modal-col">
<div class="modal-col-heading">Identity</div>
<label for="cwst-name">Name</label>
<input id="cwst-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
<label for="cwst-description">Description <span class="label-hint">optional</span></label>
<input id="cwst-description" type="text" placeholder="Brief description" autocomplete="off">
<label>System Prompt Source</label>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<label class="admin-checkbox" style="margin-top:0"><input id="cwst-src-inline" type="radio" name="cwst-src" value="inline" checked onchange="toggleWstPromptSource()"> Inline</label>
<label class="admin-checkbox" style="margin-top:0"><input id="cwst-src-ref" type="radio" name="cwst-src" value="ref" onchange="toggleWstPromptSource()"> Prompt Template</label>
</div>
<div id="cwst-inline-section">
<label for="cwst-system-prompt">System Prompt <span class="label-hint">inline text</span></label>
<textarea id="cwst-system-prompt" rows="4" placeholder="You are a..."></textarea>
</div>
<div id="cwst-ref-section" style="display:none">
<label for="cwst-prompt-template">Prompt Template <span class="label-hint">reference by name</span></label>
<select id="cwst-prompt-template"><option value="">None</option></select>
</div>
<label class="admin-checkbox"><input id="cwst-auto-approve" type="checkbox"> Auto-approve all tools</label>
<label for="cwst-auto-approve-tools">Auto-approve tools <span class="label-hint">comma-separated</span></label>
<input id="cwst-auto-approve-tools" type="text" placeholder="e.g. read_file, list_directory" autocomplete="off">
</div>
<div class="modal-col">
<div class="modal-col-heading">Model Config</div>
<label for="cwst-model">Model</label>
<input id="cwst-model" type="text" autocomplete="off">
<label for="cwst-temperature">Temperature <span class="label-hint">0.02.0</span></label>
<input id="cwst-temperature" type="number" step="0.1" min="0" max="2" autocomplete="off">
<label for="cwst-reasoning-effort">Reasoning effort</label>
<select id="cwst-reasoning-effort">
<option value="">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="none">None</option>
<option value="max">Max</option>
</select>
<label for="cwst-max-tokens">Max tokens <span class="label-hint">0 = default</span></label>
<input id="cwst-max-tokens" type="number" min="0" autocomplete="off">
<label for="cwst-agent-max-turns">Agent max turns <span class="label-hint">0 = default</span></label>
<input id="cwst-agent-max-turns" type="number" min="0" autocomplete="off">
<label for="cwst-token-budget">Token budget <span class="label-hint">0 = unlimited</span></label>
<input id="cwst-token-budget" type="number" value="0" min="0">
<label class="admin-checkbox"><input id="cwst-enabled" type="checkbox" checked> Enabled</label>
</div>
</div>
<div id="cwst-inline-section">
<label for="cwst-system-prompt">System Prompt <span class="label-hint">inline text</span></label>
<textarea id="cwst-system-prompt" rows="4" placeholder="You are a..."></textarea>
</div>
<div id="cwst-ref-section" style="display:none">
<label for="cwst-prompt-template">Prompt Template <span class="label-hint">reference by name</span></label>
<select id="cwst-prompt-template"><option value="">None</option></select>
</div>
<label for="cwst-model">Model <span class="label-hint">optional — server default if empty</span></label>
<input id="cwst-model" type="text" placeholder="Default model" autocomplete="off">
<label class="admin-checkbox"><input id="cwst-auto-approve" type="checkbox"> Auto-approve all tools</label>
<label for="cwst-auto-approve-tools">Auto-approve tools <span class="label-hint">comma-separated tool names</span></label>
<input id="cwst-auto-approve-tools" type="text" placeholder="e.g. read_file, list_directory" autocomplete="off">
<label for="cwst-token-budget">Token budget <span class="label-hint">0 = unlimited</span></label>
<input id="cwst-token-budget" type="number" value="0" min="0">
<label for="cwst-temperature">Temperature <span class="label-hint">optional — 0.0-2.0, empty = server default</span></label>
<input id="cwst-temperature" type="number" step="0.1" min="0" max="2" placeholder="Server default" autocomplete="off">
<label for="cwst-reasoning-effort">Reasoning effort <span class="label-hint">optional</span></label>
<select id="cwst-reasoning-effort">
<option value="">Server default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="none">None</option>
<option value="max">Max</option>
</select>
<label for="cwst-max-tokens">Max tokens <span class="label-hint">optional — 0 = server default</span></label>
<input id="cwst-max-tokens" type="number" min="0" placeholder="Server default" autocomplete="off">
<label for="cwst-agent-max-turns">Agent max turns <span class="label-hint">optional — 0 = server default</span></label>
<input id="cwst-agent-max-turns" type="number" min="0" placeholder="Server default" autocomplete="off">
<label class="admin-checkbox"><input id="cwst-enabled" type="checkbox" checked> Enabled</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateWsTemplateModal()">Cancel</button>
<button id="cwst-submit" class="modal-submit" onclick="submitCreateWsTemplate()">Create</button>
@@ -798,46 +893,54 @@ window.TURNSTONE_KB_SHORTCUTS = [
<h2 id="edit-wst-title">Edit Workstream Template</h2>
<div id="edit-wst-error" role="alert" aria-live="assertive"></div>
<input id="ewst-id" type="hidden">
<label for="ewst-name">Name</label>
<input id="ewst-name" type="text" autocomplete="off">
<label for="ewst-description">Description</label>
<input id="ewst-description" type="text" autocomplete="off">
<label>System Prompt Source</label>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<label class="admin-checkbox" style="margin-top:0"><input id="ewst-src-inline" type="radio" name="ewst-src" value="inline" checked onchange="toggleEditWstPromptSource()"> Inline</label>
<label class="admin-checkbox" style="margin-top:0"><input id="ewst-src-ref" type="radio" name="ewst-src" value="ref" onchange="toggleEditWstPromptSource()"> Prompt Template</label>
<div class="modal-columns">
<div class="modal-col">
<div class="modal-col-heading">Identity</div>
<label for="ewst-name">Name</label>
<input id="ewst-name" type="text" autocomplete="off">
<label for="ewst-description">Description</label>
<input id="ewst-description" type="text" autocomplete="off">
<label>System Prompt Source</label>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<label class="admin-checkbox" style="margin-top:0"><input id="ewst-src-inline" type="radio" name="ewst-src" value="inline" checked onchange="toggleEditWstPromptSource()"> Inline</label>
<label class="admin-checkbox" style="margin-top:0"><input id="ewst-src-ref" type="radio" name="ewst-src" value="ref" onchange="toggleEditWstPromptSource()"> Prompt Template</label>
</div>
<div id="ewst-inline-section">
<label for="ewst-system-prompt">System Prompt</label>
<textarea id="ewst-system-prompt" rows="4"></textarea>
</div>
<div id="ewst-ref-section" style="display:none">
<label for="ewst-prompt-template">Prompt Template</label>
<select id="ewst-prompt-template"><option value="">None</option></select>
</div>
<label class="admin-checkbox"><input id="ewst-auto-approve" type="checkbox"> Auto-approve all tools</label>
<label for="ewst-auto-approve-tools">Auto-approve tools</label>
<input id="ewst-auto-approve-tools" type="text" autocomplete="off">
</div>
<div class="modal-col">
<div class="modal-col-heading">Model Config</div>
<label for="ewst-model">Model</label>
<input id="ewst-model" type="text" autocomplete="off">
<label for="ewst-temperature">Temperature <span class="label-hint">0.02.0</span></label>
<input id="ewst-temperature" type="number" step="0.1" min="0" max="2" autocomplete="off">
<label for="ewst-reasoning-effort">Reasoning effort</label>
<select id="ewst-reasoning-effort">
<option value="">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="none">None</option>
<option value="max">Max</option>
</select>
<label for="ewst-max-tokens">Max tokens <span class="label-hint">0 = default</span></label>
<input id="ewst-max-tokens" type="number" min="0" autocomplete="off">
<label for="ewst-agent-max-turns">Agent max turns <span class="label-hint">0 = default</span></label>
<input id="ewst-agent-max-turns" type="number" min="0" autocomplete="off">
<label for="ewst-token-budget">Token budget <span class="label-hint">0 = unlimited</span></label>
<input id="ewst-token-budget" type="number" value="0" min="0">
<label class="admin-checkbox"><input id="ewst-enabled" type="checkbox" checked> Enabled</label>
</div>
</div>
<div id="ewst-inline-section">
<label for="ewst-system-prompt">System Prompt</label>
<textarea id="ewst-system-prompt" rows="4"></textarea>
</div>
<div id="ewst-ref-section" style="display:none">
<label for="ewst-prompt-template">Prompt Template</label>
<select id="ewst-prompt-template"><option value="">None</option></select>
</div>
<label for="ewst-model">Model</label>
<input id="ewst-model" type="text" autocomplete="off">
<label class="admin-checkbox"><input id="ewst-auto-approve" type="checkbox"> Auto-approve all tools</label>
<label for="ewst-auto-approve-tools">Auto-approve tools</label>
<input id="ewst-auto-approve-tools" type="text" autocomplete="off">
<label for="ewst-token-budget">Token budget</label>
<input id="ewst-token-budget" type="number" value="0" min="0">
<label for="ewst-temperature">Temperature <span class="label-hint">optional — 0.0-2.0, empty = server default</span></label>
<input id="ewst-temperature" type="number" step="0.1" min="0" max="2" placeholder="Server default" autocomplete="off">
<label for="ewst-reasoning-effort">Reasoning effort <span class="label-hint">optional</span></label>
<select id="ewst-reasoning-effort">
<option value="">Server default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="none">None</option>
<option value="max">Max</option>
</select>
<label for="ewst-max-tokens">Max tokens <span class="label-hint">optional</span></label>
<input id="ewst-max-tokens" type="number" min="0" placeholder="Server default" autocomplete="off">
<label for="ewst-agent-max-turns">Agent max turns <span class="label-hint">optional</span></label>
<input id="ewst-agent-max-turns" type="number" min="0" placeholder="Server default" autocomplete="off">
<label class="admin-checkbox"><input id="ewst-enabled" type="checkbox" checked> Enabled</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideEditWsTemplateModal()">Cancel</button>
<button id="ewst-submit" class="modal-submit" onclick="submitEditWsTemplate()">Save</button>
@@ -858,6 +961,17 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<div id="memory-detail-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="memory-detail-title">
<div id="memory-detail-box" class="admin-modal admin-modal-wide">
<h2 id="memory-detail-title">Memory Detail</h2>
<div id="memory-detail-body"></div>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideMemoryDetailModal()">Close</button>
<button id="mem-detail-delete" class="modal-submit admin-btn-danger">Delete</button>
</div>
</div>
</div>
<script src="/static/admin.js"></script>
<script src="/static/governance.js"></script>
<script src="/static/app.js"></script>
+547 -31
View File
@@ -543,6 +543,30 @@
outline-offset: 2px;
}
/* Admin button — top accent line + active state */
#admin-btn {
position: relative;
overflow: visible;
}
#admin-btn::before {
content: '';
position: absolute;
top: -2px;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
border-radius: 0 0 1px 1px;
opacity: 0;
transition: opacity 0.2s ease;
}
#admin-btn.active::before { opacity: 1; }
#admin-btn.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-dim);
}
/* ==========================================================================
New Workstream Modal
========================================================================== */
@@ -702,41 +726,130 @@
}
/* ==========================================================================
Admin panel
Admin panel sidebar layout
========================================================================== */
.admin-tabs {
#view-admin { animation: admin-fadein 0.15s ease-out; }
@keyframes admin-fadein { from { opacity: 0; } to { opacity: 1; } }
.admin-layout {
display: flex;
gap: 2px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 0;
min-height: 0;
flex: 1;
}
.admin-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--fg-dim);
/* Sidebar — right-aligned to match header admin button position */
.admin-sidebar {
width: 180px;
flex-shrink: 0;
background: var(--bg-surface);
border-left: 1px solid var(--border-strong);
padding: 4px 0;
overflow-y: auto;
order: 1;
align-self: flex-start;
position: sticky;
top: 0;
max-height: 100vh;
}
/* Group labels */
.admin-sidebar-group { margin-bottom: 2px; }
.admin-sidebar-group-label {
font-family: var(--font-display);
font-size: 11px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 8px 16px 10px;
letter-spacing: 0.12em;
color: var(--fg-dim);
padding: 12px 16px 4px;
}
.admin-sidebar-group:first-child .admin-sidebar-group-label {
padding-top: 4px;
}
/* Nav items */
.admin-nav {
display: block;
width: 100%;
background: none;
border: none;
border-right: 2px solid transparent;
color: var(--fg-dim);
font-family: var(--font-display);
font-size: 12px;
font-weight: 500;
padding: 6px 14px 6px 16px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
text-align: left;
transition: color 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-tab:hover { color: var(--fg); }
.admin-tab.active {
.admin-nav:hover { color: var(--fg); background: var(--bg-highlight); border-right-color: var(--border-strong); }
.admin-nav.active {
color: var(--accent);
border-bottom-color: var(--accent);
border-right-color: var(--accent);
background: var(--accent-dim);
}
.admin-tab:focus-visible {
.admin-nav:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
border-radius: var(--radius-sm);
}
/* Content area */
.admin-content {
flex: 1;
min-width: 0;
padding-right: 20px;
}
/* Mobile backdrop */
.admin-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 499;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.admin-sidebar-backdrop.visible {
opacity: 1;
pointer-events: auto;
}
/* Mobile menu toggle — visible only on mobile, lives in toolbars */
.admin-mobile-toggle {
display: none;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--fg-dim);
width: 28px;
height: 28px;
cursor: pointer;
align-items: center;
justify-content: center;
margin-right: 8px;
flex-shrink: 0;
padding: 0;
}
.admin-mobile-toggle::before {
content: '';
display: block;
width: 14px;
height: 2px;
background: currentColor;
box-shadow: 0 4px 0 currentColor, 0 8px 0 currentColor;
}
.admin-mobile-toggle:hover { color: var(--fg); }
@media (max-width: 700px) {
.admin-mobile-toggle { display: flex; }
}
.admin-toolbar {
display: flex;
align-items: center;
@@ -900,8 +1013,30 @@
.watch-active { color: var(--green); font-weight: 500; }
.watch-completed { color: var(--accent); }
/* Wide modal variant for schedule forms */
.admin-modal-wide { width: 480px; }
/* Two-column form layout for wide modals */
.modal-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.modal-col > label:first-child,
.modal-col > .modal-col-heading + label { margin-top: 0; }
.modal-col-heading {
font-family: var(--font-display);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: 10px;
}
.modal-columns > .modal-col:first-child {
border-right: 1px solid var(--border);
padding-right: 12px;
}
.modal-columns > .modal-col:last-child {
padding-left: 12px;
}
/* Checkbox labels inside admin modals */
.admin-modal label.admin-checkbox {
@@ -930,16 +1065,32 @@
padding: 32px;
width: 380px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5), 0 0 80px -20px var(--accent-dim);
position: relative;
}
.admin-modal.admin-modal-wide { width: 820px; }
@media (max-width: 700px) {
.admin-modal.admin-modal-wide { width: auto; }
.modal-columns { grid-template-columns: 1fr; gap: 20px 0; }
.modal-columns > .modal-col:first-child {
border-right: none;
padding-right: 0;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
}
.modal-columns > .modal-col:last-child { padding-left: 0; }
}
.admin-modal::before {
content: '';
position: absolute;
top: -1px; left: 20%; right: 20%;
top: 0; left: 20%; right: 20%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
border-radius: 1px;
z-index: 1;
pointer-events: none;
}
.admin-modal h2 {
font-family: var(--font-display);
@@ -1021,7 +1172,8 @@
#create-role-overlay, #edit-role-overlay, #user-roles-overlay,
#create-policy-overlay, #edit-policy-overlay,
#create-template-overlay, #edit-template-overlay,
#create-wst-overlay, #edit-wst-overlay, #wst-history-overlay {
#create-wst-overlay, #edit-wst-overlay, #wst-history-overlay,
#memory-detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
@@ -1030,7 +1182,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
z-index: 600;
}
/* Token display (show-once) */
@@ -1076,13 +1228,27 @@
}
/* ==========================================================================
Admin tabs horizontal scroll for 10+ tabs
Admin sidebar mobile off-canvas drawer
========================================================================== */
.admin-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
scrollbar-width: thin;
@media (max-width: 700px) {
.admin-sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: auto;
width: 220px;
z-index: 500;
background: var(--bg-surface);
border-left: 1px solid var(--border-strong);
border-right: none;
transform: translateX(100%);
transition: transform 0.25s ease;
padding-top: 48px;
}
.admin-sidebar.open { transform: translateX(0); }
.admin-sidebar.collapsed { transform: translateX(100%); width: 220px; }
.admin-content { padding-right: 0; }
}
/* ==========================================================================
@@ -1165,6 +1331,91 @@
.audit-danger { color: var(--red); border-color: var(--red-glow); }
.audit-success { color: var(--green); border-color: var(--green-glow); }
/* ==========================================================================
Memories tab
========================================================================== */
#admin-memories .admin-colheaders,
#admin-memories .admin-row {
grid-template-columns: 1.5fr 80px 110px 1fr 100px 80px;
}
/* Filter toolbar */
.admin-toolbar-filters {
display: flex;
gap: 8px;
margin-left: auto;
align-items: center;
}
.admin-toolbar-filters select,
.admin-toolbar-filters input[type="search"] {
border: 1px solid var(--border);
border-radius: 3px;
padding: 4px 8px;
font: inherit;
font-size: 12px;
}
.admin-toolbar-filters select {
min-width: 0;
}
.admin-toolbar-filters input[type="search"] {
width: 180px;
background: var(--bg);
color: var(--fg);
}
.admin-toolbar-filters input[type="search"]::placeholder {
color: var(--fg-dim);
}
/* Memory type badges */
.mem-type-project { background: var(--bg-highlight); color: var(--cyan); border-color: var(--cyan-glow, var(--border)); }
.mem-type-user { background: var(--bg-highlight); color: var(--green); border-color: var(--green-glow, var(--border)); }
.mem-type-feedback { background: var(--bg-highlight); color: var(--yellow); border-color: var(--yellow-glow, var(--border)); }
.mem-type-reference { background: var(--bg-highlight); color: var(--magenta); border-color: var(--magenta-glow, var(--border)); }
/* Memory scope badges */
.mem-scope-global { background: var(--bg-highlight); color: var(--fg-dim); }
.mem-scope-workstream { background: var(--bg-highlight); color: var(--cyan); border-color: var(--cyan-glow, var(--border)); }
.mem-scope-user { background: var(--bg-highlight); color: var(--green); border-color: var(--green-glow, var(--border)); }
/* Memory detail modal */
.mem-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px 16px;
}
.mem-detail-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.mem-detail-label {
font-size: 10px;
font-family: var(--font-display);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
}
.mem-detail-desc {
color: var(--fg);
font-size: 13px;
padding: 4px 0;
}
.memory-content-block {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
font-size: 13px;
color: var(--fg);
margin: 4px 0 0;
}
/* ==========================================================================
Governance: Usage dashboard
========================================================================== */
@@ -1334,11 +1585,272 @@
grid-template-columns: 60px 1fr 100px;
}
.admin-col-auser, .admin-col-adetail { display: none; }
#admin-memories .admin-colheaders, #admin-memories .admin-row {
grid-template-columns: 1fr 70px 90px 80px;
}
.admin-col-mdesc, .admin-col-mupdated { display: none; }
.admin-toolbar-filters { flex-wrap: wrap; }
.admin-toolbar-filters input[type="search"] { width: 120px; }
.mem-detail-grid { grid-template-columns: 1fr 1fr; }
.usage-readout-value { font-size: 18px; }
.usage-bar-row { grid-template-columns: 70px 1fr 50px; }
.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
========================================================================== */
@@ -1349,7 +1861,11 @@
.node-link, .dash-cell-node, .pagination button { transition: none; }
.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-tab, .admin-row, .admin-btn-danger, .admin-btn-action { 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; }
.admin-modal input, .admin-modal select { transition: none; }
}
+5 -1
View File
@@ -163,10 +163,11 @@ WRITE_PATHS: frozenset[str] = frozenset(
"/api/workstreams/new",
"/api/workstreams/close",
"/api/cluster/workstreams/new",
"/api/memories",
}
)
APPROVE_PATHS: frozenset[str] = frozenset({"/api/approve"})
APPROVE_PATHS: frozenset[str] = frozenset({"/api/approve", "/api/_internal/config-reload"})
ADMIN_PREFIX = "/api/admin/"
@@ -465,6 +466,9 @@ def required_scope(method: str, path: str) -> str:
and normalized.endswith("/cancel")
):
return "write"
# Memory delete: /api/memories/{name}
if method == "DELETE" and normalized.startswith("/api/memories/"):
return "write"
# Console proxy routes: /node/{node_id}/api/{tail} or /node/{node_id}/v1/api/{tail}
if method == "POST" and normalized.startswith("/node/"):
+62
View File
@@ -0,0 +1,62 @@
"""BM25 index — lightweight, pure-Python, zero external deps.
Extracted from tool_search.py for reuse by memory relevance scoring.
"""
from __future__ import annotations
import math
import re
from collections import Counter
_SPLIT_RE = re.compile(r"[_\-./\s]+")
def _tokenize(text: str) -> list[str]:
"""Split text on whitespace, underscores, hyphens, dots."""
return [t.lower() for t in _SPLIT_RE.split(text) if t]
class BM25Index:
"""Okapi BM25 ranking index over short text documents."""
def __init__(self, documents: list[str], *, k1: float = 1.5, b: float = 0.75) -> None:
self.k1 = k1
self.b = b
self._docs = documents
self._doc_tokens: list[list[str]] = [_tokenize(d) for d in documents]
self._doc_lens = [len(t) for t in self._doc_tokens]
self._avgdl = sum(self._doc_lens) / max(len(self._doc_lens), 1)
self._n = len(documents)
# Document frequency per term
self._df: Counter[str] = Counter()
for tokens in self._doc_tokens:
for term in set(tokens):
self._df[term] += 1
def search(self, query: str, k: int = 5) -> list[int]:
"""Return indices of top-k documents sorted by descending BM25 score."""
q_tokens = _tokenize(query)
if not q_tokens:
return []
scores: list[tuple[float, int]] = []
for idx, doc_tokens in enumerate(self._doc_tokens):
score = self._score(q_tokens, doc_tokens, self._doc_lens[idx])
if score > 0:
scores.append((score, idx))
scores.sort(key=lambda x: (-x[0], x[1]))
return [idx for _, idx in scores[:k]]
def _score(self, q_tokens: list[str], doc_tokens: list[str], dl: int) -> float:
tf_map: Counter[str] = Counter(doc_tokens)
score = 0.0
for term in q_tokens:
if term not in tf_map:
continue
tf = tf_map[term]
df = self._df.get(term, 0)
idf = math.log((self._n - df + 0.5) / (df + 0.5) + 1.0)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avgdl)
score += idf * numerator / denominator
return score
+35
View File
@@ -136,6 +136,13 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
"timeout": "judge_timeout",
"read_only_tools": "judge_read_only_tools",
},
"memory": {
"relevance_k": "memory_relevance_k",
"fetch_limit": "memory_fetch_limit",
"max_content": "memory_max_content",
"nudge_cooldown": "memory_nudge_cooldown",
"nudges": "memory_nudges",
},
}
# -- Tavily API key (cached) --------------------------------------------------
@@ -193,3 +200,31 @@ def apply_config(parser: argparse.ArgumentParser, sections: list[str]) -> None:
defaults[argparse_dest] = section_data[config_key]
if defaults:
parser.set_defaults(**defaults)
def warn_migrated_settings() -> None:
"""Log warnings for config.toml keys that are now managed by ConfigStore.
Called after storage is initialized so the warning can direct users
to the Settings API. Only relevant for server/console entry points
that use ConfigStore the CLI still reads config.toml directly.
"""
from turnstone.core.settings_registry import SETTINGS
cfg = load_config()
if not cfg:
return
for key, defn in SETTINGS.items():
section = defn.section
config_key = key.split(".", 1)[1]
section_data = cfg.get(section, {})
if isinstance(section_data, dict) and config_key in section_data:
log.warning(
"config.toml [%s] %s is now managed via Settings API — "
"this value will be ignored. Use the admin Settings tab "
"or PUT /v1/api/admin/settings/%s to configure.",
section,
config_key,
key,
)
+137
View File
@@ -0,0 +1,137 @@
"""Database-backed configuration store with in-memory caching.
Provides a unified ``get()`` API for runtime config access. Settings
are loaded from the ``system_settings`` table on init and cached in
memory. Call ``reload()`` to refresh from storage (e.g. on MQ
invalidation event).
Precedence chain for **server** entry point:
CLI flag > ConfigStore (this) > registry default
The server's ``apply_config()`` no longer loads ConfigStore-managed
sections from config.toml, so there is no precedence conflict.
config.toml values for these sections are ignored (with a warning).
The **CLI** entry point still reads config.toml directly (no
ConfigStore) it is a standalone tool, not a cluster node.
"""
from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING, Any
from turnstone.core.settings_registry import (
SETTINGS,
deserialize_value,
serialize_value,
validate_key,
validate_value,
)
if TYPE_CHECKING:
from turnstone.core.storage._protocol import StorageBackend
log = logging.getLogger(__name__)
_UNSET: Any = object()
class ConfigStore:
"""Runtime config accessor with database-backed storage.
Thread-safe. Reads are lock-free after initialization (dict
lookup on an immutable snapshot). Writes acquire a lock,
update storage, and swap the cache atomically.
"""
def __init__(self, storage: StorageBackend, node_id: str = "") -> None:
self._storage = storage
self._node_id = node_id
self._cache: dict[str, Any] = {}
self._lock = threading.Lock()
self._version = 0
self.reload()
@property
def version(self) -> int:
"""Monotonic counter incremented on every cache update."""
return self._version
def reload(self) -> None:
"""Load all settings from storage into the in-memory cache."""
try:
raw = self._storage.get_system_settings_bulk(node_id=self._node_id)
except Exception:
log.warning("Failed to load settings from storage", exc_info=True)
return
new_cache: dict[str, Any] = {}
for key, json_val in raw.items():
try:
new_cache[key] = deserialize_value(key, json_val)
except (ValueError, KeyError):
log.warning("Skipping invalid setting: %s", key)
with self._lock:
self._cache = new_cache
self._version += 1
def get(self, key: str, default: Any = _UNSET) -> Any:
"""Get a setting value from cache.
Returns the stored value if present, otherwise the registry
default. If *default* is provided, it takes precedence over
the registry default for unknown keys.
"""
cache = self._cache # snapshot for lock-free read
if key in cache:
return cache[key]
if default is not _UNSET:
return default
defn = SETTINGS.get(key)
return defn.default if defn else None
def set(self, key: str, value: Any, changed_by: str = "") -> Any:
"""Write a setting to storage and update cache.
Returns the typed value after validation.
"""
defn = validate_key(key)
typed_value = validate_value(key, value)
self._storage.upsert_system_setting(
key=key,
value=serialize_value(typed_value),
node_id=self._node_id,
is_secret=defn.is_secret,
changed_by=changed_by,
)
with self._lock:
self._cache = {**self._cache, key: typed_value}
self._version += 1
return typed_value
def delete(self, key: str) -> bool:
"""Remove a setting from storage (reverts to default)."""
validate_key(key) # reject unknown keys
result = self._storage.delete_system_setting(key, node_id=self._node_id)
with self._lock:
new_cache = dict(self._cache)
new_cache.pop(key, None)
self._cache = new_cache
self._version += 1
return result
def all_effective(self) -> dict[str, Any]:
"""Return all settings with their effective values.
Merges stored values with registry defaults.
"""
cache = self._cache
result: dict[str, Any] = {}
for key, defn in SETTINGS.items():
result[key] = cache.get(key, defn.default)
return result
def stored_keys(self) -> frozenset[str]:
"""Return the keys that have explicit values in storage."""
return frozenset(self._cache.keys())
+105 -35
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any
import sqlalchemy as sa
from turnstone.core.storage import get_storage
if TYPE_CHECKING:
@@ -220,41 +222,6 @@ def update_workstream_title(ws_id: str, title: str) -> None:
get_storage().update_workstream_title(ws_id, title)
# -- Key-value store (memories) ------------------------------------------------
def save_memory(key: str, value: str) -> str | None:
"""Save a memory. Returns the previous value if it existed."""
try:
return get_storage().kv_set(key, value)
except Exception:
return None
def delete_memory(key: str) -> bool:
"""Delete a memory by key. Returns True if the key existed."""
try:
return get_storage().kv_delete(key)
except Exception:
return False
def load_memories() -> list[tuple[str, str]]:
"""Return all (key, value) memory pairs sorted by key."""
try:
return get_storage().kv_list()
except Exception:
return []
def search_memories(query: str) -> list[tuple[str, str]]:
"""Search memories by query. Returns matching (key, value) pairs."""
try:
return get_storage().kv_search(query)
except Exception:
return []
# -- Conversation search -------------------------------------------------------
@@ -272,3 +239,106 @@ def search_history_recent(limit: int = 20) -> list[Any]:
return get_storage().search_history_recent(limit)
except Exception:
return []
# -- Structured memories -------------------------------------------------------
def save_structured_memory(
name: str,
content: str,
description: str = "",
mem_type: str = "project",
scope: str = "global",
scope_id: str = "",
) -> tuple[str, str | None]:
"""Save a structured memory (upsert by name+scope+scope_id).
Returns (memory_id, old_content_or_None). Uses create-first to
avoid TOCTOU races under concurrent access.
"""
import uuid
name = normalize_key(name)
try:
storage = get_storage()
# Try create first — if it hits the unique constraint, fall back to update
memory_id = str(uuid.uuid4())
try:
storage.create_structured_memory(
memory_id, name, description, mem_type, scope, scope_id, content
)
return memory_id, None
except sa.exc.IntegrityError:
# Unique constraint violation — row already exists, update it
existing = storage.get_structured_memory_by_name(name, scope, scope_id)
if existing:
old_content = existing["content"]
updates: dict[str, str] = {"content": content}
if description:
updates["description"] = description
if mem_type != "project":
updates["type"] = mem_type
storage.update_structured_memory(existing["memory_id"], **updates)
return existing["memory_id"], old_content
return "", None
except Exception:
return "", None
def delete_structured_memory(name: str, scope: str = "global", scope_id: str = "") -> bool:
"""Delete a structured memory by name+scope. Returns True if existed."""
name = normalize_key(name)
try:
return get_storage().delete_structured_memory(name, scope, scope_id)
except Exception:
return False
def delete_structured_memory_by_id(memory_id: str) -> bool:
"""Delete a structured memory by its primary key. Returns True if existed."""
try:
return get_storage().delete_structured_memory_by_id(memory_id)
except Exception:
return False
def list_structured_memories(
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""List structured memories with optional filters."""
try:
return get_storage().list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
except Exception:
return []
def search_structured_memories(
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""Search structured memories by query."""
try:
return get_storage().search_structured_memories(
query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
except Exception:
return []
def count_structured_memories(mem_type: str = "", scope: str = "", scope_id: str = "") -> int:
"""Count structured memories with optional type/scope filter."""
try:
return get_storage().count_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id
)
except Exception:
return 0
+97
View File
@@ -0,0 +1,97 @@
"""BM25-based memory relevance scoring and system message formatting."""
from __future__ import annotations
from dataclasses import dataclass
from html import escape as _html_escape
from typing import Any
from turnstone.core.bm25 import BM25Index
@dataclass
class MemoryConfig:
"""Configuration for the structured memory system."""
relevance_k: int = 5
fetch_limit: int = 50
max_content: int = 32768
nudge_cooldown: int = 300
nudges: bool = True
def score_memories(
memories: list[dict[str, str]],
query: str,
k: int = 5,
) -> list[dict[str, str]]:
"""Return the top-k memories most relevant to *query*.
Builds a BM25 index over ``name + description + content prefix``
for each memory and returns matches sorted by relevance. If *query*
is empty, returns the most recent *k* memories (they are already
ordered by ``updated DESC`` from storage).
"""
if not memories:
return []
if not query or not query.strip():
return memories[:k]
documents = [
f"{m.get('name', '')} {m.get('description', '')} {m.get('content', '')[:200]}"
for m in memories
]
index = BM25Index(documents)
top_indices = index.search(query, k)
return [memories[i] for i in top_indices]
def build_memory_context(memories: list[dict[str, str]]) -> str:
"""Format selected memories as an XML block for system message injection.
Produces a compact ``<memories>`` section matching the style used
for MCP resources (``<mcp-resources>``).
"""
if not memories:
return ""
lines = ["<memories>"]
for m in memories:
name = _html_escape(m.get("name", ""))
mem_type = _html_escape(m.get("type", "project"))
scope = _html_escape(m.get("scope", "global"))
desc = m.get("description", "")
content = m.get("content", "")
# Truncate content to avoid bloating system message
if len(content) > 500:
content = content[:500] + "..."
desc_attr = f' description="{_html_escape(desc)}"' if desc else ""
lines.append(
f' <memory name="{name}" type="{mem_type}" scope="{scope}"{desc_attr}>'
f"{_html_escape(content)}</memory>"
)
lines.append("</memories>")
return "\n".join(lines)
def extract_recent_context(messages: list[dict[str, Any]], max_messages: int = 3) -> str:
"""Extract text from the last N user messages for relevance scoring.
Handles both string and list content formats.
"""
user_texts: list[str] = []
for msg in reversed(messages):
if msg.get("role") != "user":
continue
content = msg.get("content", "")
if isinstance(content, str):
user_texts.append(content)
elif isinstance(content, list):
# Multi-part content (text + images)
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
user_texts.append(part.get("text", ""))
elif isinstance(part, str):
user_texts.append(part)
if len(user_texts) >= max_messages:
break
return " ".join(user_texts)
+131
View File
@@ -0,0 +1,131 @@
"""Metacognitive prompting — situational nudges for proactive memory use."""
from __future__ import annotations
import re
import time
_COOLDOWN_SECS = 300 # 5 minutes between nudges of the same type
# ---------------------------------------------------------------------------
# Nudge messages (brief, model-facing hints)
# ---------------------------------------------------------------------------
NUDGE_CORRECTION = (
"Note: The user's message may contain a correction or preference. "
"Pay close attention — if they explain what went wrong or how they'd "
"prefer you to work, consider saving that as a feedback memory "
"(memory action='save', type='feedback') so you don't repeat this."
)
NUDGE_DENIAL = (
"Note: The user just rejected a tool action. Their feedback may "
"explain why — pay attention to whether this reflects a persistent "
"preference (e.g. 'never use force-push', 'don't modify that file'). "
"If so, save it as a feedback memory for future sessions."
)
NUDGE_RESUME = (
"This workstream has prior conversation history. Before proceeding, "
"use memory(action='search') to check for relevant context — there "
"may be saved preferences, project notes, or prior decisions that "
"apply to this work."
)
NUDGE_COMPLETION = (
"The task may be wrapping up. Consider whether there are learnings, "
"decisions, or user preferences from this session worth persisting "
"as memories (memory action='save') so future sessions can benefit."
)
NUDGE_START = (
"You have saved memories from prior sessions that may be relevant. "
"Consider using memory(action='search') with keywords from the "
"user's request to find applicable context, preferences, or guidance."
)
_NUDGE_MAP: dict[str, str] = {
"correction": NUDGE_CORRECTION,
"denial": NUDGE_DENIAL,
"resume": NUDGE_RESUME,
"completion": NUDGE_COMPLETION,
"start": NUDGE_START,
}
# ---------------------------------------------------------------------------
# Detection heuristics
# ---------------------------------------------------------------------------
_CORRECTION_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"(?i)^no[,.\s]"),
re.compile(r"(?i)\bdon'?t\b"),
re.compile(r"(?i)^stop\b"),
re.compile(r"(?i)^actually[,\s]"),
re.compile(r"(?i)^instead[,\s]"),
re.compile(r"(?i)\bnot like that\b"),
re.compile(r"(?i)^wrong\b"),
re.compile(r"(?i)\bthat'?s not\b"),
re.compile(r"(?i)^I said\b"),
re.compile(r"(?i)^I meant\b"),
re.compile(r"(?i)\bnever\b.*\balways\b"),
re.compile(r"(?i)^please don'?t\b"),
]
_COMPLETION_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"(?i)^thanks\b"),
re.compile(r"(?i)\bthat'?s all\b"),
re.compile(r"(?i)\blooks good\b"),
re.compile(r"(?i)^perfect\b"),
re.compile(r"(?i)^great job\b"),
re.compile(r"(?i)\bthat works\b"),
re.compile(r"(?i)^done\b"),
re.compile(r"(?i)^lgtm\b"),
]
def detect_correction(message: str) -> bool:
"""Return True if the message looks like a user correction."""
if not message:
return False
return any(p.search(message) for p in _CORRECTION_PATTERNS)
def detect_completion(message: str) -> bool:
"""Return True if the message signals session completion."""
if not message:
return False
return any(p.search(message) for p in _COMPLETION_PATTERNS)
def should_nudge(
nudge_type: str,
state: dict[str, float],
*,
message_count: int = 0,
memory_count: int = 0,
cooldown_secs: int = _COOLDOWN_SECS,
) -> bool:
"""Check whether a nudge should fire, respecting cooldowns and context."""
if nudge_type not in _NUDGE_MAP:
return False
# Don't nudge on the very first message (except resume/start)
if message_count <= 1 and nudge_type not in ("resume", "start"):
return False
# Start nudge only on first message
if nudge_type == "start" and message_count != 1:
return False
# Resume/start nudge only if there are memories to recall
if nudge_type in ("resume", "start") and memory_count == 0:
return False
# Rate limit: one nudge per type per cooldown window
now = time.monotonic()
last = state.get(nudge_type)
if last is not None and now - last < cooldown_secs:
return False
state[nudge_type] = now
return True
def format_nudge(nudge_type: str) -> str:
"""Return the nudge text for the given type."""
return _NUDGE_MAP.get(nudge_type, "")
+2 -2
View File
@@ -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``.
+396 -105
View File
@@ -33,26 +33,39 @@ from turnstone.core.config import get_tavily_key
from turnstone.core.edit import find_occurrences, pick_nearest
from turnstone.core.log import get_logger
from turnstone.core.memory import (
delete_memory,
count_structured_memories,
delete_structured_memory,
delete_workstream,
get_prompt_template_by_name,
get_workstream_display_name,
list_default_templates,
list_structured_memories,
list_workstreams_with_history,
load_memories,
load_messages,
load_workstream_config,
normalize_key,
resolve_workstream,
save_memory,
save_message,
save_structured_memory,
save_workstream_config,
search_history,
search_history_recent,
search_memories,
search_structured_memories,
set_workstream_alias,
update_workstream_title,
)
from turnstone.core.memory_relevance import (
MemoryConfig,
build_memory_context,
extract_recent_context,
score_memories,
)
from turnstone.core.metacognition import (
detect_completion,
detect_correction,
format_nudge,
should_nudge,
)
from turnstone.core.providers import create_provider
from turnstone.core.safety import is_command_blocked, sanitize_command
from turnstone.core.sandbox import execute_math_sandboxed
@@ -206,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,
@@ -222,6 +235,8 @@ class ChatSession:
tool_search_max_results: int = 5,
template: str | None = None,
judge_config: JudgeConfig | None = None,
user_id: str = "",
memory_config: MemoryConfig | None = None,
):
self.client = client
self.model = model
@@ -240,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
@@ -255,6 +270,8 @@ class ChatSession:
self.debug = False
self.auto_approve = False
self._node_id = node_id
self._user_id = user_id
self._memory_config = memory_config or MemoryConfig()
self._ws_id = ws_id or uuid.uuid4().hex
self._title_generated = False
self._read_files: set[str] = set()
@@ -277,6 +294,9 @@ class ChatSession:
self._watch_runner: Any = None # WatchRunner | None
self._watch_pending: queue.Queue[dict[str, Any]] = queue.Queue()
self._watch_dispatch_depth = 0
# Metacognitive nudges: ephemeral prompts for proactive memory use
self._metacog_state: dict[str, float] = {}
self._pending_nudge: str | None = None
# Cooperative cancellation: set from outside to stop generation
self._cancel_event = threading.Event()
self._cancelled_partial_msg: dict[str, Any] | None = None
@@ -639,7 +659,15 @@ class ChatSession:
self._template_name = None
if "notify_on_complete" in config:
self._notify_on_complete = config["notify_on_complete"]
self._init_system_messages()
if self._memory_config.nudges and should_nudge(
"resume",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
cooldown_secs=self._memory_config.nudge_cooldown,
):
self._pending_nudge = format_nudge("resume")
self._init_system_messages()
return True
def _init_system_messages(self) -> None:
@@ -766,13 +794,22 @@ class ChatSession:
if self.instructions:
dev_parts.append("")
dev_parts.append(self.instructions)
memories = load_memories()
if memories:
visible_mems = self._get_visible_memories(limit=self._memory_config.fetch_limit)
if visible_mems:
context = extract_recent_context(self.messages)
relevant = score_memories(visible_mems, context, k=self._memory_config.relevance_k)
if relevant:
dev_parts.append("")
dev_parts.append(build_memory_context(relevant))
dev_parts.append("")
dev_parts.append(
f"REMINDER: You currently have {len(memories)} memories stored. "
"Use recall to see them."
f"You have {len(visible_mems)} memories in scope. "
"Use memory(action='search') or memory(action='list') for more."
)
if self._pending_nudge:
dev_parts.append("")
dev_parts.append(self._pending_nudge)
self._pending_nudge = None
new_system_messages.append({"role": "system", "content": "\n".join(dev_parts)})
# Atomic swap — readers see either old or new, never partial
self.system_messages = new_system_messages
@@ -957,6 +994,12 @@ class ChatSession:
self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token)))
save_message(self._ws_id, "user", user_input)
# Metacognitive nudge: check for correction/completion signals
nudge = self._check_metacognitive_nudge(user_input)
if nudge:
self._pending_nudge = nudge
self._init_system_messages()
try:
while True:
self._check_cancelled()
@@ -996,8 +1039,7 @@ class ChatSession:
filtered_tc = [
call
for call in tc
if call.get("function", {}).get("name", "")
not in ("remember", "forget", "recall")
if call.get("function", {}).get("name", "") not in ("memory", "recall")
]
if filtered_tc:
tool_calls_json = json.dumps(filtered_tc)
@@ -1066,8 +1108,7 @@ class ChatSession:
# Log tool result (skip memory tools to avoid noise)
_tname = _tc_names.get(tc_id, "")
if _tname not in (
"remember",
"forget",
"memory",
"recall",
):
# For image content, store text description only
@@ -1626,7 +1667,11 @@ class ChatSession:
" - **## Open tasks**: What the user asked for that is not yet done, "
"with enough context to continue.\n"
" - **## User preferences**: Workflow preferences, constraints, or "
"instructions the user stated.\n\n"
"instructions the user stated.\n"
" - **## Memories to save**: Corrections, preferences, or learnings "
"the user expressed that should be persisted across sessions. "
"Format each as: `name: description — content`. "
"Only include items the user explicitly stated, not inferences.\n\n"
"2. **Density rules:**\n"
" - Every token should carry information.\n"
" - Preserve exact paths, identifiers, and numbers — never paraphrase these.\n"
@@ -1821,6 +1866,15 @@ class ChatSession:
f"Denied by user: {user_feedback}" if user_feedback else "Denied by user"
)
user_feedback = None # feedback is in the denial_msg
if self._memory_config.nudges and should_nudge(
"denial",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
cooldown_secs=self._memory_config.nudge_cooldown,
):
self._pending_nudge = format_nudge("denial")
self._init_system_messages()
# Phase 3: execute (check cancellation before starting)
self._check_cancelled()
@@ -1972,9 +2026,8 @@ class ChatSession:
"tool_search": self._prepare_tool_search,
"task": self._prepare_task,
"create_plan": self._prepare_plan,
"remember": self._prepare_remember,
"memory": self._prepare_memory,
"recall": self._prepare_recall,
"forget": self._prepare_forget,
"notify": self._prepare_notify,
"watch": self._prepare_watch,
"read_resource": self._prepare_read_resource,
@@ -2545,55 +2598,246 @@ class ChatSession:
"prompt": goal,
}
def _prepare_remember(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a remember (save memory) action."""
key = normalize_key((args.get("key") or "").strip())
value = (args.get("value") or "").strip()
if not key or not value:
return {
"call_id": call_id,
"func_name": "remember",
"header": "\u2717 remember: requires key and value",
"preview": "",
"needs_approval": False,
"error": "Error: both 'key' and 'value' are required",
}
return {
"call_id": call_id,
"func_name": "remember",
"header": f"\u2699 remember: {key}",
"preview": "",
"needs_approval": False,
"execute": self._exec_remember,
"key": key,
"value": value,
}
def _resolve_scope_id(self, scope: str) -> str:
"""Map a scope name to its scope_id."""
if scope == "workstream":
return self._ws_id
if scope == "user":
return self._user_id
return ""
def _prepare_forget(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a forget (delete memory) action."""
key = normalize_key((args.get("key") or "").strip())
if not key:
def _validate_scope(self, scope: str, call_id: str) -> dict[str, Any] | None:
"""Return an error dict if scope is invalid, None if OK."""
if scope == "user" and not self._user_id:
return {
"call_id": call_id,
"func_name": "forget",
"header": "\u2717 forget: empty key",
"func_name": "memory",
"header": "\u2717 memory: user scope requires authentication",
"preview": "",
"needs_approval": False,
"error": "Error: key is required",
"error": "Error: 'user' scope requires authenticated user identity",
}
return None
def _get_visible_memories(self, limit: int = 50) -> list[dict[str, str]]:
"""Return memories visible to this session (scope-filtered)."""
global_mems = list_structured_memories(scope="global", limit=limit)
ws_mems = list_structured_memories(scope="workstream", scope_id=self._ws_id, limit=limit)
user_mems: list[dict[str, str]] = []
if self._user_id:
user_mems = list_structured_memories(scope="user", scope_id=self._user_id, limit=limit)
combined = global_mems + ws_mems + user_mems
combined.sort(key=lambda m: m.get("updated", ""), reverse=True)
return combined[:limit]
def _visible_memory_count(self) -> int:
"""Count memories visible to this session (cheap — counts only)."""
n = count_structured_memories(scope="global")
n += count_structured_memories(scope="workstream", scope_id=self._ws_id)
if self._user_id:
n += count_structured_memories(scope="user", scope_id=self._user_id)
return n
def _check_metacognitive_nudge(self, user_message: str) -> str | None:
"""Check if a metacognitive nudge should be injected."""
if not self._memory_config.nudges:
return None
mem_count = self._visible_memory_count()
msg_count = len(self.messages)
cd = self._memory_config.nudge_cooldown
if should_nudge(
"start",
self._metacog_state,
message_count=msg_count,
memory_count=mem_count,
cooldown_secs=cd,
):
return format_nudge("start")
if detect_correction(user_message) and should_nudge(
"correction",
self._metacog_state,
message_count=msg_count,
memory_count=mem_count,
cooldown_secs=cd,
):
return format_nudge("correction")
if detect_completion(user_message) and should_nudge(
"completion",
self._metacog_state,
message_count=msg_count,
memory_count=mem_count,
cooldown_secs=cd,
):
return format_nudge("completion")
return None
def _prepare_memory(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a memory tool action (save/search/delete/list)."""
action = (args.get("action") or "").strip().lower()
if action == "save":
name = (args.get("name") or args.get("key") or "").strip()
content = (args.get("content") or args.get("value") or "").strip()
name = normalize_key(name)
if not name or not content:
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2717 memory save: requires name and content",
"preview": "",
"needs_approval": False,
"error": "Error: both 'name' and 'content' are required for save",
}
if len(content) > self._memory_config.max_content:
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2717 memory save: content too large",
"preview": "",
"needs_approval": False,
"error": f"Error: content exceeds {self._memory_config.max_content} character limit",
}
description = (args.get("description") or "").strip()
mem_type = (args.get("type") or "project").strip().lower()
if mem_type not in ("user", "project", "feedback", "reference"):
mem_type = "project"
scope = (args.get("scope") or "global").strip().lower()
if scope not in ("global", "workstream", "user"):
scope = "global"
scope_err = self._validate_scope(scope, call_id)
if scope_err:
return scope_err
scope_id = self._resolve_scope_id(scope)
return {
"call_id": call_id,
"func_name": "memory",
"header": f"\u2699 memory save: {name}",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "save",
"name": name,
"content": content,
"description": description,
"mem_type": mem_type,
"scope": scope,
"scope_id": scope_id,
}
if action == "delete":
name = normalize_key((args.get("name") or args.get("key") or "").strip())
if not name:
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2717 memory delete: empty name",
"preview": "",
"needs_approval": False,
"error": "Error: name is required for delete",
}
scope = (args.get("scope") or "global").strip().lower()
if scope not in ("global", "workstream", "user"):
scope = "global"
scope_err = self._validate_scope(scope, call_id)
if scope_err:
return scope_err
scope_id = self._resolve_scope_id(scope)
return {
"call_id": call_id,
"func_name": "memory",
"header": f"\u2699 memory delete: {name}",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "delete",
"name": name,
"scope": scope,
"scope_id": scope_id,
}
if action == "search":
query = (args.get("query") or "").strip()
mem_type = (args.get("type") or "").strip().lower()
if mem_type and mem_type not in ("user", "project", "feedback", "reference"):
mem_type = ""
scope = (args.get("scope") or "").strip().lower()
if scope and scope not in ("global", "workstream", "user"):
scope = ""
scope_id = self._resolve_scope_id(scope) if scope else ""
limit = args.get("limit", 20)
if isinstance(limit, str):
try:
limit = int(limit)
except ValueError:
limit = 20
return {
"call_id": call_id,
"func_name": "memory",
"header": f"\u2699 memory search{': ' + query[:80] if query else ''}",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "search",
"query": query,
"mem_type": mem_type,
"scope": scope,
"scope_id": scope_id,
"limit": max(1, min(limit, 50)),
}
if action == "list":
mem_type = (args.get("type") or "").strip().lower()
if mem_type and mem_type not in ("user", "project", "feedback", "reference"):
mem_type = ""
scope = (args.get("scope") or "").strip().lower()
if scope and scope not in ("global", "workstream", "user"):
scope = ""
scope_id = self._resolve_scope_id(scope) if scope else ""
limit = args.get("limit", 20)
if isinstance(limit, str):
try:
limit = int(limit)
except ValueError:
limit = 20
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2699 memory list",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "list",
"mem_type": mem_type,
"scope": scope,
"scope_id": scope_id,
"limit": max(1, min(limit, 50)),
}
return {
"call_id": call_id,
"func_name": "forget",
"header": f"\u2699 forget: {key}",
"func_name": "memory",
"header": "\u2717 memory: invalid action",
"preview": "",
"needs_approval": False,
"execute": self._exec_forget,
"key": key,
"error": f"Error: action must be save/search/delete/list, got '{action}'",
}
def _prepare_recall(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a recall action."""
"""Prepare a conversation history search."""
query = (args.get("query") or "").strip()
if not query:
return {
"call_id": call_id,
"func_name": "recall",
"header": "\u2717 recall: requires query",
"preview": "",
"needs_approval": False,
"error": "Error: query is required",
}
limit = args.get("limit", 20)
if isinstance(limit, str):
try:
@@ -2603,12 +2847,12 @@ class ChatSession:
return {
"call_id": call_id,
"func_name": "recall",
"header": f"\u2699 recall{': ' + query[:80] if query else ''}",
"header": f"\u2699 recall: {query[:80]}",
"preview": "",
"needs_approval": False,
"execute": self._exec_recall,
"query": query,
"limit": min(limit, 50),
"limit": max(1, min(limit, 50)),
}
# -- MCP tool prepare/execute ----------------------------------------------
@@ -3500,66 +3744,113 @@ class ChatSession:
return content
def _exec_remember(self, item: dict[str, Any]) -> tuple[str, str]:
"""Save a persistent memory."""
call_id, key, value = item["call_id"], item["key"], item["value"]
def _exec_memory(self, item: dict[str, Any]) -> tuple[str, str]:
"""Execute a memory tool action."""
call_id = item["call_id"]
action = item["action"]
try:
old_value = save_memory(key, value)
self._init_system_messages()
if old_value is not None:
msg = f"Updated memory: {key} = {value} (was: {old_value})"
else:
msg = f"Saved memory: {key} = {value}"
self.ui.on_tool_result(call_id, "remember", msg)
return call_id, msg
if action == "save":
memory_id, old = save_structured_memory(
item["name"],
item["content"],
description=item["description"],
mem_type=item["mem_type"],
scope=item["scope"],
scope_id=item["scope_id"],
)
if not memory_id:
msg = f"Error: failed to save memory '{item['name']}'"
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
self._init_system_messages()
if old is not None:
msg = f"Updated memory '{item['name']}' (type={item['mem_type']}, scope={item['scope']})"
else:
msg = f"Saved memory '{item['name']}' (type={item['mem_type']}, scope={item['scope']})"
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "delete":
deleted = delete_structured_memory(item["name"], item["scope"], item["scope_id"])
if not deleted:
msg = f"Error: memory '{item['name']}' not found (scope={item['scope']})"
else:
self._init_system_messages()
msg = f"Deleted memory '{item['name']}'"
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "search":
rows = search_structured_memories(
item["query"],
mem_type=item.get("mem_type", ""),
scope=item.get("scope", ""),
scope_id=item.get("scope_id", ""),
limit=item["limit"],
)
if rows:
lines = []
for m in rows:
desc = f"{m['description']}" if m.get("description") else ""
lines.append(
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n"
f" {m['content'][:500]}"
)
msg = f"Memories ({len(rows)} results):\n" + "\n".join(lines)
else:
msg = (
f"No memories found for '{item['query']}'."
if item["query"]
else "No memories stored."
)
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "list":
rows = list_structured_memories(
mem_type=item.get("mem_type", ""),
scope=item.get("scope", ""),
scope_id=item.get("scope_id", ""),
limit=item["limit"],
)
if rows:
lines = []
for m in rows:
desc = f"{m['description']}" if m.get("description") else ""
lines.append(
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n"
f" {m['content'][:500]}"
)
msg = f"Memories ({len(rows)}):\n" + "\n".join(lines)
else:
msg = "No memories stored."
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
except Exception as e:
return call_id, f"Error: {e}"
def _exec_forget(self, item: dict[str, Any]) -> tuple[str, str]:
"""Remove a persistent memory by key."""
call_id, key = item["call_id"], item["key"]
try:
deleted = delete_memory(key)
if not deleted:
msg = f"Error: memory '{key}' not found"
else:
self._init_system_messages()
msg = f"Forgot: {key}"
self.ui.on_tool_result(call_id, "forget", msg)
return call_id, msg
except Exception as e:
return call_id, f"Error: {e}"
return call_id, "Error: unexpected action"
def _exec_recall(self, item: dict[str, Any]) -> tuple[str, str]:
"""Search memories and conversation history."""
"""Search conversation history."""
call_id = item["call_id"]
query, limit = item["query"], item["limit"]
parts: list[str] = []
# Memories: list all (no query) or search (with query)
try:
rows = search_memories(query) if query else load_memories()
if rows:
parts.append("Memories:\n" + "\n".join(f" {k}={v}" for k, v in rows))
elif not query:
parts.append("No memories stored.")
except Exception:
pass
conv_rows = search_history(query, limit)
if conv_rows:
lines = []
for ts, sid, role, content, tool_name in conv_rows:
label = f"{role}({tool_name})" if tool_name else role
text = (content or "")[:500]
if content and len(content) > 500:
text += "..."
lines.append(f"[{ts} {sid}] {label}: {text}")
output = f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines)
else:
output = f"No conversation history found for '{query}'."
# Conversations: only when a query is provided
if query:
conv_rows = search_history(query, limit)
if conv_rows:
lines = []
for ts, sid, role, content, tool_name in conv_rows:
label = f"{role}({tool_name})" if tool_name else role
text = (content or "")[:500]
if content and len(content) > 500:
text += "..."
lines.append(f"[{ts} {sid}] {label}: {text}")
parts.append(f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines))
output = "\n\n".join(parts) if parts else f"No results for '{query}'."
self.ui.on_tool_result(call_id, "recall", output)
return call_id, output
+541
View File
@@ -0,0 +1,541 @@
"""Settings registry — code-defined catalog of database-storable configuration settings.
Every setting that can be stored in the ``system_settings`` table must
have an entry here. Unknown keys are rejected at the API boundary.
Bootstrap settings (database, Redis, auth, server/console bind) are
excluded they are needed before storage is available.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class SettingDef:
"""Definition of a single configuration setting."""
key: str # dotted path: "memory.relevance_k"
type: str # "int" | "float" | "str" | "bool"
default: Any
description: str
section: str # TOML section name
is_secret: bool = False
min_value: float | None = None
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 (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 (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 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 (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",
0,
"Context window size in tokens (0 = auto-detect from model)",
"model",
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 (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",
90,
"Days to retain conversation history (0 = disabled)",
"session",
min_value=0,
),
SettingDef(
"session.compact_max_tokens",
"int",
32768,
"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",
"float",
0.8,
"Auto-compact at this fraction of context window (0 = disabled)",
"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(
"tools.timeout",
"int",
120,
"Tool execution timeout in seconds",
"tools",
min_value=1,
max_value=3600,
),
SettingDef(
"tools.truncation",
"int",
0,
"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",
"int",
-1,
"Max turns for plan/task agents (-1 = unlimited)",
"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.search",
"str",
"auto",
"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 activate search in auto mode",
"tools",
min_value=1,
),
SettingDef(
"tools.search_max_results",
"int",
5,
"Max tool search results",
"tools",
min_value=1,
max_value=50,
),
# -- server ---------------------------------------------------------
SettingDef(
"server.workstream_idle_timeout",
"int",
120,
"Idle timeout for workstream eviction in minutes (0 = disabled)",
"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",
"int",
10,
"Max concurrent workstreams",
"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(
"mcp.config_path",
"str",
"",
"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",
"int",
14400,
"MCP resource/prompt refresh interval in seconds (0 = disabled, default 4h)",
"mcp",
min_value=0,
),
# -- ratelimit ------------------------------------------------------
SettingDef(
"ratelimit.enabled",
"bool",
False,
"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",
"float",
10.0,
"Max requests per second per IP",
"ratelimit",
min_value=1.0,
restart_required=True,
),
SettingDef(
"ratelimit.burst",
"int",
20,
"Burst allowance above rate limit",
"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",
"str",
"",
"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(
"health.backend_probe_interval",
"int",
30,
"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",
"int",
5,
"Backend health probe timeout in seconds",
"health",
min_value=1,
),
SettingDef(
"health.circuit_breaker_threshold",
"int",
5,
"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",
"int",
60,
"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",
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"),
SettingDef(
"judge.api_key",
"str",
"",
"API key for judge model",
"judge",
is_secret=True,
),
SettingDef(
"judge.confidence_threshold",
"float",
0.7,
"Min confidence for judge verdict",
"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",
"float",
0.5,
"Max fraction of context window for judge",
"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",
"float",
60.0,
"Judge evaluation timeout in seconds",
"judge",
min_value=5.0,
),
SettingDef(
"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 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",
"int",
50,
"Max memories fetched from storage",
"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",
"int",
32768,
"Max memory content size in characters",
"memory",
min_value=100,
max_value=65536,
),
SettingDef(
"memory.nudge_cooldown",
"int",
300,
"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.",
),
]
return {d.key: d for d in defs}
SETTINGS: dict[str, SettingDef] = _build_registry()
# Sections that are NOT in the registry (bootstrap-critical)
BOOTSTRAP_SECTIONS: frozenset[str] = frozenset(
{
"api",
"database",
"redis",
"auth",
"bridge",
"console",
}
)
def validate_key(key: str) -> SettingDef:
"""Return the SettingDef for *key*, or raise ValueError if unknown."""
defn = SETTINGS.get(key)
if defn is None:
raise ValueError(f"Unknown setting: {key}")
return defn
def validate_value(key: str, raw_value: Any) -> Any:
"""Coerce and validate *raw_value* against the setting definition.
Returns the typed value. Raises ValueError on invalid input.
"""
defn = validate_key(key)
# Type coercion
try:
if defn.type == "int":
typed: Any = int(raw_value)
elif defn.type == "float":
typed = float(raw_value)
elif defn.type == "bool":
if isinstance(raw_value, bool):
typed = raw_value
elif isinstance(raw_value, str):
low = raw_value.lower()
if low in ("true", "1", "yes"):
typed = True
elif low in ("false", "0", "no"):
typed = False
else:
raise ValueError(f"Cannot convert {raw_value!r} to bool for {key}")
else:
typed = bool(raw_value)
else: # str
typed = "" if raw_value is None else str(raw_value)
except (ValueError, TypeError) as exc:
raise ValueError(f"Cannot convert {raw_value!r} to {defn.type} for {key}") from exc
# Range validation
if typed is not None:
if (
defn.min_value is not None
and isinstance(typed, (int, float))
and typed < defn.min_value
):
raise ValueError(f"{key}: {typed} < minimum {defn.min_value}")
if (
defn.max_value is not None
and isinstance(typed, (int, float))
and typed > defn.max_value
):
raise ValueError(f"{key}: {typed} > maximum {defn.max_value}")
# Choices validation
if defn.choices is not None and typed not in defn.choices:
raise ValueError(f"{key}: {typed!r} not in {defn.choices}")
return typed
def serialize_value(value: Any) -> str:
"""JSON-encode a typed value for storage."""
return json.dumps(value)
def deserialize_value(key: str, json_str: str) -> Any:
"""JSON-decode and type-coerce against registry."""
raw = json.loads(json_str)
defn = SETTINGS.get(key)
if defn is None:
return raw # Unknown key — return raw
return validate_value(key, raw)
+279 -59
View File
@@ -14,11 +14,12 @@ from turnstone.core.storage._schema import (
audit_events,
conversations,
intent_verdicts,
memories,
metadata,
orgs,
prompt_templates,
roles,
structured_memories,
system_settings,
tool_policies,
usage_events,
user_roles,
@@ -37,6 +38,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
ROLE_MUTABLE as _ROLE_MUTABLE,
)
from turnstone.core.storage._utils import (
STRUCTURED_MEMORY_MUTABLE as _SMEM_MUTABLE,
)
from turnstone.core.storage._utils import (
TEMPLATE_MUTABLE as _TEMPLATE_MUTABLE,
)
@@ -56,6 +60,11 @@ from turnstone.core.storage._utils import (
log = logging.getLogger(__name__)
def _escape_ilike(s: str) -> str:
"""Escape ILIKE metacharacters for use with ESCAPE '\\\\'."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
class PostgreSQLBackend:
"""PostgreSQL implementation of the StorageBackend protocol."""
@@ -274,64 +283,6 @@ class PostgreSQLBackend:
)
conn.commit()
# -- Generic key-value store -----------------------------------------------
def kv_get(self, key: str) -> str | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(memories.c.value).where(memories.c.key == key)).fetchone()
return str(row[0]) if row else None
def kv_set(self, key: str, value: str) -> str | None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(memories.c.value, memories.c.created).where(memories.c.key == key)
).fetchone()
old_value = str(existing[0]) if existing else None
created = str(existing[1]) if existing else now
# Delete + insert for cross-dialect upsert
conn.execute(sa.delete(memories).where(memories.c.key == key))
conn.execute(
sa.insert(memories),
{"key": key, "value": value, "created": created, "updated": now},
)
conn.commit()
return old_value
def kv_delete(self, key: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(sa.delete(memories).where(memories.c.key == key))
conn.commit()
return result.rowcount > 0
def kv_list(self) -> list[tuple[str, str]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(memories.c.key, memories.c.value).order_by(memories.c.key)
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
def kv_search(self, query: str) -> list[tuple[str, str]]:
if not query or not query.strip():
return self.kv_list()
terms = query.split()
with self._engine.connect() as conn:
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
clauses.append(f"(key ILIKE :k{i} OR value ILIKE :v{i})")
params[f"k{i}"] = f"%{t}%"
params[f"v{i}"] = f"%{t}%"
rows = conn.execute(
sa.text(
"SELECT key, value FROM memories WHERE "
+ " AND ".join(clauses)
+ " ORDER BY key"
),
params,
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
# -- Workstream operations -------------------------------------------------
def register_workstream(
@@ -2144,6 +2095,275 @@ class PostgreSQLBackend:
row = conn.execute(q).fetchone()
return row[0] if row else 0
# -- Structured memories ---------------------------------------------------
def create_structured_memory(
self,
memory_id: str,
name: str,
description: str,
mem_type: str,
scope: str,
scope_id: str,
content: str,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(structured_memories),
{
"memory_id": memory_id,
"name": name,
"description": description,
"type": mem_type,
"scope": scope,
"scope_id": scope_id,
"content": content,
"created": now,
"updated": now,
"last_accessed": now,
"access_count": 0,
},
)
conn.commit()
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(structured_memories).where(structured_memories.c.memory_id == memory_id)
).fetchone()
return dict(row._mapping) if row else None
def get_structured_memory_by_name(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
).fetchone()
return dict(row._mapping) if row else None
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
fields = {k: v for k, v in fields.items() if k in _SMEM_MUTABLE}
if not fields:
return False
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
fields["updated"] = now
fields["last_accessed"] = now
with self._engine.connect() as conn:
result = conn.execute(
sa.update(structured_memories)
.where(structured_memories.c.memory_id == memory_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
)
conn.commit()
return result.rowcount > 0
def delete_structured_memory_by_id(self, memory_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(structured_memories).where(structured_memories.c.memory_id == memory_id)
)
conn.commit()
return result.rowcount > 0
def list_structured_memories(
self,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
with self._engine.connect() as conn:
q = sa.select(structured_memories).order_by(structured_memories.c.updated.desc())
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id:
q = q.where(structured_memories.c.scope_id == scope_id)
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [dict(r._mapping) for r in rows]
def search_structured_memories(
self,
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
if not query or not query.strip():
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
terms = query.split()
with self._engine.connect() as conn:
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_ilike(t)
clauses.append(
f"(name ILIKE :n{i} ESCAPE '\\' "
f"OR description ILIKE :d{i} ESCAPE '\\' "
f"OR content ILIKE :c{i} ESCAPE '\\')"
)
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
where = " AND ".join(clauses)
if mem_type:
where += " AND type = :type_filter"
params["type_filter"] = mem_type
if scope:
where += " AND scope = :scope_filter"
params["scope_filter"] = scope
if scope_id:
where += " AND scope_id = :scope_id_filter"
params["scope_id_filter"] = scope_id
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE {where} "
f"ORDER BY updated DESC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def count_structured_memories(
self, mem_type: str = "", scope: str = "", scope_id: str = ""
) -> int:
with self._engine.connect() as conn:
q = sa.select(sa.func.count()).select_from(structured_memories)
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id:
q = q.where(structured_memories.c.scope_id == scope_id)
result = conn.execute(q).scalar()
return int(result or 0)
# -- System settings -------------------------------------------------------
def get_system_setting(self, key: str, node_id: str = "") -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(system_settings).where(
sa.and_(
system_settings.c.key == key,
system_settings.c.node_id == node_id,
)
)
).fetchone()
return dict(row._mapping) if row else None
def list_system_settings(self, node_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(system_settings).order_by(system_settings.c.key)
if node_id:
# Return both global and node-specific
q = q.where(
sa.or_(
system_settings.c.node_id == "",
system_settings.c.node_id == node_id,
)
)
return [dict(r._mapping) for r in conn.execute(q).fetchall()]
def upsert_system_setting(
self,
key: str,
value: str,
node_id: str = "",
is_secret: bool = False,
changed_by: str = "",
) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
secret_val = 1 if is_secret else 0
stmt = pg_insert(system_settings).values(
key=key,
value=value,
node_id=node_id,
is_secret=secret_val,
changed_by=changed_by,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=["key", "node_id"],
set_={
"value": value,
"is_secret": secret_val,
"changed_by": changed_by,
"updated": now,
},
)
with self._engine.connect() as conn:
conn.execute(stmt)
conn.commit()
def delete_system_setting(self, key: str, node_id: str = "") -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(system_settings).where(
sa.and_(
system_settings.c.key == key,
system_settings.c.node_id == node_id,
)
)
)
conn.commit()
return result.rowcount > 0
def get_system_settings_bulk(self, node_id: str = "") -> dict[str, str]:
with self._engine.connect() as conn:
if not node_id:
rows = conn.execute(
sa.select(system_settings.c.key, system_settings.c.value).where(
system_settings.c.node_id == ""
)
).fetchall()
return {r.key: r.value for r in rows}
# Global + node overrides in one query; node_id sorts after ""
# so node-specific values overwrite globals in the dict
rows = conn.execute(
sa.select(system_settings.c.key, system_settings.c.value)
.where(
sa.or_(
system_settings.c.node_id == "",
system_settings.c.node_id == node_id,
)
)
.order_by(system_settings.c.node_id)
).fetchall()
return {r.key: r.value for r in rows}
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
+94 -13
View File
@@ -9,8 +9,8 @@ from typing import Any, Protocol, runtime_checkable
class StorageBackend(Protocol):
"""Protocol that every storage backend adapter must implement.
Provides workstream management, conversation persistence, key-value storage
(for memories), and full-text search.
Provides workstream management, conversation persistence, structured
memories, and full-text search.
"""
# -- Core conversation operations ------------------------------------------
@@ -71,26 +71,70 @@ class StorageBackend(Protocol):
"""Set or update the auto-generated title for a workstream."""
...
# -- Generic key-value store (backs memories table) ------------------------
# -- Structured memories ---------------------------------------------------
def kv_get(self, key: str) -> str | None:
"""Get a value by key. Returns None if not found."""
def create_structured_memory(
self,
memory_id: str,
name: str,
description: str,
mem_type: str,
scope: str,
scope_id: str,
content: str,
) -> None:
"""Create a structured memory record."""
...
def kv_set(self, key: str, value: str) -> str | None:
"""Set a key-value pair. Returns the previous value if it existed."""
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
"""Return structured memory dict or None."""
...
def kv_delete(self, key: str) -> bool:
"""Delete a key. Returns True if the key existed."""
def get_structured_memory_by_name(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
"""Lookup structured memory by (name, scope, scope_id). Returns dict or None."""
...
def kv_list(self) -> list[tuple[str, str]]:
"""Return all (key, value) pairs sorted by key."""
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
"""Update specified fields on a structured memory. Returns True if found."""
...
def kv_search(self, query: str) -> list[tuple[str, str]]:
"""Search key-value pairs by query. Returns matching (key, value) pairs."""
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
"""Delete a structured memory by (name, scope, scope_id). Returns True if existed."""
...
def delete_structured_memory_by_id(self, memory_id: str) -> bool:
"""Delete a structured memory by its primary key. Returns True if existed."""
...
def list_structured_memories(
self,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""Return structured memories with optional filters, ordered by updated DESC."""
...
def search_structured_memories(
self,
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""Search structured memories by query. Returns matching memory dicts."""
...
def count_structured_memories(
self, mem_type: str = "", scope: str = "", scope_id: str = ""
) -> int:
"""Count structured memories with optional type and scope filters."""
...
# -- Workstream operations -------------------------------------------------
@@ -703,6 +747,43 @@ class StorageBackend(Protocol):
"""Count intent verdicts matching the filters."""
...
# -- System settings -------------------------------------------------------
def get_system_setting(self, key: str, node_id: str = "") -> dict[str, Any] | None:
"""Return setting dict or None."""
...
def list_system_settings(self, node_id: str = "") -> list[dict[str, Any]]:
"""Return settings ordered by key.
When *node_id* is provided, returns both global (node_id="")
and node-specific settings. When empty, returns all settings.
"""
...
def upsert_system_setting(
self,
key: str,
value: str,
node_id: str = "",
is_secret: bool = False,
changed_by: str = "",
) -> None:
"""Create or update a system setting. Value is JSON-encoded."""
...
def delete_system_setting(self, key: str, node_id: str = "") -> bool:
"""Delete a setting by (key, node_id). Returns True if existed."""
...
def get_system_settings_bulk(self, node_id: str = "") -> dict[str, str]:
"""Return all settings as {key: json_value} dict.
Loads global settings (node_id="") first, then overlays per-node
overrides if node_id is provided.
"""
...
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
+31 -4
View File
@@ -9,13 +9,21 @@ import sqlalchemy as sa
metadata = sa.MetaData()
memories = sa.Table(
"memories",
structured_memories = sa.Table(
"structured_memories",
metadata,
sa.Column("key", sa.Text, primary_key=True),
sa.Column("value", sa.Text, nullable=False),
sa.Column("memory_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False),
sa.Column("description", sa.Text, nullable=False, server_default=""),
sa.Column("type", sa.Text, nullable=False, server_default="project"),
sa.Column("scope", sa.Text, nullable=False, server_default="global"),
sa.Column("scope_id", sa.Text, nullable=False, server_default=""),
sa.Column("content", sa.Text, nullable=False),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
sa.Column("last_accessed", sa.Text, nullable=False, server_default=""),
sa.Column("access_count", sa.Integer, nullable=False, server_default="0"),
sa.UniqueConstraint("name", "scope", "scope_id", name="uq_smem_name_scope"),
)
conversations = sa.Table(
@@ -418,3 +426,22 @@ intent_verdicts = sa.Table(
sa.Index("idx_intent_verdicts_ws", intent_verdicts.c.ws_id)
sa.Index("idx_intent_verdicts_created", intent_verdicts.c.created)
sa.Index("idx_intent_verdicts_risk", intent_verdicts.c.risk_level)
# ---------------------------------------------------------------------------
# System settings — database-backed configuration
# ---------------------------------------------------------------------------
system_settings = sa.Table(
"system_settings",
metadata,
sa.Column("key", sa.Text, nullable=False),
sa.Column("value", sa.Text, nullable=False),
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
sa.Column("is_secret", sa.Integer, nullable=False, server_default="0"),
sa.Column("changed_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
sa.PrimaryKeyConstraint("key", "node_id"),
)
sa.Index("idx_system_settings_node", system_settings.c.node_id)
+275 -63
View File
@@ -14,11 +14,12 @@ from turnstone.core.storage._schema import (
audit_events,
conversations,
intent_verdicts,
memories,
metadata,
orgs,
prompt_templates,
roles,
structured_memories,
system_settings,
tool_policies,
usage_events,
user_roles,
@@ -37,6 +38,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
ROLE_MUTABLE as _ROLE_MUTABLE,
)
from turnstone.core.storage._utils import (
STRUCTURED_MEMORY_MUTABLE as _SMEM_MUTABLE,
)
from turnstone.core.storage._utils import (
TEMPLATE_MUTABLE as _TEMPLATE_MUTABLE,
)
@@ -341,68 +345,6 @@ class SQLiteBackend:
)
conn.commit()
# -- Generic key-value store -----------------------------------------------
def kv_get(self, key: str) -> str | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(memories.c.value).where(memories.c.key == key)).fetchone()
return str(row[0]) if row else None
def kv_set(self, key: str, value: str) -> str | None:
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(memories.c.value).where(memories.c.key == key)
).fetchone()
old_value = str(existing[0]) if existing else None
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
conn.execute(
sa.text(
"INSERT OR REPLACE INTO memories (key, value, created, updated) "
"VALUES (:key, :value, "
"COALESCE((SELECT created FROM memories WHERE key = :key), :now), "
":now)"
),
{"key": key, "value": value, "now": now},
)
conn.commit()
return old_value
def kv_delete(self, key: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(sa.delete(memories).where(memories.c.key == key))
conn.commit()
return result.rowcount > 0
def kv_list(self) -> list[tuple[str, str]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(memories.c.key, memories.c.value).order_by(memories.c.key)
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
def kv_search(self, query: str) -> list[tuple[str, str]]:
if not query or not query.strip():
return self.kv_list()
terms = query.split()
with self._engine.connect() as conn:
# Build WHERE clause: each term must match key OR value
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_like(t)
clauses.append(f"(key LIKE :k{i} ESCAPE '\\' OR value LIKE :v{i} ESCAPE '\\')")
params[f"k{i}"] = f"%{escaped}%"
params[f"v{i}"] = f"%{escaped}%"
rows = conn.execute(
sa.text(
"SELECT key, value FROM memories WHERE "
+ " AND ".join(clauses)
+ " ORDER BY key"
),
params,
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
# -- Workstream operations -------------------------------------------------
def register_workstream(
@@ -2177,6 +2119,276 @@ class SQLiteBackend:
row = conn.execute(q).fetchone()
return row[0] if row else 0
# -- Structured memories ---------------------------------------------------
def create_structured_memory(
self,
memory_id: str,
name: str,
description: str,
mem_type: str,
scope: str,
scope_id: str,
content: str,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(structured_memories),
{
"memory_id": memory_id,
"name": name,
"description": description,
"type": mem_type,
"scope": scope,
"scope_id": scope_id,
"content": content,
"created": now,
"updated": now,
"last_accessed": now,
"access_count": 0,
},
)
conn.commit()
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(structured_memories).where(structured_memories.c.memory_id == memory_id)
).fetchone()
return dict(row._mapping) if row else None
def get_structured_memory_by_name(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
).fetchone()
return dict(row._mapping) if row else None
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
fields = {k: v for k, v in fields.items() if k in _SMEM_MUTABLE}
if not fields:
return False
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
fields["updated"] = now
fields["last_accessed"] = now
with self._engine.connect() as conn:
result = conn.execute(
sa.update(structured_memories)
.where(structured_memories.c.memory_id == memory_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
)
conn.commit()
return result.rowcount > 0
def delete_structured_memory_by_id(self, memory_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(structured_memories).where(structured_memories.c.memory_id == memory_id)
)
conn.commit()
return result.rowcount > 0
def list_structured_memories(
self,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
with self._engine.connect() as conn:
q = sa.select(structured_memories).order_by(structured_memories.c.updated.desc())
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id:
q = q.where(structured_memories.c.scope_id == scope_id)
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [dict(r._mapping) for r in rows]
def search_structured_memories(
self,
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
if not query or not query.strip():
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
terms = query.split()
with self._engine.connect() as conn:
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_like(t)
clauses.append(
f"(name LIKE :n{i} ESCAPE '\\' "
f"OR description LIKE :d{i} ESCAPE '\\' "
f"OR content LIKE :c{i} ESCAPE '\\')"
)
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
where = " AND ".join(clauses)
if mem_type:
where += " AND type = :type_filter"
params["type_filter"] = mem_type
if scope:
where += " AND scope = :scope_filter"
params["scope_filter"] = scope
if scope_id:
where += " AND scope_id = :scope_id_filter"
params["scope_id_filter"] = scope_id
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE {where} "
f"ORDER BY updated DESC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def count_structured_memories(
self, mem_type: str = "", scope: str = "", scope_id: str = ""
) -> int:
with self._engine.connect() as conn:
q = sa.select(sa.func.count()).select_from(structured_memories)
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id:
q = q.where(structured_memories.c.scope_id == scope_id)
result = conn.execute(q).scalar()
return int(result or 0)
# -- System settings -------------------------------------------------------
def get_system_setting(self, key: str, node_id: str = "") -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(system_settings).where(
sa.and_(
system_settings.c.key == key,
system_settings.c.node_id == node_id,
)
)
).fetchone()
return dict(row._mapping) if row else None
def list_system_settings(self, node_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(system_settings).order_by(system_settings.c.key)
if node_id:
# Return both global and node-specific
q = q.where(
sa.or_(
system_settings.c.node_id == "",
system_settings.c.node_id == node_id,
)
)
return [dict(r._mapping) for r in conn.execute(q).fetchall()]
def upsert_system_setting(
self,
key: str,
value: str,
node_id: str = "",
is_secret: bool = False,
changed_by: str = "",
) -> None:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
secret_val = 1 if is_secret else 0
stmt = sqlite_insert(system_settings).values(
key=key,
value=value,
node_id=node_id,
is_secret=secret_val,
changed_by=changed_by,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=["key", "node_id"],
set_={
"value": value,
"is_secret": secret_val,
"changed_by": changed_by,
"updated": now,
},
)
with self._engine.connect() as conn:
conn.execute(stmt)
conn.commit()
def delete_system_setting(self, key: str, node_id: str = "") -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(system_settings).where(
sa.and_(
system_settings.c.key == key,
system_settings.c.node_id == node_id,
)
)
)
conn.commit()
return result.rowcount > 0
def get_system_settings_bulk(self, node_id: str = "") -> dict[str, str]:
with self._engine.connect() as conn:
if not node_id:
# Global only
rows = conn.execute(
sa.select(system_settings.c.key, system_settings.c.value).where(
system_settings.c.node_id == ""
)
).fetchall()
return {r.key: r.value for r in rows}
# Global + node overrides in one query; node_id sorts after ""
# so node-specific values overwrite globals in the dict
rows = conn.execute(
sa.select(system_settings.c.key, system_settings.c.value)
.where(
sa.or_(
system_settings.c.node_id == "",
system_settings.c.node_id == node_id,
)
)
.order_by(system_settings.c.node_id)
).fetchall()
return {r.key: r.value for r in rows}
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
+1
View File
@@ -47,6 +47,7 @@ WS_TEMPLATE_MUTABLE = frozenset(
"enabled",
}
)
STRUCTURED_MEMORY_MUTABLE = frozenset({"content", "description", "type"})
VERDICT_MUTABLE = frozenset(
{
"user_decision",
@@ -0,0 +1,85 @@
"""Create structured_memories table and migrate existing flat memories.
Revision ID: 014
Revises: 013
Create Date: 2026-03-13
"""
import sqlalchemy as sa
from alembic import op
revision = "014"
down_revision = "013"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"structured_memories",
sa.Column("memory_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False),
sa.Column("description", sa.Text, nullable=False, server_default=""),
sa.Column("type", sa.Text, nullable=False, server_default="project"),
sa.Column("scope", sa.Text, nullable=False, server_default="global"),
sa.Column("scope_id", sa.Text, nullable=False, server_default=""),
sa.Column("content", sa.Text, nullable=False),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
sa.Column("last_accessed", sa.Text, nullable=False, server_default=""),
sa.Column("access_count", sa.Integer, nullable=False, server_default="0"),
)
op.create_unique_constraint(
"uq_smem_name_scope", "structured_memories", ["name", "scope", "scope_id"]
)
op.create_index("idx_smem_type", "structured_memories", ["type"])
op.create_index("idx_smem_scope", "structured_memories", ["scope", "scope_id"])
# Migrate existing flat memories into structured_memories
conn = op.get_bind()
conn.execute(
sa.text(
"INSERT INTO structured_memories "
"(memory_id, name, description, type, scope, scope_id, content, created, updated) "
"SELECT "
" 'migrated-' || key, "
" key, "
" '', "
" 'project', "
" 'global', "
" '', "
" value, "
" created, "
" updated "
"FROM memories"
)
)
op.drop_table("memories")
# Grant admin.memories permission to the built-in admin role
conn.execute(
sa.text(
"UPDATE roles SET permissions = permissions || ',admin.memories' "
"WHERE role_id = 'builtin-admin' "
"AND permissions NOT LIKE '%admin.memories%'"
)
)
def downgrade() -> None:
op.create_table(
"memories",
sa.Column("key", sa.Text, primary_key=True),
sa.Column("value", sa.Text, nullable=False),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
conn = op.get_bind()
conn.execute(
sa.text(
"INSERT INTO memories (key, value, created, updated) "
"SELECT name, content, created, updated "
"FROM structured_memories WHERE scope = 'global'"
)
)
op.drop_table("structured_memories")
@@ -0,0 +1,51 @@
"""Create system_settings table for database-backed configuration.
Revision ID: 015
Revises: 014
Create Date: 2026-03-14
"""
import sqlalchemy as sa
from alembic import op
revision = "015"
down_revision = "014"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"system_settings",
sa.Column("key", sa.Text, nullable=False),
sa.Column("value", sa.Text, nullable=False),
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
sa.Column("is_secret", sa.Integer, nullable=False, server_default="0"),
sa.Column("changed_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
sa.PrimaryKeyConstraint("key", "node_id"),
)
op.create_index("idx_system_settings_node", "system_settings", ["node_id"])
# Grant admin.settings permission to the built-in admin role
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = permissions || ',admin.settings' "
"WHERE role_id = 'builtin-admin' "
"AND permissions NOT LIKE '%admin.settings%'"
)
)
def downgrade() -> None:
# Remove admin.settings permission from builtin-admin role
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = REPLACE(permissions, ',admin.settings', '') "
"WHERE role_id = 'builtin-admin'"
)
)
op.drop_table("system_settings")
+1 -57
View File
@@ -8,67 +8,11 @@ models (vLLM, llama.cpp) use the client-side BM25 fallback here.
from __future__ import annotations
import math
import re
from collections import Counter
from typing import Any
# ---------------------------------------------------------------------------
# BM25 index — lightweight, pure-Python, zero external deps
# ---------------------------------------------------------------------------
_SPLIT_RE = re.compile(r"[_\-./\s]+")
def _tokenize(text: str) -> list[str]:
"""Split text on whitespace, underscores, hyphens, dots."""
return [t.lower() for t in _SPLIT_RE.split(text) if t]
class BM25Index:
"""Okapi BM25 index over tool name + description text."""
def __init__(self, documents: list[str], *, k1: float = 1.5, b: float = 0.75) -> None:
self.k1 = k1
self.b = b
self._docs = documents
self._doc_tokens: list[list[str]] = [_tokenize(d) for d in documents]
self._doc_lens = [len(t) for t in self._doc_tokens]
self._avgdl = sum(self._doc_lens) / max(len(self._doc_lens), 1)
self._n = len(documents)
# Document frequency per term
self._df: Counter[str] = Counter()
for tokens in self._doc_tokens:
for term in set(tokens):
self._df[term] += 1
def search(self, query: str, k: int = 5) -> list[int]:
"""Return indices of top-k documents sorted by descending BM25 score."""
q_tokens = _tokenize(query)
if not q_tokens:
return []
scores: list[tuple[float, int]] = []
for idx, doc_tokens in enumerate(self._doc_tokens):
score = self._score(q_tokens, doc_tokens, self._doc_lens[idx])
if score > 0:
scores.append((score, idx))
scores.sort(key=lambda x: (-x[0], x[1]))
return [idx for _, idx in scores[:k]]
def _score(self, q_tokens: list[str], doc_tokens: list[str], dl: int) -> float:
tf_map: Counter[str] = Counter(doc_tokens)
score = 0.0
for term in q_tokens:
if term not in tf_map:
continue
tf = tf_map[term]
df = self._df.get(term, 0)
idf = math.log((self._n - df + 0.5) / (df + 0.5) + 1.0)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avgdl)
score += idf * numerator / denominator
return score
from turnstone.core.bm25 import BM25Index, _tokenize # noqa: F401
# ---------------------------------------------------------------------------
# Tool search manager — partitions tools, tracks visibility
+1 -1
View File
@@ -55,7 +55,7 @@ if TYPE_CHECKING:
log = logging.getLogger("turnstone.mq.bridge")
# Server's default safe tools (auto-approved without user confirmation)
DEFAULT_SAFE_TOOLS = frozenset(["read_file", "search", "man", "remember", "recall", "forget"])
DEFAULT_SAFE_TOOLS = frozenset(["read_file", "search", "man", "memory", "recall"])
class Bridge:
+11
View File
@@ -388,6 +388,16 @@ class IntentVerdictEvent(OutboundEvent):
latency_ms: int = 0
@dataclass
class ConfigChangeEvent(OutboundEvent):
"""System setting changed — nodes should invalidate config cache."""
type: str = "config_change"
key: str = ""
node_id: str = ""
action: str = "" # "set" | "delete"
# ---------------------------------------------------------------------------
# Type registries (built after all classes are defined)
# ---------------------------------------------------------------------------
@@ -443,5 +453,6 @@ _OUTBOUND_REGISTRY: dict[str, type[OutboundEvent]] = {
WorkstreamResumedEvent,
ClusterStateEvent,
IntentVerdictEvent,
ConfigChangeEvent,
]
}
+144
View File
@@ -14,16 +14,20 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from turnstone.api.console_schemas import (
AdminMemoryInfo,
ClusterNodesResponse,
ClusterOverviewResponse,
ClusterSnapshotResponse,
ClusterWorkstreamsResponse,
ConsoleCreateWsResponse,
ConsoleHealthResponse,
ListAdminMemoriesResponse,
ListAuditEventsResponse,
ListOrgsResponse,
ListPromptTemplatesResponse,
ListRolesResponse,
ListSettingSchemaResponse,
ListSettingsResponse,
ListToolPoliciesResponse,
ListUserRolesResponse,
ListWsTemplatesResponse,
@@ -32,6 +36,7 @@ from turnstone.api.console_schemas import (
OrgInfo,
PromptTemplateInfo,
RoleInfo,
SettingInfo,
ToolPolicyInfo,
UsageResponse,
WsTemplateInfo,
@@ -568,6 +573,99 @@ class AsyncTurnstoneConsole(_BaseClient):
"GET", "/v1/api/admin/audit", params=params, response_model=ListAuditEventsResponse
)
# -- governance: memories ------------------------------------------------
async def list_memories(
self,
*,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> ListAdminMemoriesResponse:
params: dict[str, Any] = {"limit": limit}
if mem_type:
params["type"] = mem_type
if scope:
params["scope"] = scope
if scope_id:
params["scope_id"] = scope_id
return await self._request(
"GET",
"/v1/api/admin/memories",
params=params,
response_model=ListAdminMemoriesResponse,
)
async def search_memories(
self,
query: str,
*,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> ListAdminMemoriesResponse:
params: dict[str, Any] = {"q": query, "limit": limit}
if mem_type:
params["type"] = mem_type
if scope:
params["scope"] = scope
if scope_id:
params["scope_id"] = scope_id
return await self._request(
"GET",
"/v1/api/admin/memories/search",
params=params,
response_model=ListAdminMemoriesResponse,
)
async def get_memory(self, memory_id: str) -> AdminMemoryInfo:
return await self._request(
"GET",
f"/v1/api/admin/memories/{memory_id}",
response_model=AdminMemoryInfo,
)
async def delete_memory(self, memory_id: str) -> StatusResponse:
return await self._request(
"DELETE",
f"/v1/api/admin/memories/{memory_id}",
response_model=StatusResponse,
)
# -- system: settings ----------------------------------------------------
async def list_settings(self) -> ListSettingsResponse:
"""List all settings with effective values."""
return await self._request(
"GET", "/v1/api/admin/settings", response_model=ListSettingsResponse
)
async def get_settings_schema(self) -> ListSettingSchemaResponse:
"""Return the full settings registry schema."""
return await self._request(
"GET", "/v1/api/admin/settings/schema", response_model=ListSettingSchemaResponse
)
async def update_setting(self, key: str, value: Any, *, node_id: str = "") -> SettingInfo:
"""Set a configuration setting value."""
body: dict[str, Any] = {"value": value}
if node_id:
body["node_id"] = node_id
return await self._request(
"PUT", f"/v1/api/admin/settings/{key}", json_body=body, response_model=SettingInfo
)
async def delete_setting(self, key: str, *, node_id: str = "") -> StatusResponse:
"""Reset a setting to its default value."""
params: dict[str, Any] = {}
if node_id:
params["node_id"] = node_id
return await self._request(
"DELETE", f"/v1/api/admin/settings/{key}", params=params, response_model=StatusResponse
)
class TurnstoneConsole:
"""Synchronous client for the turnstone console API.
@@ -897,6 +995,52 @@ class TurnstoneConsole:
)
)
# -- governance: memories ------------------------------------------------
def list_memories(
self, *, mem_type: str = "", scope: str = "", scope_id: str = "", limit: int = 100
) -> ListAdminMemoriesResponse:
return self._runner.run(
self._async.list_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
)
def search_memories(
self,
query: str,
*,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> ListAdminMemoriesResponse:
return self._runner.run(
self._async.search_memories(
query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
)
def get_memory(self, memory_id: str) -> AdminMemoryInfo:
return self._runner.run(self._async.get_memory(memory_id))
def delete_memory(self, memory_id: str) -> StatusResponse:
return self._runner.run(self._async.delete_memory(memory_id))
# -- system: settings ----------------------------------------------------
def list_settings(self) -> ListSettingsResponse:
return self._runner.run(self._async.list_settings())
def get_settings_schema(self) -> ListSettingSchemaResponse:
return self._runner.run(self._async.get_settings_schema())
def update_setting(self, key: str, value: Any, *, node_id: str = "") -> SettingInfo:
return self._runner.run(self._async.update_setting(key, value, node_id=node_id))
def delete_setting(self, key: str, *, node_id: str = "") -> StatusResponse:
return self._runner.run(self._async.delete_setting(key, node_id=node_id))
# -- lifecycle -----------------------------------------------------------
def close(self) -> None:
+139
View File
@@ -26,8 +26,10 @@ from turnstone.api.server_schemas import (
CreateWorkstreamResponse,
DashboardResponse,
HealthResponse,
ListMemoriesResponse,
ListSavedWorkstreamsResponse,
ListWorkstreamsResponse,
MemoryInfo,
SendResponse,
)
from turnstone.sdk._base import _BaseClient
@@ -235,6 +237,91 @@ class AsyncTurnstoneServer(_BaseClient):
"GET", "/v1/api/workstreams/saved", response_model=ListSavedWorkstreamsResponse
)
# -- memories ------------------------------------------------------------
async def list_memories(
self,
*,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> ListMemoriesResponse:
params: dict[str, Any] = {"limit": limit}
if mem_type:
params["type"] = mem_type
if scope:
params["scope"] = scope
if scope_id:
params["scope_id"] = scope_id
return await self._request(
"GET", "/v1/api/memories", params=params, response_model=ListMemoriesResponse
)
async def save_memory(
self,
name: str,
content: str,
*,
description: str = "",
mem_type: str = "project",
scope: str = "global",
scope_id: str = "",
) -> MemoryInfo:
body: dict[str, Any] = {
"name": name,
"content": content,
"type": mem_type,
"scope": scope,
}
if description:
body["description"] = description
if scope_id:
body["scope_id"] = scope_id
return await self._request(
"POST", "/v1/api/memories", json_body=body, response_model=MemoryInfo
)
async def search_memories(
self,
query: str,
*,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> ListMemoriesResponse:
body: dict[str, Any] = {"query": query, "limit": limit}
if mem_type:
body["type"] = mem_type
if scope:
body["scope"] = scope
if scope_id:
body["scope_id"] = scope_id
return await self._request(
"POST",
"/v1/api/memories/search",
json_body=body,
response_model=ListMemoriesResponse,
)
async def delete_memory(
self,
name: str,
*,
scope: str = "global",
scope_id: str = "",
) -> StatusResponse:
params: dict[str, Any] = {"scope": scope}
if scope_id:
params["scope_id"] = scope_id
return await self._request(
"DELETE",
f"/v1/api/memories/{name}",
params=params,
response_model=StatusResponse,
)
# -- auth ----------------------------------------------------------------
async def login(
@@ -394,6 +481,58 @@ class TurnstoneServer:
def list_saved_workstreams(self) -> ListSavedWorkstreamsResponse:
return self._runner.run(self._async.list_saved_workstreams())
# -- memories ------------------------------------------------------------
def list_memories(
self, *, mem_type: str = "", scope: str = "", scope_id: str = "", limit: int = 100
) -> ListMemoriesResponse:
return self._runner.run(
self._async.list_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
)
def save_memory(
self,
name: str,
content: str,
*,
description: str = "",
mem_type: str = "project",
scope: str = "global",
scope_id: str = "",
) -> MemoryInfo:
return self._runner.run(
self._async.save_memory(
name,
content,
description=description,
mem_type=mem_type,
scope=scope,
scope_id=scope_id,
)
)
def search_memories(
self,
query: str,
*,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> ListMemoriesResponse:
return self._runner.run(
self._async.search_memories(
query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
)
def delete_memory(
self, name: str, *, scope: str = "global", scope_id: str = ""
) -> StatusResponse:
return self._runner.run(self._async.delete_memory(name, scope=scope, scope_id=scope_id))
# -- auth ----------------------------------------------------------------
def login(
+344 -337
View File
@@ -1359,6 +1359,166 @@ async def cancel_watch(request: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "watch_id": watch_id})
# ---------------------------------------------------------------------------
# Memory endpoints
# ---------------------------------------------------------------------------
_VALID_MEMORY_TYPES = frozenset({"user", "project", "feedback", "reference"})
_VALID_MEMORY_SCOPES = frozenset({"global", "workstream", "user"})
_MAX_MEMORY_CONTENT = 65536 # hard upper bound; server may enforce lower via config
def _resolve_user_scope_id(
request: Request, provided_scope_id: str = ""
) -> tuple[str, JSONResponse | None]:
"""Resolve and validate scope_id for user-scoped memory.
Always binds to the authenticated user's identity. If a scope_id is
provided and doesn't match, returns 403 to prevent cross-user access.
"""
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = getattr(auth, "user_id", "") or ""
if not uid:
return "", JSONResponse(
{"error": "User scope requires authentication with a user identity"},
status_code=400,
)
if provided_scope_id and provided_scope_id != uid:
return "", JSONResponse(
{"error": "Cannot access another user's memories"},
status_code=403,
)
return uid, None
async def list_memories(request: Request) -> JSONResponse:
"""GET /v1/api/memories — list memories with optional filters."""
from turnstone.core.memory import list_structured_memories
mem_type = request.query_params.get("type", "")
scope = request.query_params.get("scope", "")
scope_id = request.query_params.get("scope_id", "")
try:
limit = min(int(request.query_params.get("limit", "100")), 200)
except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
rows = list_structured_memories(mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit)
return JSONResponse({"memories": rows, "total": len(rows)})
async def save_memory(request: Request) -> JSONResponse:
"""POST /v1/api/memories — save (upsert) a structured memory."""
from turnstone.core.memory import save_structured_memory
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()
content = str(body.get("content", "")).strip()
if not name or len(name) > 256:
return JSONResponse({"error": "name is required (max 256 characters)"}, status_code=400)
if not content:
return JSONResponse({"error": "content is required"}, status_code=400)
if len(content) > _MAX_MEMORY_CONTENT:
return JSONResponse(
{"error": f"content exceeds {_MAX_MEMORY_CONTENT} character limit"},
status_code=400,
)
description = str(body.get("description", ""))
mem_type = str(body.get("type", "project"))
scope = str(body.get("scope", "global"))
scope_id = str(body.get("scope_id", ""))
if mem_type not in _VALID_MEMORY_TYPES:
return JSONResponse(
{"error": f"invalid type: {mem_type}; must be one of {sorted(_VALID_MEMORY_TYPES)}"},
status_code=400,
)
if scope not in _VALID_MEMORY_SCOPES:
return JSONResponse(
{"error": f"invalid scope: {scope}; must be one of {sorted(_VALID_MEMORY_SCOPES)}"},
status_code=400,
)
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
# save_structured_memory normalises the name internally
from turnstone.core.memory import normalize_key
normalized_name = normalize_key(name)
memory_id, old_content = save_structured_memory(
name, content, description=description, mem_type=mem_type, scope=scope, scope_id=scope_id
)
if not memory_id:
return JSONResponse({"error": "Failed to save memory"}, status_code=500)
from turnstone.core.storage._registry import get_storage
storage = get_storage()
mem = storage.get_structured_memory(memory_id) if storage else None
if not mem:
return JSONResponse(
{"memory_id": memory_id, "name": normalized_name, "status": "saved"},
status_code=201,
)
status_code = 200 if old_content is not None else 201
return JSONResponse(mem, status_code=status_code)
async def search_memories(request: Request) -> JSONResponse:
"""POST /v1/api/memories/search — search memories by query.
Uses POST for the request body but requires only read scope (non-mutating).
"""
from turnstone.core.memory import search_structured_memories as search_fn
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
query = str(body.get("query", "")).strip()
if not query:
return JSONResponse({"error": "query is required"}, status_code=400)
mem_type = str(body.get("type", ""))
scope = str(body.get("scope", ""))
scope_id = str(body.get("scope_id", ""))
try:
limit = min(int(body.get("limit", 20)), 50)
except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
rows = search_fn(query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit)
return JSONResponse({"memories": rows, "total": len(rows)})
async def delete_memory_endpoint(request: Request) -> JSONResponse:
"""DELETE /v1/api/memories/{name} — delete a memory by name and scope."""
from turnstone.core.memory import delete_structured_memory, normalize_key
name = normalize_key(request.path_params["name"])
scope = request.query_params.get("scope", "global")
if scope not in _VALID_MEMORY_SCOPES:
return JSONResponse(
{"error": f"invalid scope: {scope}; must be one of {sorted(_VALID_MEMORY_SCOPES)}"},
status_code=400,
)
scope_id = request.query_params.get("scope_id", "")
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
if delete_structured_memory(name, scope, scope_id):
return JSONResponse({"status": "ok", "name": name})
return JSONResponse({"error": f"Memory '{name}' not found"}, status_code=404)
async def auth_login(request: Request) -> Response:
"""POST /v1/api/auth/login — authenticate and return JWT."""
from turnstone.core.auth import handle_auth_login
@@ -1387,6 +1547,15 @@ async def auth_setup(request: Request) -> Response:
return await handle_auth_setup(request, JWT_AUD_SERVER)
def config_reload(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/config-reload — invalidate config cache."""
cs = getattr(request.app.state, "config_store", None)
if not cs:
return JSONResponse({"status": "noop"})
cs.reload()
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Global SSE fan-out
# ---------------------------------------------------------------------------
@@ -1518,6 +1687,7 @@ def create_app(
cors_origins: list[str] | None = None,
watch_runner: Any = None,
judge_config: Any = None,
config_store: Any = None,
) -> Starlette:
"""Create and configure the Starlette ASGI application."""
_spec = build_server_spec()
@@ -1544,10 +1714,15 @@ def create_app(
Route("/api/workstreams/close", close_workstream, methods=["POST"]),
Route("/api/watches", list_watches),
Route("/api/watches/{watch_id}/cancel", cancel_watch, methods=["POST"]),
Route("/api/memories", list_memories),
Route("/api/memories", save_memory, methods=["POST"]),
Route("/api/memories/search", search_memories, methods=["POST"]),
Route("/api/memories/{name}", delete_memory_endpoint, methods=["DELETE"]),
Route("/api/auth/login", auth_login, methods=["POST"]),
Route("/api/auth/logout", auth_logout, methods=["POST"]),
Route("/api/auth/status", auth_status),
Route("/api/auth/setup", auth_setup, methods=["POST"]),
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
],
),
Route("/health", health),
@@ -1576,6 +1751,7 @@ def create_app(
app.state.node_id = node_id
app.state.watch_runner = watch_runner
app.state.judge_config = judge_config
app.state.config_store = config_store
from turnstone.core.auth import LoginRateLimiter
@@ -1610,105 +1786,23 @@ def main() -> None:
default=None,
help="Model name (default: auto-detect from server)",
)
parser.add_argument(
"--instructions",
default=None,
help="Developer instructions injected as developer message",
)
parser.add_argument(
"--template",
default=None,
help="Prompt template name (replaces default templates)",
)
parser.add_argument(
"--temperature",
type=float,
default=0.5,
help="Sampling temperature (default: 0.5)",
)
parser.add_argument(
"--max-tokens",
type=int,
default=32768,
help="Max completion tokens (default: 32768)",
)
parser.add_argument(
"--tool-timeout",
type=int,
default=30,
help="Bash command timeout in seconds (default: 30)",
)
parser.add_argument(
"--reasoning-effort",
default="medium",
choices=["none", "minimal", "low", "medium", "high", "xhigh", "max"],
help="Reasoning effort level (default: medium)",
)
parser.add_argument(
"--provider",
default="openai",
choices=["openai", "anthropic"],
help="LLM provider for the default model (default: openai)",
)
parser.add_argument(
"--context-window",
type=int,
default=131072,
help="Context window size in tokens (default: 131072)",
)
parser.add_argument(
"--compact-max-tokens",
type=int,
default=32768,
help="Max tokens for compaction summary (default: 32768)",
)
parser.add_argument(
"--auto-compact-pct",
type=float,
default=0.8,
help="Auto-compact when prompt exceeds this fraction of context window (default: 0.8)",
)
parser.add_argument(
"--agent-max-turns",
type=int,
default=-1,
help="Max tool turns for agent sub-sessions, -1 for unlimited (default: -1)",
)
parser.add_argument(
"--tool-truncation",
type=int,
default=0,
help="Tool output truncation limit in chars, 0 for auto (50%% of context window) (default: 0)",
)
parser.add_argument(
"--tool-search",
choices=["auto", "on", "off"],
default="auto",
help="Dynamic tool search: auto (enable when tool count exceeds threshold), on, off (default: auto)",
)
parser.add_argument(
"--tool-search-threshold",
type=int,
default=20,
help="Min tools before tool search activates (default: 20)",
)
parser.add_argument(
"--tool-search-max-results",
type=int,
default=5,
help="Max tools returned per tool search query (default: 5)",
)
parser.add_argument(
"--resume",
default=None,
metavar="WS",
help="Resume a previous workstream by alias or ws_id",
)
parser.add_argument(
"--skip-permissions",
action="store_true",
help="Auto-approve all tool calls (no confirmation prompts)",
)
parser.add_argument(
"--api-key",
default=None,
@@ -1725,149 +1819,21 @@ def main() -> None:
default=8080,
help="Port to listen on (default: 8080)",
)
parser.add_argument(
"--retention-days",
type=int,
default=90,
metavar="DAYS",
help="Delete unnamed workstreams older than DAYS days on startup, 0 to disable (default: 90)",
)
parser.add_argument(
"--workstream-idle-timeout",
type=int,
default=120,
metavar="MINUTES",
help="Close IDLE workstreams after MINUTES of inactivity, 0 to disable (default: 120)",
)
# MCP config path is bootstrap-critical (needed before ConfigStore for tool loading)
parser.add_argument(
"--mcp-config",
default=None,
metavar="PATH",
help="Path to MCP server config file (standard mcpServers JSON format)",
)
from turnstone.core.config import nonneg_float
parser.add_argument(
"--mcp-refresh-interval",
type=nonneg_float,
default=14400,
metavar="SECONDS",
help="Periodic MCP tool refresh interval for servers without push notifications (default: 14400 = 4h, 0 to disable)",
)
parser.add_argument(
"--max-workstreams",
type=int,
default=10,
help="Maximum concurrent workstreams, auto-evicts idle when full (default: 10)",
)
parser.add_argument(
"--ratelimit-enabled",
action="store_true",
default=False,
help="Enable per-IP rate limiting",
)
parser.add_argument(
"--ratelimit-rps",
type=float,
default=10.0,
help="Rate limit: requests per second per client IP (default: 10.0)",
)
parser.add_argument(
"--ratelimit-burst",
type=int,
default=20,
help="Rate limit: burst size (default: 20)",
)
parser.add_argument(
"--ratelimit-trusted-proxies",
default="",
help="Trusted proxy CIDRs for X-Forwarded-For parsing (comma-separated, e.g. '10.0.0.0/8,172.16.0.0/12')",
)
parser.add_argument(
"--health-probe-interval",
type=float,
default=30.0,
help="Backend health probe interval in seconds (default: 30)",
)
parser.add_argument(
"--health-probe-timeout",
type=float,
default=5.0,
help="Backend health probe timeout in seconds (default: 5)",
)
parser.add_argument(
"--circuit-breaker-threshold",
type=int,
default=5,
help="Consecutive failures to open circuit breaker (default: 5)",
)
parser.add_argument(
"--circuit-breaker-cooldown",
type=float,
default=60.0,
help="Circuit breaker cooldown in seconds (default: 60)",
)
judge_group = parser.add_argument_group("Judge options")
judge_group.add_argument(
"--judge",
dest="judge_enabled",
action="store_true",
default=True,
help="Enable intent validation judge for tool approvals (default)",
)
judge_group.add_argument(
"--no-judge",
dest="judge_enabled",
action="store_false",
help="Disable intent validation judge",
)
judge_group.add_argument(
"--judge-model",
dest="judge_model",
default="",
help="Model for judge (default: same as session model)",
)
judge_group.add_argument(
"--judge-provider",
dest="judge_provider",
default="",
help="Provider for judge (default: same as session provider)",
)
judge_group.add_argument(
"--judge-timeout",
dest="judge_timeout",
type=float,
default=60.0,
help="LLM judge timeout in seconds (default: 60)",
)
judge_group.add_argument(
"--judge-confidence",
dest="judge_confidence",
type=float,
default=0.7,
help="Confidence threshold for judge (default: 0.7)",
)
from turnstone.core.log import add_log_args
add_log_args(parser)
from turnstone.core.config import apply_config
apply_config(
parser,
[
"api",
"model",
"session",
"tools",
"server",
"mcp",
"ratelimit",
"health",
"database",
"judge",
],
)
# Only load bootstrap sections from config.toml — all other settings
# are managed by ConfigStore (database-backed) after storage init.
apply_config(parser, ["api", "server", "database"])
args = parser.parse_args()
from turnstone.core.log import configure_logging_from_args
@@ -1887,86 +1853,7 @@ def main() -> None:
)
init_storage(db_backend, path=db_path, url=db_url, pool_size=db_pool_size)
# Prune stale / empty workstreams on startup
from turnstone.core.memory import prune_workstreams
prune_workstreams(retention_days=args.retention_days, log_fn=print)
# Create client and detect model
provider_name = args.provider
api_key = (
args.api_key
or os.environ.get("ANTHROPIC_API_KEY" if provider_name == "anthropic" else "OPENAI_API_KEY")
or "dummy"
)
base_url = args.base_url
if provider_name == "anthropic" and base_url == "http://localhost:8000/v1":
base_url = "https://api.anthropic.com"
from turnstone.core.providers import create_client
client = create_client(provider_name, base_url=base_url, api_key=api_key)
if args.model:
model = args.model
detected_ctx = None
else:
from turnstone.core.model_registry import detect_model
model, detected_ctx = detect_model(client, provider=provider_name)
# Use detected context window when the user hasn't explicitly set one
context_window = args.context_window
if detected_ctx and context_window == 131072: # default unchanged
context_window = detected_ctx
log.info("Context window: %s (detected from backend)", f"{context_window:,}")
# Build model registry (reads [models.*] sections from config.toml)
from turnstone.core.model_registry import load_model_registry
registry = load_model_registry(
base_url=base_url,
api_key=api_key,
model=model,
context_window=context_window,
provider=provider_name,
)
# Initialize MCP client (connects to configured MCP servers, if any)
from turnstone.core.mcp_client import create_mcp_client
mcp_client = create_mcp_client(
getattr(args, "mcp_config", None),
refresh_interval=getattr(args, "mcp_refresh_interval", 14400),
)
# Backend health monitor with circuit breaker
from turnstone.core.healthcheck import BackendHealthMonitor
health_monitor = BackendHealthMonitor(
client=client,
probe_interval=args.health_probe_interval,
probe_timeout=args.health_probe_timeout,
failure_threshold=args.circuit_breaker_threshold,
cooldown=args.circuit_breaker_cooldown,
)
health_monitor.start()
# Per-IP rate limiter
from turnstone.core.ratelimit import RateLimiter
rate_limiter = RateLimiter(
enabled=args.ratelimit_enabled,
rate=args.ratelimit_rps,
burst=args.ratelimit_burst,
trusted_proxies=args.ratelimit_trusted_proxies,
)
# Set up global event queue for state-change broadcasts
global_queue: queue.Queue[dict[str, Any]] = queue.Queue()
global_listeners: list[queue.Queue[dict[str, Any]]] = []
global_listeners_lock = threading.Lock()
WebUI._global_queue = global_queue
# Server-owned node identity
# Server-owned node identity (needed before ConfigStore for node_id scoping)
def _default_node_id() -> str:
"""Generate a node_id: ``{hostname}_{4hex}``, or a UUID on failure."""
suffix = uuid.uuid4().hex[:4]
@@ -1984,20 +1871,134 @@ def main() -> None:
ctx_node_id.set(_node_id)
# Intent validation judge config
from turnstone.core.judge import JudgeConfig
# Database-backed config store — single source of truth for non-bootstrap
# settings. Created early so all subsequent init code can read from it.
from turnstone.core.config_store import ConfigStore
from turnstone.core.storage import get_storage as _get_cs_storage
judge_config = JudgeConfig(
enabled=getattr(args, "judge_enabled", True),
model=getattr(args, "judge_model", ""),
provider=getattr(args, "judge_provider", ""),
base_url=getattr(args, "judge_base_url", ""),
api_key=getattr(args, "judge_api_key", ""),
confidence_threshold=getattr(args, "judge_confidence", 0.7),
max_context_ratio=getattr(args, "judge_context_ratio", 0.5),
timeout=getattr(args, "judge_timeout", 60.0),
read_only_tools=getattr(args, "judge_read_only_tools", True),
config_store = ConfigStore(storage=_get_cs_storage(), node_id=_node_id)
# Warn about config.toml keys that are now managed by ConfigStore
from turnstone.core.config import warn_migrated_settings
warn_migrated_settings()
# Prune stale / empty workstreams on startup
from turnstone.core.memory import prune_workstreams
prune_workstreams(retention_days=config_store.get("session.retention_days"), log_fn=print)
# Create client and detect model
provider_name = args.provider
api_key = (
args.api_key
or os.environ.get("ANTHROPIC_API_KEY" if provider_name == "anthropic" else "OPENAI_API_KEY")
or "dummy"
)
base_url = args.base_url
if provider_name == "anthropic" and base_url == "http://localhost:8000/v1":
base_url = "https://api.anthropic.com"
from turnstone.core.providers import create_client
client = create_client(provider_name, base_url=base_url, api_key=api_key)
cs_model = config_store.get("model.name")
cli_model = args.model
effective_model = cli_model or cs_model or None
if effective_model:
model = effective_model
detected_ctx = None
else:
from turnstone.core.model_registry import detect_model
model, detected_ctx = detect_model(client, provider=provider_name)
# 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 = 32768
# Build model registry (reads [models.*] sections from config.toml)
from turnstone.core.model_registry import load_model_registry
registry = load_model_registry(
base_url=base_url,
api_key=api_key,
model=model,
context_window=context_window,
provider=provider_name,
)
# Initialize MCP client (connects to configured MCP servers, if any)
from turnstone.core.mcp_client import create_mcp_client
mcp_config_cli = args.mcp_config # CLI-only (no config.toml for this)
mcp_client = create_mcp_client(
mcp_config_cli or config_store.get("mcp.config_path") or None,
refresh_interval=config_store.get("mcp.refresh_interval"),
)
# Backend health monitor with circuit breaker
from turnstone.core.healthcheck import BackendHealthMonitor
health_monitor = BackendHealthMonitor(
client=client,
probe_interval=config_store.get("health.backend_probe_interval"),
probe_timeout=config_store.get("health.backend_probe_timeout"),
failure_threshold=config_store.get("health.circuit_breaker_threshold"),
cooldown=config_store.get("health.circuit_breaker_cooldown"),
)
health_monitor.start()
# Per-IP rate limiter
from turnstone.core.ratelimit import RateLimiter
rate_limiter = RateLimiter(
enabled=config_store.get("ratelimit.enabled"),
rate=config_store.get("ratelimit.requests_per_second"),
burst=config_store.get("ratelimit.burst"),
trusted_proxies=config_store.get("ratelimit.trusted_proxies"),
)
# Set up global event queue for state-change broadcasts
global_queue: queue.Queue[dict[str, Any]] = queue.Queue()
global_listeners: list[queue.Queue[dict[str, Any]]] = []
global_listeners_lock = threading.Lock()
WebUI._global_queue = global_queue
# Config builders — shared between startup logging and session factory.
# Re-read from ConfigStore each call so hot-reload works.
from turnstone.core.judge import JudgeConfig
from turnstone.core.memory_relevance import MemoryConfig
def _build_judge_config() -> JudgeConfig:
return JudgeConfig(
enabled=config_store.get("judge.enabled"),
model=config_store.get("judge.model"),
provider=config_store.get("judge.provider"),
base_url=config_store.get("judge.base_url"),
api_key=config_store.get("judge.api_key"),
confidence_threshold=config_store.get("judge.confidence_threshold"),
max_context_ratio=config_store.get("judge.max_context_ratio"),
timeout=config_store.get("judge.timeout"),
read_only_tools=config_store.get("judge.read_only_tools"),
)
def _build_memory_config() -> MemoryConfig:
return MemoryConfig(
relevance_k=config_store.get("memory.relevance_k"),
fetch_limit=config_store.get("memory.fetch_limit"),
max_content=config_store.get("memory.max_content"),
nudge_cooldown=config_store.get("memory.nudge_cooldown"),
nudges=config_store.get("memory.nudges"),
)
judge_config = _build_judge_config()
if judge_config.enabled:
log.info(
"Judge: enabled (model=%s, threshold=%.2f)",
@@ -2005,7 +2006,7 @@ def main() -> None:
judge_config.confidence_threshold,
)
# Session factory — captures shared config
# Session factory — captures shared config (including config_store for hot-reload)
def session_factory(
ui: SessionUI | None,
model_alias: str | None = None,
@@ -2013,31 +2014,39 @@ def main() -> None:
) -> ChatSession:
assert ui is not None
r_client, r_model, r_cfg = registry.resolve(model_alias)
uid = getattr(ui, "_user_id", "") or ""
# Re-resolve from ConfigStore so new workstreams pick up hot-reloaded settings.
live_memory_config = _build_memory_config()
live_judge_config = _build_judge_config()
return ChatSession(
client=r_client,
model=r_model,
ui=ui,
instructions=args.instructions,
temperature=args.temperature,
max_tokens=args.max_tokens,
tool_timeout=args.tool_timeout,
reasoning_effort=args.reasoning_effort,
instructions=config_store.get("session.instructions") or None,
temperature=config_store.get("model.temperature"),
max_tokens=config_store.get("model.max_tokens"),
tool_timeout=config_store.get("tools.timeout"),
reasoning_effort=config_store.get("model.reasoning_effort"),
context_window=r_cfg.context_window,
compact_max_tokens=args.compact_max_tokens,
auto_compact_pct=args.auto_compact_pct,
agent_max_turns=args.agent_max_turns,
tool_truncation=args.tool_truncation,
compact_max_tokens=config_store.get("session.compact_max_tokens"),
auto_compact_pct=config_store.get("session.auto_compact_pct"),
agent_max_turns=config_store.get("tools.agent_max_turns"),
tool_truncation=config_store.get("tools.truncation"),
mcp_client=mcp_client,
registry=registry,
model_alias=model_alias or registry.default,
health_monitor=health_monitor,
node_id=_node_id,
ws_id=ws_id,
tool_search=args.tool_search,
tool_search_threshold=args.tool_search_threshold,
tool_search_max_results=args.tool_search_max_results,
tool_search=config_store.get("tools.search"),
tool_search_threshold=config_store.get("tools.search_threshold"),
tool_search_max_results=config_store.get("tools.search_max_results"),
template=args.template,
judge_config=judge_config,
judge_config=live_judge_config,
user_id=uid,
memory_config=live_memory_config,
)
# Create WatchRunner (periodic command polling, server-level)
@@ -2046,7 +2055,9 @@ def main() -> None:
# Create workstream manager first (watch restore_fn captures it)
manager = WorkstreamManager(
session_factory, max_workstreams=args.max_workstreams, node_id=_node_id
session_factory,
max_workstreams=config_store.get("server.max_workstreams"),
node_id=_node_id,
)
WebUI._workstream_mgr = manager
@@ -2078,7 +2089,7 @@ def main() -> None:
_watch_runner = WatchRunner(
storage=_get_storage(),
node_id=_node_id,
tool_timeout=args.tool_timeout,
tool_timeout=config_store.get("tools.timeout"),
restore_fn=_watch_restore_fn,
)
ws = manager.create(
@@ -2086,7 +2097,7 @@ def main() -> None:
ui_factory=lambda wid: WebUI(ws_id=wid),
)
assert isinstance(ws.ui, WebUI)
if args.skip_permissions:
if config_store.get("tools.skip_permissions"):
ws.ui.auto_approve = True
# Handle --resume
@@ -2124,12 +2135,13 @@ def main() -> None:
cors_origins = parse_cors_origins()
_skip_perms = config_store.get("tools.skip_permissions")
app = create_app(
workstreams=manager,
global_queue=global_queue,
global_listeners=global_listeners,
global_listeners_lock=global_listeners_lock,
skip_permissions=args.skip_permissions,
skip_permissions=_skip_perms,
auth_config=auth_config,
jwt_secret=jwt_secret,
auth_storage=get_storage(),
@@ -2137,11 +2149,12 @@ def main() -> None:
rate_limiter=rate_limiter,
mcp_client=mcp_client,
registry=registry,
idle_timeout=args.workstream_idle_timeout,
idle_timeout=config_store.get("server.workstream_idle_timeout"),
node_id=_node_id,
cors_origins=cors_origins,
watch_runner=_watch_runner,
judge_config=judge_config,
config_store=config_store,
)
log.info("Server starting on http://%s:%s", args.host, args.port)
@@ -2156,22 +2169,16 @@ def main() -> None:
mcp_client.set_storage(get_storage())
log.info(
"Health monitor: probe every %ss, circuit breaker threshold=%s",
args.health_probe_interval,
args.circuit_breaker_threshold,
config_store.get("health.backend_probe_interval"),
config_store.get("health.circuit_breaker_threshold"),
)
if rate_limiter.enabled:
proxy_info = (
f", trusted proxies: {args.ratelimit_trusted_proxies}"
if args.ratelimit_trusted_proxies
else ""
)
log.info(
"Rate limiter: %s req/s, burst=%s%s",
args.ratelimit_rps,
args.ratelimit_burst,
proxy_info,
"Rate limiter: %s req/s, burst=%s",
config_store.get("ratelimit.requests_per_second"),
config_store.get("ratelimit.burst"),
)
log.info("Max workstreams: %s", args.max_workstreams)
log.info("Max workstreams: %s", config_store.get("server.max_workstreams"))
log.info("Node ID: %s", _node_id)
print("Press Ctrl+C to stop.")
-15
View File
@@ -1,15 +0,0 @@
{
"name": "forget",
"description": "Remove a persistent memory by key. Use when the user asks to forget, remove, or delete a stored memory.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The memory key to remove (e.g. 'user_name')."
}
},
"required": ["key"]
},
"primary_key": "key"
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "memory",
"description": "Persistent memory across sessions. Actions: 'save' stores a memory, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Memories have a type (user/project/feedback/reference) and scope (global/workstream/user).",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["save", "search", "delete", "list"],
"description": "Action to perform."
},
"name": {
"type": "string",
"description": "Memory identifier (required for 'save' and 'delete'). Short snake_case key."
},
"content": {
"type": "string",
"description": "Memory content (required for 'save')."
},
"description": {
"type": "string",
"description": "Short description for relevance matching (recommended for 'save')."
},
"type": {
"type": "string",
"enum": ["user", "project", "feedback", "reference"],
"description": "Memory type. Default: 'project'."
},
"scope": {
"type": "string",
"enum": ["global", "workstream", "user"],
"description": "Memory scope. Default: 'global'. Use 'workstream' for context private to this workstream, 'user' for context that follows the user across workstreams."
},
"query": {
"type": "string",
"description": "Search query (for 'search' action)."
},
"limit": {
"type": "integer",
"description": "Max results for 'search' or 'list'. Default: 20."
}
},
"required": ["action"]
},
"primary_key": "name"
}
+5 -4
View File
@@ -1,18 +1,19 @@
{
"name": "recall",
"description": "Search memories and past conversations. With no query, lists all saved memories. With a query, searches both memories and conversation history.",
"description": "Search conversation history for past messages, tool results, and interactions across sessions.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term or phrase. Omit to list all memories."
"description": "Search term or phrase to find in conversation history."
},
"limit": {
"type": "integer",
"description": "Max conversation results to return (default 20)."
"description": "Max results to return (default 20)."
}
}
},
"required": ["query"]
},
"primary_key": "query"
}
-19
View File
@@ -1,19 +0,0 @@
{
"name": "remember",
"description": "Save a persistent memory. Memories persist across sessions. Use to remember IPs, paths, commands, conventions, or any fact worth recalling later.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Short identifier (e.g. 'user_name')."
},
"value": {
"type": "string",
"description": "Content to remember."
}
},
"required": ["key", "value"]
},
"primary_key": "key"
}