Compare commits

...

11 Commits

Author SHA1 Message Date
Patrick Buckley 0e02d1b52c release: v0.9.6 2026-03-30 06:07:44 -07:00
renovate[bot] 9b29453e9b chore(deps): lock file maintenance (#259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 05:58:00 -07:00
Patrick Buckley c4ff1caf09 fix: sync actual TLS state to ConfigStore on console startup (#258)
* fix: sync actual TLS state to ConfigStore on console startup

The console writes tls.enabled to the DB but never clears it when TLS
init fails or isn't configured.  Server nodes read the stale DB value
and attempt TLS negotiation with a non-TLS console, producing noisy
SSL errors on every startup.

Console now syncs the actual TLS state after init: if TLS succeeded,
tls.enabled=true; if it failed or wasn't attempted, tls.enabled=false.
Server TLS failure log reduced from full traceback to one-line warning.

* fix: sync TLS state to ConfigStore on console startup

Console now writes the definitive TLS state to ConfigStore so server
nodes don't attempt TLS against a non-TLS console:

- TLS init succeeded → write true
- TLS not configured (DB false/unset) → write false (definitive)
- TLS configured (DB true) but init failed → don't overwrite
  (transient failure shouldn't permanently disable)

Server TLS warning reduced to one line with exception type, full
traceback available at debug level.
2026-03-30 05:57:36 -07:00
Patrick Buckley 22245145db fix: remove hamburger menu and logout button from server UI header (#256)
Replace with a direct theme toggle button matching the console UI
pattern. Dashboard remains accessible via Ctrl+D.
2026-03-30 05:52:04 -07:00
renovate[bot] 8eacc4d632 chore(deps): lock file maintenance (#257) 2026-03-30 05:51:03 -07:00
Patrick Buckley 23fed785c4 feat: auto-detect model changes when LLM backend swaps models (#255)
* feat: auto-detect model changes when LLM backend swaps models

The BackendHealthMonitor already probes /v1/models every 30s but
discarded the response.  Now compares the detected model against the
last known one and triggers a registry reload when it changes.

- Extract _extract_context_window() helper for reuse across
  detect_model, probe_model_endpoint, and the health monitor
- BackendHealthMonitor: new provider/initial_model/on_model_changed
  params; _check_model_change() fires callback on model swap
- Server: wire _handle_model_change callback that updates cli_model_args
  and calls registry.reload(); guarded by _user_specified_model flag
  so --model overrides are never auto-replaced
- Session: _refresh_model_from_registry() called at top of send();
  two string compares when nothing changed, full re-resolve on swap
- 7 new tests for _extract_context_window and model change detection

* fix: address Copilot review on model re-detection

- server: update cli_model_args only after successful reload (not
  before), add finally block for new_reg.shutdown(), guard against
  cli_model_args not yet initialized
- session: wrap registry lookup in try/except for concurrent reload
  race, reset judge on model change, recompute tool_truncation when
  context_window changes in auto mode
2026-03-30 05:43:53 -07:00
Patrick Buckley 688c27e68a feat: replace generic system prompt with resident engineer persona
Replace "You are an expert software engineer" with a grounded
narrative persona: a resident engineer on a focused team with real
tools, real code, and real consequences.  Sets expectations about
boundaries, judgment calls, and working within constraints.
2026-03-30 05:13:29 -07:00
Patrick Buckley 405baf7cb2 fix: memory list/search cross-workstream scope leak (#253)
* fix: scope-filter memory list/search to current workstream and user

Unscoped memory(action='list') and memory(action='search') returned all
memories across all workstreams. Now applies the same 3-query pattern
(global + current workstream + current user) used by system prompt
injection.

* fix: validate user scope on memory search/list for unauthenticated sessions

Adds _validate_scope guard to search and list prepare paths, matching
save/get/delete. Prevents explicit scope='user' from returning all
user-scoped memories when session is unauthenticated.

* fix: update _get_visible_memories references to _list_visible_memories

* fix: defense-in-depth guard for empty scope_id on search/list

Copilot review: if scope is 'user' or 'workstream' with empty
scope_id, the storage query returns all memories in that scope
across all users/workstreams.  The prepare step already validates
via _validate_scope, but add exec-level guard to reject scoped
queries with empty scope_id as defense-in-depth.
2026-03-30 04:43:49 -07:00
Patrick Buckley 1027c22333 fix: add procps and file to Docker image (#254)
Agents need ps for process inspection and file for identifying file
types.  Both were missing from the slim base image.
2026-03-30 04:30:36 -07:00
Patrick Buckley 381651049b fix: detect context window from vLLM max_model_len field (#252)
* fix: detect context window from vLLM max_model_len field

vLLM exposes the context window as max_model_len on the model object,
not meta.n_ctx_train (llama.cpp format).  Both detect_model() and
probe_model_endpoint() now check max_model_len first, falling back
to meta.n_ctx_train for llama.cpp.  Fixes 32768 fallback on vLLM
servers that report 262144+ token context windows.

* test: add vLLM max_model_len detection tests

Copilot review: new vLLM context window path had no test coverage.
Add tests for probe_model_endpoint (max_model_len detected, preferred
over meta.n_ctx_train) and detect_model (vLLM model object with
max_model_len).
2026-03-30 04:19:24 -07:00
renovate[bot] 322b7dabc4 chore(deps): lock file maintenance (#251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 04:07:28 -07:00
17 changed files with 591 additions and 265 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 git curl jq man-db manpages \
libpq5 git curl jq man-db manpages procps file \
&& rm -rf /var/lib/apt/lists/*
# Non-root user
+1 -1
View File
@@ -61,7 +61,7 @@ end
Session -> Session : _init_system_messages()\nevery conversation turn
Session -> Session : _get_visible_memories(\nlimit=fetch_limit)
Session -> Session : _list_visible_memories(\nlimit=fetch_limit)
note right
**Scope resolution:**
1. global scope (always)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.9.5"
version = "0.9.6"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+1 -1
View File
@@ -436,7 +436,7 @@ class TestSkillCatalogDisclosure:
"turnstone.core.session.list_skills_by_activation",
return_value=search_skills or [],
),
patch.object(session, "_get_visible_memories", return_value=[]),
patch.object(session, "_list_visible_memories", return_value=[]),
):
session._init_system_messages()
+24
View File
@@ -20,12 +20,15 @@ def _mock_model(
*,
owned_by: str = "test",
meta: dict[str, Any] | None = None,
**kwargs: Any,
) -> MagicMock:
m = MagicMock()
m.id = model_id
dumped: dict[str, Any] = {"owned_by": owned_by}
if meta is not None:
dumped["meta"] = meta
if kwargs.get("max_model_len") is not None:
dumped["max_model_len"] = kwargs["max_model_len"]
m.model_dump.return_value = dumped
return m
@@ -109,6 +112,27 @@ class TestProbeModelEndpoint:
result = probe_model_endpoint("openai", "http://localhost:30000/v1", "key")
assert result["server_type"] == "sglang"
@patch("turnstone.core.providers.create_client")
def test_vllm_max_model_len(self, mock_cc: MagicMock) -> None:
m = _mock_model("/models/nemotron", max_model_len=262144, owned_by="vllm")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["context_window"] == 262144
assert result["server_type"] == "vllm"
@patch("turnstone.core.providers.create_client")
def test_vllm_max_model_len_preferred_over_meta(self, mock_cc: MagicMock) -> None:
m = _mock_model(
"/models/test",
meta={"n_ctx_train": 8192},
max_model_len=131072,
)
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["context_window"] == 131072
@patch("turnstone.core.providers.create_client")
def test_server_type_vllm(self, mock_cc: MagicMock) -> None:
m = _mock_model("org/model-name")
+122
View File
@@ -899,3 +899,125 @@ class TestDetectModelTimeout:
result = detect_model(client, provider="openai", fatal=False)
assert result == (None, None)
def test_vllm_max_model_len_detected(self) -> None:
"""detect_model() reads max_model_len from vLLM model objects."""
mock_model = MagicMock()
mock_model.id = "/models/nemotron"
mock_model.model_dump.return_value = {
"owned_by": "vllm",
"max_model_len": 262144,
}
fast_client = MagicMock()
fast_client.models.list.return_value = MagicMock(data=[mock_model])
client = MagicMock()
client.with_options.return_value = fast_client
model_id, ctx = detect_model(client, provider="openai")
assert model_id == "/models/nemotron"
assert ctx == 262144
class TestExtractContextWindow:
def test_vllm_max_model_len(self) -> None:
from turnstone.core.model_registry import _extract_context_window
m = MagicMock()
m.id = "/models/test"
m.model_dump.return_value = {"max_model_len": 131072}
assert _extract_context_window(m, "openai") == 131072
def test_llama_cpp_meta(self) -> None:
from turnstone.core.model_registry import _extract_context_window
m = MagicMock()
m.id = "test"
m.model_dump.return_value = {"meta": {"n_ctx_train": 8192}}
assert _extract_context_window(m, "openai") == 8192
def test_vllm_preferred_over_meta(self) -> None:
from turnstone.core.model_registry import _extract_context_window
m = MagicMock()
m.id = "test"
m.model_dump.return_value = {"max_model_len": 262144, "meta": {"n_ctx_train": 4096}}
assert _extract_context_window(m, "openai") == 262144
def test_no_metadata_returns_none(self) -> None:
from turnstone.core.model_registry import _extract_context_window
m = MagicMock()
m.id = "test"
m.model_dump.return_value = {}
assert _extract_context_window(m, "openai") is None
class TestHealthMonitorModelChange:
def test_model_change_fires_callback(self) -> None:
from turnstone.core.healthcheck import BackendHealthMonitor
changes: list[tuple[str, int | None]] = []
def on_change(model_id: str, ctx: int | None) -> None:
changes.append((model_id, ctx))
client = MagicMock()
monitor = BackendHealthMonitor(
client=client,
provider="openai",
initial_model="model-a",
on_model_changed=on_change,
)
# Simulate probe returning a different model
resp = MagicMock()
m = MagicMock()
m.id = "model-b"
m.model_dump.return_value = {"max_model_len": 131072}
resp.data = [m]
monitor._check_model_change(resp)
assert len(changes) == 1
assert changes[0] == ("model-b", 131072)
assert monitor._last_detected_model == "model-b"
def test_same_model_no_callback(self) -> None:
from turnstone.core.healthcheck import BackendHealthMonitor
changes: list[tuple[str, int | None]] = []
def on_change(model_id: str, ctx: int | None) -> None:
changes.append((model_id, ctx))
client = MagicMock()
monitor = BackendHealthMonitor(
client=client,
provider="openai",
initial_model="model-a",
on_model_changed=on_change,
)
resp = MagicMock()
m = MagicMock()
m.id = "model-a"
m.model_dump.return_value = {}
resp.data = [m]
monitor._check_model_change(resp)
assert len(changes) == 0
def test_no_callback_configured(self) -> None:
from turnstone.core.healthcheck import BackendHealthMonitor
client = MagicMock()
monitor = BackendHealthMonitor(client=client, initial_model="model-a")
resp = MagicMock()
m = MagicMock()
m.id = "model-b"
resp.data = [m]
# Should not raise
monitor._check_model_change(resp)
+106
View File
@@ -104,3 +104,109 @@ class TestCountStructuredMemories:
class TestNormalizeKey:
def test_basic(self):
assert normalize_key("My-Key Name") == "my_key_name"
class TestScopeIsolation:
"""Verify that list/search without scope only returns visible memories.
Reproduces the cross-workstream leak: unscoped list/search must not
return workstream-scoped memories from other workstreams or
user-scoped memories from other users.
"""
def _seed(self):
"""Create memories across multiple scopes."""
save_structured_memory("global_note", "visible to all", scope="global")
save_structured_memory("ws1_note", "belongs to ws1", scope="workstream", scope_id="ws1")
save_structured_memory("ws2_note", "belongs to ws2", scope="workstream", scope_id="ws2")
save_structured_memory("u1_note", "belongs to user1", scope="user", scope_id="u1")
save_structured_memory("u2_note", "belongs to user2", scope="user", scope_id="u2")
@staticmethod
def _list_visible(ws_id: str, user_id: str, mem_type: str = "", limit: int = 50):
"""Replicate the scope-filtered list logic from ChatSession."""
global_mems = list_structured_memories(mem_type=mem_type, scope="global", limit=limit)
ws_mems = list_structured_memories(
mem_type=mem_type, scope="workstream", scope_id=ws_id, limit=limit
)
user_mems = (
list_structured_memories(mem_type=mem_type, scope="user", scope_id=user_id, limit=limit)
if user_id
else []
)
combined = global_mems + ws_mems + user_mems
combined.sort(key=lambda m: m.get("updated", ""), reverse=True)
return combined[:limit]
@staticmethod
def _search_visible(query: str, ws_id: str, user_id: str, mem_type: str = "", limit: int = 20):
"""Replicate the scope-filtered search logic from ChatSession."""
global_mems = search_structured_memories(
query, mem_type=mem_type, scope="global", limit=limit
)
ws_mems = search_structured_memories(
query, mem_type=mem_type, scope="workstream", scope_id=ws_id, limit=limit
)
user_mems = (
search_structured_memories(
query, mem_type=mem_type, scope="user", scope_id=user_id, limit=limit
)
if user_id
else []
)
combined = global_mems + ws_mems + user_mems
combined.sort(key=lambda m: m.get("updated", ""), reverse=True)
return combined[:limit]
def test_unscoped_list_returns_all_scopes(self, tmp_db):
"""Demonstrate the leak: unscoped list returns everything."""
self._seed()
all_mems = list_structured_memories()
assert len(all_mems) == 5 # no scope filter → all memories
def test_visible_list_excludes_other_workstreams(self, tmp_db):
"""Scope-filtered list for ws1/u1 excludes ws2 and u2 memories."""
self._seed()
visible = self._list_visible("ws1", "u1")
names = {m["name"] for m in visible}
assert "global_note" in names
assert "ws1_note" in names
assert "u1_note" in names
assert "ws2_note" not in names
assert "u2_note" not in names
def test_visible_list_no_user(self, tmp_db):
"""Scope-filtered list with no user_id excludes all user memories."""
self._seed()
visible = self._list_visible("ws1", "")
names = {m["name"] for m in visible}
assert "global_note" in names
assert "ws1_note" in names
assert "u1_note" not in names
assert "u2_note" not in names
def test_visible_search_no_user(self, tmp_db):
"""Scope-filtered search with no user_id excludes all user memories."""
self._seed()
visible = self._search_visible("belongs", "ws1", "")
names = {m["name"] for m in visible}
assert "ws1_note" in names
assert "u1_note" not in names
assert "u2_note" not in names
def test_visible_search_excludes_other_workstreams(self, tmp_db):
"""Scope-filtered search for ws1/u1 excludes ws2 and u2 memories."""
self._seed()
visible = self._search_visible("belongs", "ws1", "u1")
names = {m["name"] for m in visible}
assert "ws1_note" in names
assert "u1_note" in names
assert "ws2_note" not in names
assert "u2_note" not in names
def test_explicit_scope_still_works(self, tmp_db):
"""Explicit scope filter continues to work as before."""
self._seed()
ws2_only = list_structured_memories(scope="workstream", scope_id="ws2")
assert len(ws2_only) == 1
assert ws2_only[0]["name"] == "ws2_note"
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.9.5"
__version__ = "0.9.6"
+23
View File
@@ -5869,8 +5869,31 @@ def main() -> None:
log.info("TLS enabled")
except ImportError:
log.warning("TLS enabled but lacme not installed — pip install turnstone[tls]")
tls_mgr = None
except Exception:
log.warning("TLS initialization failed", exc_info=True)
tls_mgr = None
# Sync TLS state to ConfigStore so server nodes see the correct value.
# Three cases:
# 1. TLS succeeded → write true
# 2. TLS not configured (DB false/unset) → write false (definitive)
# 3. TLS configured (DB true) but init failed → don't overwrite
# (transient failure shouldn't permanently disable TLS)
try:
db_enabled = _cs.get("tls.enabled")
if tls_mgr is not None:
if not db_enabled:
_cs.set("tls.enabled", True, changed_by="console-startup")
elif db_enabled:
log.warning(
"tls.enabled is true in ConfigStore but TLS init failed — "
"server nodes will attempt TLS and fall back to plain HTTP"
)
else:
_cs.set("tls.enabled", False, changed_by="console-startup")
except Exception:
log.debug("Failed to sync TLS state to ConfigStore", exc_info=True)
app = create_app(
collector=collector,
+41 -2
View File
@@ -5,11 +5,13 @@ from __future__ import annotations
import enum
import threading
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from turnstone.core.log import get_logger
if TYPE_CHECKING:
from collections.abc import Callable
from openai import OpenAI
log = get_logger(__name__)
@@ -37,6 +39,10 @@ class BackendHealthMonitor:
probe_timeout: float = 5.0,
failure_threshold: int = 5,
cooldown: float = 60.0,
*,
provider: str = "openai",
initial_model: str = "",
on_model_changed: Callable[[str, int | None], None] | None = None,
) -> None:
self._client = client
self._probe_interval = probe_interval
@@ -44,6 +50,11 @@ class BackendHealthMonitor:
self._failure_threshold = failure_threshold
self._cooldown = cooldown
# Model change detection
self._provider = provider
self._last_detected_model = initial_model
self._on_model_changed = on_model_changed
self._lock = threading.Lock()
self._state = CircuitState.CLOSED
self._consecutive_failures = 0
@@ -197,11 +208,39 @@ class BackendHealthMonitor:
def _probe_once(self) -> bool:
"""Single probe: call ``client.models.list()``. Returns True on success."""
try:
self._client.with_options(timeout=self._probe_timeout).models.list()
resp = self._client.with_options(timeout=self._probe_timeout).models.list()
self._check_model_change(resp)
return True
except Exception:
return False
def _check_model_change(self, resp: Any) -> None:
"""Compare detected model against last known and fire callback if changed."""
if not self._on_model_changed or not resp.data:
return
try:
from turnstone.core.model_registry import (
_extract_context_window,
_select_best_model,
)
all_ids = [m.id for m in resp.data]
selected = _select_best_model(all_ids, self._provider)
if selected == self._last_detected_model:
return
model_obj = next((m for m in resp.data if m.id == selected), None)
ctx = _extract_context_window(model_obj, self._provider) if model_obj else None
log.info(
"Backend model changed: %s -> %s (ctx=%s)",
self._last_detected_model,
selected,
ctx,
)
self._last_detected_model = selected
self._on_model_changed(selected, ctx)
except Exception:
log.debug("Model change check failed", exc_info=True)
# ------------------------------------------------------------------
# Metrics
# ------------------------------------------------------------------
+29 -18
View File
@@ -366,6 +366,28 @@ def _select_best_model(model_ids: list[str], provider: str) -> str:
return model_ids[0]
def _extract_context_window(model_obj: Any, provider: str) -> int | None:
"""Extract context window from a model object returned by ``/v1/models``.
Handles Anthropic (static capability table), vLLM (``max_model_len``),
and llama.cpp (``meta.n_ctx_train``). Returns ``None`` when not available.
"""
if provider == "anthropic":
from turnstone.core.providers._anthropic import AnthropicProvider
return AnthropicProvider().get_capabilities(model_obj.id).context_window
model_data = model_obj.model_dump()
max_len = model_data.get("max_model_len")
if isinstance(max_len, int) and max_len > 0:
return max_len
meta = model_data.get("meta")
if isinstance(meta, dict):
n_ctx = meta.get("n_ctx_train")
if isinstance(n_ctx, int) and n_ctx > 0:
return n_ctx
return None
def detect_model(
client: Any,
log_fn: Any = print,
@@ -411,18 +433,7 @@ def detect_model(
log_fn(f"Available models: {', '.join(all_ids)}")
log_fn(f"Using: {m.id} (override with --model)")
ctx: int | None = None
if provider == "anthropic":
from turnstone.core.providers._anthropic import AnthropicProvider
ctx = AnthropicProvider().get_capabilities(m.id).context_window
else:
# OpenAI-compatible: extract from backend metadata (llama.cpp, vLLM)
meta = m.model_dump().get("meta")
if isinstance(meta, dict):
n_ctx = meta.get("n_ctx_train")
if isinstance(n_ctx, int) and n_ctx > 0:
ctx = n_ctx
ctx = _extract_context_window(m, provider)
return m.id, ctx
except SystemExit:
raise
@@ -514,6 +525,7 @@ def _detect_openai_compat(
meta: dict[str, Any] | None = None
owned_by: str = ""
dumped: dict[str, Any] = {}
if model_obj is not None:
dumped = model_obj.model_dump()
raw_meta = dumped.get("meta")
@@ -521,12 +533,11 @@ def _detect_openai_compat(
meta = raw_meta
owned_by = str(dumped.get("owned_by", ""))
# Context window: prefer backend metadata, fall back to static table
# (only for known models — the default 200k would be misleading for local servers)
if meta is not None:
n_ctx = meta.get("n_ctx_train")
if isinstance(n_ctx, int) and n_ctx > 0:
result["context_window"] = n_ctx
# Context window: prefer backend metadata, fall back to static table.
if model_obj is not None:
ctx = _extract_context_window(model_obj, "openai")
if ctx is not None:
result["context_window"] = ctx
if result["context_window"] is None:
from turnstone.core.providers import lookup_model_capabilities
+138 -28
View File
@@ -648,6 +648,43 @@ class ChatSession:
"""
self._init_system_messages()
def _refresh_model_from_registry(self) -> None:
"""Re-resolve model from registry if the backend changed.
Called at the top of ``send()`` two string compares when nothing
changed, full re-resolve when the health monitor detected a model swap.
"""
if not self._registry or not self._model_alias:
return
try:
if not self._registry.has_alias(self._model_alias):
return
cfg = self._registry.get_config(self._model_alias)
if cfg.model == self.model:
return
client, model_name, new_cfg = self._registry.resolve(self._model_alias)
except (ValueError, KeyError):
return # alias disappeared during concurrent reload
self.client = client
self.model = model_name
self._provider = self._registry.get_provider(self._model_alias)
self._cached_capabilities = None
if new_cfg.context_window and new_cfg.context_window != self.context_window:
self.context_window = new_cfg.context_window
# Recompute auto tool truncation for new context window
if not self._manual_tool_truncation:
self.tool_truncation = int(new_cfg.context_window * self._chars_per_token * 0.5)
# Reset judge so it picks up the new model/provider
if self._judge is not None:
self._judge = None
self._init_system_messages()
log.info(
"session.model_updated ws=%s model=%s ctx=%d",
self._ws_id,
model_name,
self.context_window,
)
def _rebuild_tool_search(self) -> None:
"""Reconstruct ToolSearchManager, preserving expanded tools."""
old_expanded = self._tool_search.get_expanded_names() if self._tool_search else []
@@ -952,9 +989,24 @@ class ChatSession:
]
else:
dev_parts = [
"You are an expert software engineer. You solve problems "
"by reading code, making targeted edits, and running commands. "
"Always respond with tool calls, not just text.\n\n"
"You are a resident engineer on a small, focused infrastructure team. "
"Your workspace is an instrumented workbench — a terminal with tools for "
"reading, writing, searching, and executing code. You've been here a while. "
"You know the codebase. You know the tools. You know their limits.\n\n"
"Your team trusts you with real work: investigating bugs, implementing features, "
"reviewing security, writing code that ships. You have access to the project's "
"files, git history, and a running database. You don't have access to everything "
"— some tools require approval, some paths are restricted, and that's by design. "
"You work within those boundaries.\n\n"
"You think before you act. You read before you edit. You verify before you commit. "
"When something breaks, you diagnose before you retry. When you're uncertain, you "
"say so. When a request is ambiguous, you make a reasonable call and note what you "
"assumed — you don't stall asking for permission on every judgment call.\n\n"
"When you disagree with a direction, you push back with reasoning — then defer to "
"the team's call.\n\n"
"You are not performing a demo. There is no audience. The code you write will run. "
"The files you edit are real. The commits you make go to a shared repository. "
"Act accordingly.\n\n"
"TOOL PATTERNS:\n\n"
"Modify existing file → read_file then edit_file:\n"
" read_file(path='config.py') → "
@@ -1094,7 +1146,7 @@ class ChatSession:
if self.instructions:
dev_parts.append("")
dev_parts.append(self.instructions)
visible_mems = self._get_visible_memories(limit=self._mem_cfg.fetch_limit)
visible_mems = self._list_visible_memories(limit=self._mem_cfg.fetch_limit)
if visible_mems:
context = extract_recent_context(self.messages)
relevant = score_memories(visible_mems, context, k=self._mem_cfg.relevance_k)
@@ -1323,6 +1375,7 @@ class ChatSession:
def send(self, user_input: str) -> None:
"""Send user input and handle the response loop (including tool calls)."""
self._refresh_model_from_registry()
# Token budget approval gate
if self._budget_exhausted:
approved, _ = self.ui.approve_tools(
@@ -3567,17 +3620,6 @@ class ChatSession:
}
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")
@@ -3586,6 +3628,40 @@ class ChatSession:
n += count_structured_memories(scope="user", scope_id=self._user_id)
return n
def _list_visible_memories(self, mem_type: str = "", limit: int = 50) -> list[dict[str, str]]:
"""List memories visible to this session with optional type filter."""
global_mems = list_structured_memories(mem_type=mem_type, scope="global", limit=limit)
ws_mems = list_structured_memories(
mem_type=mem_type, scope="workstream", scope_id=self._ws_id, limit=limit
)
user_mems: list[dict[str, str]] = []
if self._user_id:
user_mems = list_structured_memories(
mem_type=mem_type, 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 _search_visible_memories(
self, query: str, mem_type: str = "", limit: int = 20
) -> list[dict[str, str]]:
"""Search memories visible to this session (scope-filtered)."""
global_mems = search_structured_memories(
query, mem_type=mem_type, scope="global", limit=limit
)
ws_mems = search_structured_memories(
query, mem_type=mem_type, scope="workstream", scope_id=self._ws_id, limit=limit
)
user_mems: list[dict[str, str]] = []
if self._user_id:
user_mems = search_structured_memories(
query, mem_type=mem_type, 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 _check_metacognitive_nudge(self, user_message: str) -> tuple[str, str] | None:
"""Check if a metacognitive nudge should be injected.
@@ -3791,6 +3867,10 @@ class ChatSession:
scope = (args.get("scope") or "").strip().lower()
if scope and scope not in ("global", "workstream", "user"):
scope = ""
if scope:
scope_err = self._validate_scope(scope, call_id)
if scope_err:
return scope_err
scope_id = self._resolve_scope_id(scope) if scope else ""
limit = args.get("limit", 20)
if isinstance(limit, str):
@@ -3820,6 +3900,10 @@ class ChatSession:
scope = (args.get("scope") or "").strip().lower()
if scope and scope not in ("global", "workstream", "user"):
scope = ""
if scope:
scope_err = self._validate_scope(scope, call_id)
if scope_err:
return scope_err
scope_id = self._resolve_scope_id(scope) if scope else ""
limit = args.get("limit", 20)
if isinstance(limit, str):
@@ -5164,13 +5248,27 @@ class ChatSession:
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"],
)
scope = item.get("scope", "")
scope_id = item.get("scope_id", "")
# Defense-in-depth: reject scoped queries with empty scope_id
if scope in ("user", "workstream") and not scope_id:
msg = f"Error: '{scope}' scope requires a valid identity"
self._report_tool_result(call_id, "memory", msg, is_error=True)
return call_id, msg
if scope:
rows = search_structured_memories(
item["query"],
mem_type=item.get("mem_type", ""),
scope=scope,
scope_id=scope_id,
limit=item["limit"],
)
else:
rows = self._search_visible_memories(
item["query"],
mem_type=item.get("mem_type", ""),
limit=item["limit"],
)
if rows:
lines = []
for m in rows:
@@ -5193,12 +5291,24 @@ class ChatSession:
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"],
)
scope = item.get("scope", "")
scope_id = item.get("scope_id", "")
if scope in ("user", "workstream") and not scope_id:
msg = f"Error: '{scope}' scope requires a valid identity"
self._report_tool_result(call_id, "memory", msg, is_error=True)
return call_id, msg
if scope:
rows = list_structured_memories(
mem_type=item.get("mem_type", ""),
scope=scope,
scope_id=scope_id,
limit=item["limit"],
)
else:
rows = self._list_visible_memories(
mem_type=item.get("mem_type", ""),
limit=item["limit"],
)
if rows:
lines = []
for m in rows:
+39 -2
View File
@@ -2477,12 +2477,43 @@ def main() -> None:
# Backend health monitor with circuit breaker
from turnstone.core.healthcheck import BackendHealthMonitor
def _handle_model_change(new_model_id: str, new_ctx: int | None) -> None:
"""Called from health probe thread when backend model changes."""
cli_args = getattr(getattr(app, "state", None), "cli_model_args", None)
if not cli_args or cli_args.get("_user_specified_model"):
return
old_model = cli_args["model"]
ctx = new_ctx or cli_args["context_window"]
log.info("Backend model changed: %s -> %s (ctx=%s)", old_model, new_model_id, ctx)
new_reg = None
try:
new_reg = load_model_registry(
base_url=cli_args["base_url"],
api_key=cli_args["api_key"],
model=new_model_id,
context_window=ctx,
provider=cli_args["provider"],
storage=get_storage(),
)
registry.reload(new_reg.models, new_reg.default, new_reg.fallback, new_reg.agent_model)
# Update cli_model_args only after successful reload
cli_args["model"] = new_model_id
cli_args["context_window"] = ctx
except Exception:
log.warning("Model change reload failed", exc_info=True)
finally:
if new_reg is not None:
new_reg.shutdown()
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"),
provider=provider_name,
initial_model=model,
on_model_changed=_handle_model_change,
)
health_monitor.start()
@@ -2701,6 +2732,7 @@ def main() -> None:
"model": model,
"context_window": context_window,
"provider": provider_name,
"_user_specified_model": bool(effective_model),
}
log.info("Server starting on http://%s:%s", args.host, args.port)
@@ -2768,8 +2800,13 @@ def main() -> None:
log.info("TLS enabled — serving HTTPS")
else:
log.warning("TLS enabled but no cert available")
except Exception:
log.warning("TLS initialization failed — serving plain HTTP", exc_info=True)
except Exception as exc:
log.warning(
"TLS initialization failed — serving plain HTTP: %s: %s",
type(exc).__name__,
exc,
)
log.debug("TLS init traceback", exc_info=True)
print("Press Ctrl+C to stop.")
+21 -99
View File
@@ -2151,101 +2151,34 @@ window.onLogout = function () {
};
// ===========================================================================
// 7. Theme + hamburger menu
// 7. Theme toggle
// ===========================================================================
function updateThemeMenuItem() {
var isLight = document.documentElement.dataset.theme === "light";
document.getElementById("theme-menu-icon").textContent = isLight
? "\u263E"
: "\u2600";
document.getElementById("theme-menu-label").textContent = isLight
? "Dark mode"
: "Light mode";
document
.getElementById("theme-menu-item")
.setAttribute(
window.onThemeChange = function (next) {
var btn = document.getElementById("theme-toggle");
if (btn) {
var isLight = next === "light";
btn.textContent = isLight ? "\u2600" : "\u263E";
btn.title = isLight ? "Switch to dark theme" : "Switch to light theme";
btn.setAttribute(
"aria-label",
isLight
? "Switch to dark mode (currently light)"
: "Switch to light mode (currently dark)",
isLight ? "Switch to dark theme" : "Switch to light theme",
);
}
window.onThemeChange = function () {
updateThemeMenuItem();
}
reRenderAllMermaid();
};
updateThemeMenuItem();
function toggleHamburger() {
var menu = document.getElementById("hamburger-menu");
var btn = document.getElementById("hamburger-btn");
var open = menu.classList.toggle("open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
if (open) {
updateThemeMenuItem();
var first = menu.querySelector(".hmenu-item");
if (first) first.focus();
(function () {
var btn = document.getElementById("theme-toggle");
if (btn) {
var isLight = document.documentElement.dataset.theme === "light";
btn.textContent = isLight ? "\u2600" : "\u263E";
btn.title = isLight ? "Switch to dark theme" : "Switch to light theme";
btn.setAttribute(
"aria-label",
isLight ? "Switch to dark theme" : "Switch to light theme",
);
}
}
function closeHamburger() {
document.getElementById("hamburger-menu").classList.remove("open");
document
.getElementById("hamburger-btn")
.setAttribute("aria-expanded", "false");
}
function hamburgerDashboard() {
closeHamburger();
toggleDashboard();
}
function hamburgerTheme() {
toggleTheme();
closeHamburger();
}
// Close on outside click
document.addEventListener("click", function (e) {
var wrap = document.getElementById("hamburger-wrap");
if (wrap && !wrap.contains(e.target)) closeHamburger();
});
// Keyboard nav within menu
document
.getElementById("hamburger-menu")
.addEventListener("keydown", function (e) {
var items = Array.from(this.querySelectorAll(".hmenu-item"));
var idx = items.indexOf(document.activeElement);
if (e.key === "ArrowDown") {
e.preventDefault();
items[(idx + 1) % items.length].focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
items[(idx - 1 + items.length) % items.length].focus();
} else if (e.key === "Home") {
e.preventDefault();
items[0].focus();
} else if (e.key === "End") {
e.preventDefault();
items[items.length - 1].focus();
} else if (e.key === "Escape") {
e.preventDefault();
closeHamburger();
document.getElementById("hamburger-btn").focus();
} else if (e.key === "Tab") {
closeHamburger();
}
});
// Escape when focus is on the button itself
document
.getElementById("hamburger-btn")
.addEventListener("keydown", function (e) {
if (
e.key === "Escape" &&
document.getElementById("hamburger-menu").classList.contains("open")
) {
e.preventDefault();
closeHamburger();
}
});
})();
// ===========================================================================
// 8. Tab bar
@@ -2641,7 +2574,6 @@ function closeWorkstream(wsId) {
function showDashboard() {
dashboardVisible = true;
closeHamburger();
document.getElementById("dashboard").classList.add("active");
document.getElementById("header").inert = true;
document.getElementById("tab-bar").inert = true;
@@ -3350,16 +3282,6 @@ document.addEventListener("keydown", function (e) {
var nwsOverlay = document.getElementById("new-ws-overlay");
if (nwsOverlay && nwsOverlay.style.display !== "none") return;
// Escape: close hamburger first, then dashboard
if (
e.key === "Escape" &&
document.getElementById("hamburger-menu").classList.contains("open")
) {
e.preventDefault();
closeHamburger();
document.getElementById("hamburger-btn").focus();
return;
}
if (e.key === "Escape" && dashboardVisible) {
e.preventDefault();
hideDashboard();
+2 -16
View File
@@ -13,24 +13,10 @@
</head>
<body>
<div id="header">
<div id="hamburger-wrap">
<button id="hamburger-btn" onclick="toggleHamburger()" aria-label="Menu" aria-haspopup="true" aria-expanded="false" aria-controls="hamburger-menu">
<span></span><span></span><span></span>
</button>
<div id="hamburger-menu" role="menu">
<button class="hmenu-item" role="menuitem" tabindex="-1" onclick="hamburgerDashboard()">
<span class="hmenu-icon">&#8962;</span> Dashboard
</button>
<div class="hmenu-sep" role="separator"></div>
<button class="hmenu-item" role="menuitem" tabindex="-1" id="theme-menu-item" onclick="hamburgerTheme()">
<span class="hmenu-icon" id="theme-menu-icon">&#9790;</span> <span id="theme-menu-label">Light mode</span>
</button>
</div>
</div>
<h1>turnstone</h1>
<span id="mcp-status" role="status" aria-live="polite"></span>
<span id="health-indicator" class="health-ok" role="status" aria-live="polite" aria-atomic="true"></span>
<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" title="Switch to light theme">&#9790;</button>
</div>
<div id="tab-bar" role="toolbar" aria-label="Workstreams">
@@ -132,7 +118,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
]},
{ title: "Navigation", keys: [
{ desc: "Navigate table rows", badge: '<span class="kb-key">\u2191</span> <span class="kb-key">\u2193</span>' },
{ desc: "Close dashboard / menu", badge: '<span class="kb-key">Esc</span>' }
{ desc: "Close dashboard", badge: '<span class="kb-key">Esc</span>' }
]},
{ title: "General", keys: [
{ desc: "Show this help", badge: '<span class="kb-key">?</span>' }
+8 -62
View File
@@ -43,68 +43,14 @@
}
/* ==========================================================================
Hamburger menu
Theme toggle
========================================================================== */
#theme-toggle { color: var(--fg); margin-left: auto; font-size: 14px; line-height: 1; }
/* ==========================================================================
Mobile overrides
========================================================================== */
#hamburger-wrap { position: relative; }
#hamburger-btn {
background: none;
border: 1px solid var(--border-strong);
color: var(--fg);
border-radius: var(--radius-sm);
width: 32px;
height: 32px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0;
flex-shrink: 0;
transition: background 0.15s, border-color 0.15s;
}
#hamburger-btn:hover { background: var(--bg-highlight); border-color: var(--accent-dim); }
#hamburger-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
#hamburger-btn span { display: block; width: 14px; height: 2px; background: var(--fg); border-radius: 1px; }
#hamburger-menu {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 40;
overflow: hidden;
}
[data-theme="light"] #hamburger-menu { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
#hamburger-menu.open { display: block; }
.hmenu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
background: none;
border: none;
color: var(--fg);
font: inherit;
font-family: var(--font-display);
font-size: 13px;
cursor: pointer;
text-align: left;
white-space: nowrap;
transition: background 0.1s;
}
.hmenu-item:hover { background: var(--bg-highlight); }
.hmenu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.hmenu-item .hmenu-icon { width: 16px; text-align: center; font-size: 14px; opacity: 0.8; }
.hmenu-sep { height: 1px; background: var(--border-strong); margin: 4px 0; }
@media (max-width: 600px) {
#hamburger-btn { width: 40px; height: 40px; }
.hmenu-item { padding: 12px 14px; }
.ws-tab .tab-close { opacity: 1; padding: 4px 6px; font-size: 16px; }
#split-btn { display: none; }
}
@@ -1514,11 +1460,11 @@ body { position: static; }
.judge-spinner-dot { animation: none; opacity: 1; }
.thinking-indicator::after { animation: none; content: '...'; }
.ws-tab, .ws-tab .tab-close, #new-tab-btn, #split-btn,
.hmenu-item, .dashboard-card,
.dashboard-card,
.approval-btn, .approval-feedback-input,
#plan-buttons button, .pane-input-area button,
.dashboard-new-btn, .dashboard-input,
#health-indicator, #hamburger-btn,
#health-indicator, #theme-toggle,
#mcp-status, .msg-assistant tbody tr,
.msg-assistant .img-placeholder,
#new-ws-cancel, #new-ws-submit,
Generated
+33 -33
View File
@@ -1582,40 +1582,40 @@ wheels = [
[[package]]
name = "primp"
version = "1.2.0"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/47/b3bc742632ceec08125f44f65d11fed668e977dabe8254e8262322d9be9c/primp-1.2.0.tar.gz", hash = "sha256:e5a7238888880ef8b7cb0a91f9e43b6b126182dd7466f8cd694815dbd573b9d8", size = 163029, upload-time = "2026-03-27T06:48:03.39Z" }
sdist = { url = "https://files.pythonhosted.org/packages/15/d1/e4df56552783475e6e580059ab88733e0934e0c9d7b38438501f15c8e06b/primp-1.2.1.tar.gz", hash = "sha256:77da763a7b5ab435e94f667da480ef3aab868d844badd30d1206b79ac5b460a5", size = 165839, upload-time = "2026-03-30T12:19:03.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/77/148bde5c61658818dfe9f7abe20dde3aa66c693058a7fbf6b74da6030587/primp-1.2.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bbc5c0f9961087d9e4fccc5f58b5c6c1b620dd36f55df5d61175a34dd4a6f205", size = 4340702, upload-time = "2026-03-27T06:48:16.822Z" },
{ url = "https://files.pythonhosted.org/packages/46/13/da986592d85739328d9a40b68d17e42e13bc91a973c62c2ab69066ce01ad/primp-1.2.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:94283e1874b084d0d0104260f4a2b3b61c7836c6983476fc8a5d5f6b8e41277f", size = 4016031, upload-time = "2026-03-27T06:48:01.947Z" },
{ url = "https://files.pythonhosted.org/packages/01/8f/13520f647e7faeaaab065a787d1674ef6613e56781f97e6642a2999f29fe/primp-1.2.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e5caea2c774f314e504b5da3ae189ba1f0406712d746e037f6990614c0eab97", size = 4294852, upload-time = "2026-03-27T06:48:07.075Z" },
{ url = "https://files.pythonhosted.org/packages/49/06/2b480ed4c66e00cbf8be7030825d4d85517f60b87d829cd062c782d9999e/primp-1.2.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44b67e2759d4f5944ca075256ec52f19124532af1a176d98e7f4053b4d4daae3", size = 3887640, upload-time = "2026-03-27T06:48:10.378Z" },
{ url = "https://files.pythonhosted.org/packages/48/75/cf8465eef07d9595e47efe840bad9e3b7a8724e2ff1c1d47709a95988788/primp-1.2.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f6ab28c9a50a2a58ed9997f1821a169fb5d5bce612a3e49d7bdcdb021014d6", size = 4145009, upload-time = "2026-03-27T06:48:32.475Z" },
{ url = "https://files.pythonhosted.org/packages/cb/a5/c8f0e6c92915da2205169d30967291db33e69f85233d39cbad99b235863c/primp-1.2.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:538c7877208d93b402766e1a95203c196591c9919814b96e830e36494fa7e1e7", size = 4420116, upload-time = "2026-03-27T06:48:05.67Z" },
{ url = "https://files.pythonhosted.org/packages/09/2d/2ed76ce32a1fe2a3884c47f2d395cd51790cdd458a0e0f564a154a1e21e7/primp-1.2.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45cc81c19124b01c87f71606b18840b1e29350fa62bf1d77d30d6f491ef06730", size = 4319884, upload-time = "2026-03-27T06:48:13.446Z" },
{ url = "https://files.pythonhosted.org/packages/fc/4e/6d4681be11ea6e4d4be3f7561636dec7ea0ef3eb741f44f7661d3dec61bd/primp-1.2.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d3889e2df064c504064e7c935eb8b2662a4877edd57d172d4ce9217ca4a17c", size = 4527647, upload-time = "2026-03-27T06:48:36.521Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ba/43aeeba9db2c8118bd27799cd9caca596374101badd46092b9505565b251/primp-1.2.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ecff32b61fd32f91fefdb055cf5dec7d8c8a1addc48d736a7e39ff5598b48e5", size = 4460168, upload-time = "2026-03-27T06:48:37.77Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3f/5e69db3307878bc3dec43e28ae1b9a80ff1e3824936882d4a24a80d2c8d3/primp-1.2.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a636a6198603080c6454d08565796956393e3510e1a419f04243514e0524d410", size = 4123422, upload-time = "2026-03-27T06:48:26.662Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f7/d48d29843a99bad516669d7c696eaa6567ffbc5911e3cb2160c434b00035/primp-1.2.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:fcbff0f8774dee3076b0fdebe2c96a78fc8d9b8720733f40c0801d8952b75829", size = 4268557, upload-time = "2026-03-27T06:48:39.143Z" },
{ url = "https://files.pythonhosted.org/packages/cb/fc/89f5aea2bf783d16fd0fad5fa19baaf39db1d927f6b1c0b046df87efe8da/primp-1.2.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3f262e9758cd7534c688679aa595b9ae5f2ad1a0523dc8a2e68e7bdae6970d41", size = 4781482, upload-time = "2026-03-27T06:48:11.783Z" },
{ url = "https://files.pythonhosted.org/packages/d4/62/f693f24bee4f20b5d1c6f8b24e93fb307440ece392a4f2af0a777783467e/primp-1.2.0-cp310-abi3-win32.whl", hash = "sha256:e842bb1709b00ed76fa7bb36452cc40a849525292880389d5cb96b3816b81085", size = 3495841, upload-time = "2026-03-27T06:48:22.464Z" },
{ url = "https://files.pythonhosted.org/packages/55/8c/30220457fdf3aef691c4576fd5061845ec9616b350f3f4f4b9244b1a45dd/primp-1.2.0-cp310-abi3-win_amd64.whl", hash = "sha256:534d5f4758c6e6de8cb5468371088b9b63c1c9fb1598dde3a812c3e0041ff764", size = 3874396, upload-time = "2026-03-27T06:48:34.27Z" },
{ url = "https://files.pythonhosted.org/packages/da/e6/e85992bc182487e82af364b83c4940fd79bfc6225fdfd1c3704a1ec2e1ba/primp-1.2.0-cp310-abi3-win_arm64.whl", hash = "sha256:4d6b76832cddbaf5015f351ef92297ca2b5ff1c0a9b7d48869950bfcb7fc0c3f", size = 3859529, upload-time = "2026-03-27T06:48:44.173Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d5/b0cf726d1ce1188df28e1a341e55cf72fe27b9830979cda3ace88f26ab2a/primp-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7b6c91d80eaf8111aaed2614cd6a1828a34f6a4e808304db5e68d45814a94d17", size = 4323365, upload-time = "2026-03-27T06:48:41.309Z" },
{ url = "https://files.pythonhosted.org/packages/60/87/36eea1248c75075fc0043b9267f94a74178796eac8e31f1e8692d7703564/primp-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:85a0752fcab7620f2c0e33ae645798db7ba4338ec29afa2f76214cba3200f113", size = 4014867, upload-time = "2026-03-27T06:48:23.896Z" },
{ url = "https://files.pythonhosted.org/packages/c7/73/7c7296bec28db2021b03193d2dae18802178189cd7df8a4083822a9e5bfc/primp-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b45bd9ab3bd9ffd03d9fb0da402f74655a1c91957602327901235ee44eee13b6", size = 4288377, upload-time = "2026-03-27T06:48:08.462Z" },
{ url = "https://files.pythonhosted.org/packages/46/a8/493c73ee22070c15941526b6dbf9155fac3a4585bc6a99f096e88d8a73ca/primp-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:471ee87c9cdacbea4745f4062fee8fb46f90f31258c3fed3439a8679fb76a180", size = 3889957, upload-time = "2026-03-27T06:48:19.826Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0b/fd94f740b56c2bfb5553e9f80ffaae028614db06d3dc15f905e230a2cd2b/primp-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad9f2b09a3fa40a4c777dd326c40123e2466619451df54add44197ddaaba6664", size = 4141722, upload-time = "2026-03-27T06:48:42.563Z" },
{ url = "https://files.pythonhosted.org/packages/ba/e3/8b8f2fa8b30c59fbee62036b98d06085c599761771da8a732439eecd0d78/primp-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c309a1fa2b01abd568ad489e343414add9827416411998a8e30b994e5102bf8", size = 4414221, upload-time = "2026-03-27T06:48:31.002Z" },
{ url = "https://files.pythonhosted.org/packages/25/34/94ebfad7a725599c027258105419965f7ced13195861c78281e93a9c7444/primp-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:457cee253d7b609d98718433e5df05f7afa735d864431d969bdb264a19f0da44", size = 4305538, upload-time = "2026-03-27T06:48:21.157Z" },
{ url = "https://files.pythonhosted.org/packages/cd/64/1dfe0a5cd84292c549df9003b8e4a600b87a92cb7958d0e90d1991b725e7/primp-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3fca95d5c32d3a05750fa55a0a9259efd926dbebe64ab9e17be0e764e6c1b6d", size = 4523230, upload-time = "2026-03-27T06:48:48.541Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a5/e32dcb9fc837f34dec87530a0e2ae3d49d1f77fb6b7bf3e9fe032077072d/primp-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0015a435b95c09daff1997032d6d6fca0898d4dba438f5e4814ae9687a0d7523", size = 4450713, upload-time = "2026-03-27T06:48:25.298Z" },
{ url = "https://files.pythonhosted.org/packages/69/be/8866cfe40ac56f8be705f4df6d142a9721d82e21d3cb4500347fa02bfbbb/primp-1.2.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e77490c117a46e96a96899b3f45969197b86b7916264ce1bff7b32dd024aa122", size = 4116166, upload-time = "2026-03-27T06:48:29.644Z" },
{ url = "https://files.pythonhosted.org/packages/2f/15/1cb8b26ae3db007c14108e26f66a83be12b92669b3cf8de47d50fbffff94/primp-1.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5ff36e707ba27bb63963d289eac995639a4839ce3a0810fc6814c82ed34637bc", size = 4265289, upload-time = "2026-03-27T06:48:47.062Z" },
{ url = "https://files.pythonhosted.org/packages/74/cd/3e9d9b5f39e588244054ea167a3937a11b65047617ac14cf7f94c32b3f9f/primp-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f10264895525f097f83a1c4834f064bd6f07b2ff877bfcaed08520f849366d6", size = 4776628, upload-time = "2026-03-27T06:48:45.544Z" },
{ url = "https://files.pythonhosted.org/packages/54/1a/390717fc42daf236524bb2c7ec3707daed0a7cbb5675a31459e97eb56416/primp-1.2.0-cp314-cp314t-win32.whl", hash = "sha256:7ba931c8331f44fcea68e451553f653d23238b9eac4733a318051b9ec5f8219b", size = 3495791, upload-time = "2026-03-27T06:48:18.164Z" },
{ url = "https://files.pythonhosted.org/packages/8f/bd/304e6314dddd776620ab9713989ea699f6476762e09b0bc17c074440f7c5/primp-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c5db92428d76d3302c2b103e702723c4598cfb9cf8d637deb98c53a99302557d", size = 3867412, upload-time = "2026-03-27T06:48:28.279Z" },
{ url = "https://files.pythonhosted.org/packages/f3/23/6776c98be6683b6097e27c46bb5931af83364a759345a64ff69c76106918/primp-1.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:53a46046a17adbc3d06d7fdc53a681ab1a64e531e0cbf4aacace94d233f353c0", size = 3857698, upload-time = "2026-03-27T06:48:04.393Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9b/839567a1ed4235fc8dc9e278d613e9ece2c364063e6a1d725f689d9c0405/primp-1.2.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f1123eb5822eadb3cdadb29b910d45b4626ff4559e0b14ac39dda69fce751756", size = 4355623, upload-time = "2026-03-30T12:19:24.955Z" },
{ url = "https://files.pythonhosted.org/packages/db/27/43922770620219fee11be4cbd33ffa8689b9e37780b2464a72cde540edc9/primp-1.2.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eec51c71754c40fd3590c4fe13c4a6dfcdb776ad84d805901659600d88a25ace", size = 4035804, upload-time = "2026-03-30T12:19:16.353Z" },
{ url = "https://files.pythonhosted.org/packages/b8/59/909becbcbfa27b87ab56fb9b730ba64dfcb90de3b6bce58b748f19d5c8ce/primp-1.2.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d2ace1328a153b5a28af98b636cf58271932da62d30cc7ed4a9296fd46abc3a", size = 4308476, upload-time = "2026-03-30T12:19:41.505Z" },
{ url = "https://files.pythonhosted.org/packages/35/7f/006692e21190c83e553ac5da8fb6b5a4a1150fec6cee2e5f2eaba4fc05db/primp-1.2.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6b1b5569c990d2754d7327972bd0fcca1ad3c5fb422ab275b897ac945e552569", size = 3904546, upload-time = "2026-03-30T12:19:38.626Z" },
{ url = "https://files.pythonhosted.org/packages/44/23/1d6a8eb431eb50a264c07f8a90a6a0c64906f544ab66c17afb426fed67b9/primp-1.2.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:945e38ac8dfb440746f83fae519c6461a105f797cc67803ddce12619c9d7dff6", size = 4154849, upload-time = "2026-03-30T12:19:11.031Z" },
{ url = "https://files.pythonhosted.org/packages/8b/e8/587e850c18c686dceb0083ce8cabd96d16bdddf27d5e00521674492cf1c2/primp-1.2.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ca3784de907a8818c4fd69763e84518bbd78ba6d416bbb4b613d64c1f33fb5f", size = 4443881, upload-time = "2026-03-30T12:19:23.4Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ba/b774d79ed000e04f2000fa7939728a35f28fa04006c0c275299824ebe58c/primp-1.2.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca4b8b5e6717d3ecffaf707a15e10e73fb344f9b35752057e6ea320560d0ab60", size = 4334549, upload-time = "2026-03-30T12:19:29.445Z" },
{ url = "https://files.pythonhosted.org/packages/ab/bb/44254a82f76e9ab75311971c6bbded3adfa5a9b6a48c36a2d07eb9449137/primp-1.2.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00d43a98f0725175a8e9f49515d1c211713e40a95f24bdc5a35a84f7edba3566", size = 4539205, upload-time = "2026-03-30T12:19:06.128Z" },
{ url = "https://files.pythonhosted.org/packages/4b/28/cc3ea87172a451d352068f86e76c04d2a8a64a3c2e5178c9bd15d188209f/primp-1.2.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:44d41ac824f13dbb65bede87ad91e5baa27418e744221e03a187e2baa100eab3", size = 4469006, upload-time = "2026-03-30T12:19:35.55Z" },
{ url = "https://files.pythonhosted.org/packages/d2/8e/1a6ec24e1a5a3ab6f77dcbe7df1297d85d39d9315d2a76197c0f71ad03b9/primp-1.2.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5990f6da9909291d3884ff2333ff1caf765769a07175e3be473461f490d8a61c", size = 4132529, upload-time = "2026-03-30T12:19:20.473Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/78f5eaa8a200ba563472bcef3582d4bd9078b15e7922711a1a073e7f478a/primp-1.2.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:79b34bd865712bbb03d8713c08ba4ab5a4a8c7ad580c6c730ade957c76b6be53", size = 4282622, upload-time = "2026-03-30T12:19:13.58Z" },
{ url = "https://files.pythonhosted.org/packages/32/c6/c28b716ba8acaac41a8cb0cd31f9b77c357438d9a87e899acd4cd3d0557d/primp-1.2.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c07f526d9e31054d08c5766a3f3add5ca70dfadb87a652ed5b46d8eb92ae0de7", size = 4794078, upload-time = "2026-03-30T12:19:01.782Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ea/18dbdf68ffff0d6c0a58b70341cf4c881e556a894074ec971c761ca11881/primp-1.2.1-cp310-abi3-win32.whl", hash = "sha256:e82e6a61f972aab5809f6e680867e522d0b475e20fcc70f67c2b061da9eee746", size = 3512964, upload-time = "2026-03-30T12:19:22.002Z" },
{ url = "https://files.pythonhosted.org/packages/24/51/c55ba2e7051d1f2f3dfe4ce93d0a1b0061fa8d70b1dda664ee9f4b238773/primp-1.2.1-cp310-abi3-win_amd64.whl", hash = "sha256:77eae842a8cefdcbc1ca02d11b8a3c40548b82447f7259c50e34314161ee7464", size = 3887675, upload-time = "2026-03-30T12:19:39.86Z" },
{ url = "https://files.pythonhosted.org/packages/af/c4/02325db4b4db008083170dc880b85247edea651d049dde8ed51d1af5897a/primp-1.2.1-cp310-abi3-win_arm64.whl", hash = "sha256:21debb530039c087ad477e07e945c4000ef6b655732b0dea01007d52feb138ae", size = 3879095, upload-time = "2026-03-30T12:19:27.826Z" },
{ url = "https://files.pythonhosted.org/packages/39/d5/27b896cfd4ea38033e0b65ceed34d64ba41a00f0ef6e5ca58a6350b4a4ef/primp-1.2.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:06837bf3ececc1482fbafb7c8cf306121a86c43ac7e57ec7a4d57add7e8ad126", size = 4344723, upload-time = "2026-03-30T12:19:00.457Z" },
{ url = "https://files.pythonhosted.org/packages/14/44/ef009ac63dc7f4053c4e9a82699fa1d259542a8c334f1a46397cf415499b/primp-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3388ed838b8469592d9ae04ada7bc0d324211a1a16b2f64b5ed08f36e9c54ff6", size = 4028205, upload-time = "2026-03-30T12:19:07.739Z" },
{ url = "https://files.pythonhosted.org/packages/e7/11/7f2380127cd174fdc74face38d017a0d4a3feb711b5cd306e34e113911e4/primp-1.2.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c86f524e6380697f16e0eeed74f0a1fb360c4ec6c43e1d2843f0ba0bdd7e238", size = 4300606, upload-time = "2026-03-30T12:19:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/1e/d4/552fef7bae66cfd922506f532c6f15655b9fbbd9821a43db1b00d4541ee0/primp-1.2.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e272d6f97d5e1696018a6cbfef68ba51c89f4d3f676595ca49ce71544cacd7b6", size = 3899190, upload-time = "2026-03-30T12:19:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/66304f7e8408aac88731001bbbef9f480ba2d351e764e8aecaecfd9b88a3/primp-1.2.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92e992e8d0689511b8311951b3ed0a468c98fda949567616f30a6f0fac824c8b", size = 4152615, upload-time = "2026-03-30T12:18:58.851Z" },
{ url = "https://files.pythonhosted.org/packages/1c/c5/fecebde683382533e7d217da5fb48a262e7af0d288f833e89a96cb0076c0/primp-1.2.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:faa681c76a306db378ef6543ae84c405b604a1315a8ecedac2b52cb4159c4465", size = 4428506, upload-time = "2026-03-30T12:19:12.291Z" },
{ url = "https://files.pythonhosted.org/packages/5d/51/6a25ca1ff9700ab62f6d4bd7f9c8d6f413e135308344bacb9302c0b78043/primp-1.2.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3fa5d5f89fd35f1a7bac6d003f2d23857a4216e5b52adefd6f661a7e6d333ed", size = 4321356, upload-time = "2026-03-30T12:19:31.193Z" },
{ url = "https://files.pythonhosted.org/packages/6b/14/953a923c6ae93f4f7c7ac9d241ede544e9c3684b637874b539ee812bf380/primp-1.2.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0890ff425d2f95cd624ae6789828c5f3f4fd9b82ef091dfd3441c8489b81c12e", size = 4533517, upload-time = "2026-03-30T12:19:09.436Z" },
{ url = "https://files.pythonhosted.org/packages/79/45/559e6a2166fd287eaef56d0a65efaaeed7a76c1b52c485fe40a9a5bccb2f/primp-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:688b21b73ffcababbad757f2a81d641c01567a59c2e0f3b5cc74b37b872598c2", size = 4464386, upload-time = "2026-03-30T12:19:04.42Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/cb2d02b527c15a284055bf417db680c666c52bac1ac714a32a79d5888e24/primp-1.2.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ad9ac3e347e193982961c0d333f0a9f57f83224104b239421ef85b0bab3409e3", size = 4130750, upload-time = "2026-03-30T12:19:43.099Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f1/9e73a29e63d3440522d841700c29f8e49dfceee6f0b701b0d72f70b22c61/primp-1.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:52c8e8eeccc67302120673cdc32ab7022b3df8ea218d46bda9d81ad90f1d32bb", size = 4287536, upload-time = "2026-03-30T12:19:17.915Z" },
{ url = "https://files.pythonhosted.org/packages/cc/8b/0eec2862d8b3b6471cba851de105fda4457ac07f50f1ba62bc2643312054/primp-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3c03c6111c372ea82ed552c37a437ecb2f7e2a0e26033620303ab4fe4380155a", size = 4782711, upload-time = "2026-03-30T12:19:14.903Z" },
{ url = "https://files.pythonhosted.org/packages/a9/69/6439585738b6b3d79d0d8e83f91abaaf857941f5ef81d1c8e24b8869adfc/primp-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:56e7b1f6d062bf72f3e06a0fc03630a8dcb6349c44397a3513da6668f51d3fc9", size = 3506042, upload-time = "2026-03-30T12:19:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/67/f1/a00d35e01512a10ad1c607b52fcdb4a39cfabe2a8c8c7975a84d2da5e5ea/primp-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3e449fa51e7d06889c30247bba7a73f6b2e3958179d9f37e4ffef78fda714a3c", size = 3883151, upload-time = "2026-03-30T12:19:19.225Z" },
{ url = "https://files.pythonhosted.org/packages/ef/7f/1c4bd7a311da73832086cff75274bf2620ed0ceb38e92fbcf606cc0567a4/primp-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a20ca7542e912c6cb83dc86f42a34cc4e2618e3a24878101433888259e566433", size = 3875623, upload-time = "2026-03-30T12:19:33.952Z" },
]
[[package]]
@@ -2506,7 +2506,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "0.9.5"
version = "0.9.6"
source = { editable = "." }
dependencies = [
{ name = "alembic" },