mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e02d1b52c | |||
| 9b29453e9b | |||
| c4ff1caf09 | |||
| 22245145db | |||
| 8eacc4d632 | |||
| 23fed785c4 | |||
| 688c27e68a | |||
| 405baf7cb2 | |||
| 1027c22333 | |||
| 381651049b | |||
| 322b7dabc4 |
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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">⌂</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">☾</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">☾</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>' }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user