mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5cdfc8f44 | |||
| 8895bf07eb | |||
| 101afd84da | |||
| efd98712e9 | |||
| 67f43a7ee0 | |||
| 2888e8ce0a | |||
| d1a248b413 | |||
| 723cad24bb | |||
| 73cacc8ad6 |
@@ -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 | |
|
||||
|
||||
@@ -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
@@ -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
@@ -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:**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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"
|
||||
|
||||
+4473
-35
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
@@ -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 "<" in ctx
|
||||
assert "&" in ctx
|
||||
assert """ 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([]) == ""
|
||||
@@ -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") == ""
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 -------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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 & token administration">admin</button>
|
||||
<button id="admin-btn" class="header-btn" onclick="showAdmin()" title="User & 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">☾</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…</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 — 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.0–2.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.0–2.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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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/"):
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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, "")
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.")
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user