Compare commits

...

17 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
Patrick Buckley d5e86c8493 release: v0.9.5 2026-03-29 23:02:53 -07:00
Patrick Buckley c154ea3966 fix: subscribe to workstream events before sending first Discord message (#250)
The first message sent from Discord was silently dropped because the
cog delegated the initial message to the bridge via CreateWorkstream-
Message, but the bridge published response events to the per-workstream
Redis pub/sub channel before the Discord bot had subscribed to it.
Redis pub/sub is fire-and-forget — events with no subscribers are lost.

Fix: create the workstream with initial_message="" (no delegation),
subscribe to the per-workstream event channel, then send the message
through router.send_message() — the same path the second message
already uses successfully.

Applied to both @mention handler and /ask slash command.
2026-03-29 22:57:56 -07:00
renovate[bot] 755ab51802 chore(deps): lock file maintenance (#249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 22:22:49 -07:00
Patrick Buckley 9df8ab836f Feat/per pane status bar (#248)
* feat: per-workstream status bar above input

Move the global token counter and model name from the header into a
per-pane telemetry strip between messages and the text input. Each
workstream pane now independently shows model name, token usage with
context percentage, tool calls this turn, and turn count.

Backend: add _ws_turn_tool_calls counter (reset per user turn, emitted
in SSE status event alongside turn_count). MQ bridge forwards the new
fields. SDK and TypeScript types updated.

Frontend: build .ws-status-bar DOM in _createDOM, rewrite updateStatus
to target per-pane elements, update SSE connect/disconnect handlers.
Remove #model-name and #status-bar from global header. Restore console
#status-bar CSS in its own stylesheet.

Accessibility: aria-atomic, aria-labels on each field, warning symbols
(▲/⚠) at 80%/95% context for color-blind users, placeholder text
before first status event. Disconnect state uses 2px red border with
dimmed stale fields.

* fix: emit status event on SSE connect so status bar populates on resume

When resuming a workstream, the event_generator only sent connected +
history events. The status bar stayed at placeholder values until the
next LLM response. Now replays session._last_usage as a synthetic
status event right after connected, so token count, tool calls, and
turn count render immediately.

* fix: address Copilot review — remove dead function, clarify locals

Remove updateHeaderForFocusedPane() and its call site (no-op since
status moved per-pane). Rename ambiguous ttc/tc locals to
turn_tool_calls/turn_count in the status replay block.
2026-03-29 22:22:06 -07:00
Patrick Buckley 8d88e6a7eb feat: add memory get action, reduce search/list preview to 200 chars (#247)
* feat: add memory get action, reduce search/list preview to 200 chars

search and list truncated memory content to 500 chars with no way to
read the full value.  Two changes:

- New 'get' action retrieves a single memory by name with complete
  untruncated content.  Searches scopes narrowest-first (workstream
  → user → global).
- search/list previews reduced from 500 to 200 chars now that get
  exists for full content.  Both append a hint:
  "Use memory(action='get', name='...') for full content."

Includes get_structured_memory_by_name wrapper in memory.py and
4 tests.

* Update turnstone/tools/memory.json

* fix: include 'get' in _prepare_memory docstring and invalid-action error
2026-03-29 20:53:48 -07:00
Patrick Buckley cce292f793 fix: strip NUL bytes in both storage backends via shared sanitize_text
PostgreSQL text fields cannot store NUL (0x00) bytes, and SQLite
stores them but they cause downstream issues (API payloads, web UI).
Add sanitize_text() to _utils.py and apply it in both backends'
save_message to content and provider_data fields.
2026-03-29 19:20:42 -07:00
29 changed files with 986 additions and 405 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.4"
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"
+2
View File
@@ -83,6 +83,8 @@ export interface StatusEvent {
effort: string;
cache_creation_tokens?: number;
cache_read_tokens?: number;
tool_calls_this_turn?: number;
turn_count?: number;
}
export interface PlanReviewEvent {
+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)
+130
View File
@@ -3,6 +3,7 @@
from turnstone.core.memory import (
count_structured_memories,
delete_structured_memory,
get_structured_memory_by_name,
list_structured_memories,
normalize_key,
save_structured_memory,
@@ -67,6 +68,29 @@ class TestSearchStructuredMemories:
assert any(r["name"] == "db_host" for r in results)
class TestGetStructuredMemoryByName:
def test_get_existing(self, tmp_db):
save_structured_memory("my_mem", "full content here that is quite long")
mem = get_structured_memory_by_name("my_mem", "global", "")
assert mem is not None
assert mem["content"] == "full content here that is quite long"
assert mem["name"] == "my_mem"
def test_get_nonexistent(self, tmp_db):
assert get_structured_memory_by_name("nope", "global", "") is None
def test_get_wrong_scope(self, tmp_db):
save_structured_memory("ws_mem", "data", scope="workstream", scope_id="ws1")
assert get_structured_memory_by_name("ws_mem", "global", "") is None
assert get_structured_memory_by_name("ws_mem", "workstream", "ws1") is not None
def test_get_normalizes_key(self, tmp_db):
save_structured_memory("My-Key", "value")
mem = get_structured_memory_by_name("My-Key", "global", "")
assert mem is not None
assert mem["name"] == "my_key"
class TestCountStructuredMemories:
def test_count_zero(self, tmp_db):
assert count_structured_memories() == 0
@@ -80,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.4"
__version__ = "0.9.6"
+9 -3
View File
@@ -183,16 +183,21 @@ class MessageCog:
auto_archive_duration=self.ts.config.thread_auto_archive, # type: ignore[arg-type]
)
# Create workstream with the initial message.
# Create workstream WITHOUT initial_message — subscribe to events
# first, then send the message. Sending initial_message through
# the bridge races with subscription: Redis pub/sub is fire-and-
# forget, so response events published before subscribe completes
# are silently dropped.
ws_id, _is_new = await self.ts.router.get_or_create_workstream(
channel_type="discord",
channel_id=str(thread.id),
name=thread_name,
model=self.ts.config.model,
initial_message=content,
initial_message="",
)
await self.ts.subscribe_ws(ws_id, thread)
await self.ts.router.send_message(ws_id, content)
log.info(
"discord.workstream_created",
ws_id=ws_id,
@@ -362,10 +367,11 @@ class MessageCog:
channel_id=str(thread.id),
name=thread_name,
model=self.ts.config.model,
initial_message=message,
initial_message="",
)
await self.ts.subscribe_ws(ws_id, thread)
await self.ts.router.send_message(ws_id, message)
await interaction.followup.send(
f"Workstream started in {thread.mention}",
+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,
+2
View File
@@ -7,6 +7,8 @@
Header overrides wider padding for console layout
========================================================================== */
#header { padding: 10px 20px; gap: 16px; }
#status-bar { font-size: 11px; color: var(--fg-dim); margin-left: auto; }
#status-bar.disconnected { color: var(--red); }
.header-dim {
color: var(--fg-dim);
font-weight: 400;
+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
# ------------------------------------------------------------------
+12
View File
@@ -331,6 +331,18 @@ def save_structured_memory(
return "", None
def get_structured_memory_by_name(
name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
"""Retrieve a single structured memory by name+scope. Returns full content."""
name = normalize_key(name)
try:
return get_storage().get_structured_memory_by_name(name, scope, scope_id)
except Exception:
log.warning("Failed to get structured memory name=%s", name, exc_info=True)
return None
def delete_structured_memory(name: str, scope: str = "global", scope_id: str = "") -> bool:
"""Delete a structured memory by name+scope. Returns True if existed."""
name = normalize_key(name)
+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
+219 -34
View File
@@ -40,6 +40,7 @@ from turnstone.core.memory import (
delete_structured_memory,
delete_workstream,
get_skill_by_name,
get_structured_memory_by_name,
get_workstream_display_name,
list_default_skills,
list_skills_by_activation,
@@ -647,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 []
@@ -951,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') → "
@@ -1093,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)
@@ -1322,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(
@@ -3566,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")
@@ -3585,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.
@@ -3626,7 +3703,7 @@ class ChatSession:
return None
def _prepare_memory(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a memory tool action (save/search/delete/list)."""
"""Prepare a memory tool action (save/get/search/delete/list)."""
action = (args.get("action") or "").strip().lower()
if action == "save":
@@ -3687,6 +3764,51 @@ class ChatSession:
"scope_id": scope_id,
}
if action == "get":
name = normalize_key((args.get("name") or args.get("key") or "").strip())
if not name:
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2717 memory get: missing name",
"preview": "",
"needs_approval": False,
"error": "Error: 'name' is required for get",
}
explicit_scope = (args.get("scope") or "").strip().lower()
valid_scopes = ("global", "workstream", "user")
if explicit_scope and explicit_scope not in valid_scopes:
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2717 memory get: invalid scope",
"preview": "",
"needs_approval": False,
"error": f"Error: invalid scope '{explicit_scope}'. Valid: {', '.join(valid_scopes)}",
}
if explicit_scope:
scope_err = self._validate_scope(explicit_scope, call_id)
if scope_err:
return scope_err
scopes_to_try = [(explicit_scope, self._resolve_scope_id(explicit_scope))]
else:
scopes_to_try = []
for s in ("workstream", "user", "global"):
sid = self._resolve_scope_id(s)
if sid or s == "global":
scopes_to_try.append((s, sid))
return {
"call_id": call_id,
"func_name": "memory",
"header": f"\u2699 memory get: {name}",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "get",
"name": name,
"scopes_to_try": scopes_to_try,
}
if action == "delete":
name = normalize_key((args.get("name") or args.get("key") or "").strip())
if not name:
@@ -3745,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):
@@ -3774,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):
@@ -3801,7 +3931,7 @@ class ChatSession:
"header": "\u2717 memory: invalid action",
"preview": "",
"needs_approval": False,
"error": f"Error: action must be save/search/delete/list, got '{action}'",
"error": f"Error: action must be save/get/search/delete/list, got '{action}'",
}
def _prepare_recall(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
@@ -5075,6 +5205,29 @@ class ChatSession:
self._report_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "get":
scopes = item["scopes_to_try"]
mem = None
found_scope = ""
for scope, scope_id in scopes:
mem = get_structured_memory_by_name(item["name"], scope, scope_id)
if mem:
found_scope = scope
break
if mem:
content = mem.get("content", "")
desc = mem.get("description", "")
mem_type = mem.get("type", "")
header = f"[{mem_type}:{found_scope}] {item['name']}"
if desc:
header += f"{desc}"
msg = f"{header}\n\n{content}"
else:
tried = ", ".join(s for s, _ in scopes)
msg = f"Error: memory '{item['name']}' not found (searched scopes: {tried})"
self._report_tool_result(call_id, "memory", msg, is_error=mem is None)
return call_id, msg
if action == "delete":
scopes = item["scopes_to_try"]
deleted = False
@@ -5095,22 +5248,39 @@ 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:
desc = f"{m['description']}" if m.get("description") else ""
preview = m["content"][:200]
if len(m["content"]) > 200:
preview += "..."
lines.append(
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n"
f" {m['content'][:500]}"
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n {preview}"
)
msg = f"Memories ({len(rows)} results):\n" + "\n".join(lines)
msg += "\n\nUse memory(action='get', name='...') for full content."
else:
msg = (
f"No memories found for '{item['query']}'."
@@ -5121,21 +5291,36 @@ 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:
desc = f"{m['description']}" if m.get("description") else ""
preview = m["content"][:200]
if len(m["content"]) > 200:
preview += "..."
lines.append(
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n"
f" {m['content'][:500]}"
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n {preview}"
)
msg = f"Memories ({len(rows)}):\n" + "\n".join(lines)
msg += "\n\nUse memory(action='get', name='...') for full content."
else:
msg = "No memories stored."
self._report_tool_result(call_id, "memory", msg)
+3
View File
@@ -72,6 +72,7 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
row_to_dict as _row_to_dict,
)
from turnstone.core.storage._utils import sanitize_text
from turnstone.core.storage._utils import (
scan_skill_content as _scan_skill_content,
)
@@ -112,6 +113,8 @@ class PostgreSQLBackend:
tool_calls: str | None = None,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
content = sanitize_text(content)
provider_data = sanitize_text(provider_data)
with self._engine.connect() as conn:
conn.execute(
sa.insert(conversations),
+3
View File
@@ -72,6 +72,7 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
row_to_dict as _row_to_dict,
)
from turnstone.core.storage._utils import sanitize_text
from turnstone.core.storage._utils import (
scan_skill_content as _scan_skill_content,
)
@@ -163,6 +164,8 @@ class SQLiteBackend:
tool_calls: str | None = None,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
content = sanitize_text(content)
provider_data = sanitize_text(provider_data)
with self._engine.connect() as conn:
result = conn.execute(
sa.insert(conversations),
+16
View File
@@ -10,6 +10,22 @@ from turnstone.core.log import get_logger
log = get_logger(__name__)
# ---------------------------------------------------------------------------
# Text sanitization
# ---------------------------------------------------------------------------
def sanitize_text(value: str | None) -> str | None:
"""Strip NUL bytes that PostgreSQL text fields cannot store.
SQLite tolerates NUL in TEXT but they cause downstream issues (API
payloads, web UI rendering), so both backends use this.
"""
if value and "\x00" in value:
return value.replace("\x00", "")
return value
# ---------------------------------------------------------------------------
# Row helper
# ---------------------------------------------------------------------------
+2
View File
@@ -665,6 +665,8 @@ class Bridge:
effort=data.get("effort", ""),
cache_creation_tokens=data.get("cache_creation_tokens", 0),
cache_read_tokens=data.get("cache_read_tokens", 0),
tool_calls_this_turn=data.get("tool_calls_this_turn", 0),
turn_count=data.get("turn_count", 0),
),
)
elif etype == "error":
+2
View File
@@ -252,6 +252,8 @@ class StatusEvent(OutboundEvent):
effort: str = ""
cache_creation_tokens: int = 0
cache_read_tokens: int = 0
tool_calls_this_turn: int = 0
turn_count: int = 0
@dataclass
+2
View File
@@ -128,6 +128,8 @@ class StatusEvent(ServerEvent):
effort: str = ""
cache_creation_tokens: int = 0
cache_read_tokens: int = 0
tool_calls_this_turn: int = 0
turn_count: int = 0
@dataclass
+72 -2
View File
@@ -102,6 +102,7 @@ class WebUI:
self._ws_tool_calls: dict[str, int] = {}
self._ws_tool_calls_reported: int = 0 # last cumulative total sent to usage
self._ws_context_ratio: float = 0.0
self._ws_turn_tool_calls: int = 0
# Activity tracking for dashboard (current tool / thinking / approval)
self._ws_current_activity: str = ""
self._ws_activity_state: str = "" # "tool" | "approval" | "thinking" | ""
@@ -393,6 +394,7 @@ class WebUI:
_metrics.record_tool_call(name)
with self._ws_lock:
self._ws_tool_calls[name] = self._ws_tool_calls.get(name, 0) + 1
self._ws_turn_tool_calls += 1
self._ws_current_activity = ""
self._ws_activity_state = ""
self._broadcast_activity()
@@ -424,6 +426,8 @@ class WebUI:
tool_total = sum(self._ws_tool_calls.values())
tool_count = tool_total - self._ws_tool_calls_reported
self._ws_tool_calls_reported = tool_total
turn_tool_calls = self._ws_turn_tool_calls
turn_count = self._ws_messages
self._enqueue(
{
"type": "status",
@@ -435,6 +439,8 @@ class WebUI:
"effort": effort,
"cache_creation_tokens": cache_creation,
"cache_read_tokens": cache_read,
"tool_calls_this_turn": turn_tool_calls,
"turn_count": turn_count,
}
)
# Record usage event for governance dashboard
@@ -856,6 +862,32 @@ async def events_sse(request: Request) -> Response:
}
)
}
# Replay last status so the per-pane status bar populates on resume
if session._last_usage is not None:
u = session._last_usage
total_tok = u["prompt_tokens"] + u["completion_tokens"]
cw = session.context_window
pct = total_tok / cw * 100 if cw > 0 else 0
with ui._ws_lock:
turn_tool_calls = ui._ws_turn_tool_calls
turn_count = ui._ws_messages
yield {
"data": json.dumps(
{
"type": "status",
"prompt_tokens": u["prompt_tokens"],
"completion_tokens": u["completion_tokens"],
"total_tokens": total_tok,
"context_window": cw,
"pct": round(pct, 1),
"effort": session.reasoning_effort,
"cache_creation_tokens": u.get("cache_creation_tokens", 0),
"cache_read_tokens": u.get("cache_read_tokens", 0),
"tool_calls_this_turn": turn_tool_calls,
"turn_count": turn_count,
}
)
}
# History replay
history = _build_history(session, has_pending_approval=ui._pending_approval is not None)
if history:
@@ -1240,6 +1272,7 @@ async def send_message(request: Request) -> JSONResponse:
_metrics.record_message_sent()
with ui._ws_lock:
ui._ws_messages += 1
ui._ws_turn_tool_calls = 0
return JSONResponse({"status": "ok"})
@@ -2444,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()
@@ -2668,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)
@@ -2735,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.")
-2
View File
@@ -133,8 +133,6 @@ body {
color: var(--accent);
letter-spacing: 0.02em;
}
#status-bar { font-size: 11px; color: var(--fg-dim); margin-left: auto; }
#status-bar.disconnected { color: var(--red); }
.header-btn {
background: none;
+3 -3
View File
@@ -1,17 +1,17 @@
{
"name": "memory",
"description": "Persistent memory across sessions. Actions: 'save' stores a memory, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Memories have a type (user/project/feedback/reference) and scope (global/workstream/user).",
"description": "Persistent memory across sessions. Actions: 'save' stores a memory, 'get' retrieves full content by name, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Use 'get' to read full content — search/list truncate previews to 200 chars. Memories have a type (user/project/feedback/reference) and scope (global/workstream/user).",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["save", "search", "delete", "list"],
"enum": ["save", "get", "search", "delete", "list"],
"description": "Action to perform."
},
"name": {
"type": "string",
"description": "Memory identifier (required for 'save' and 'delete'). Short snake_case key."
"description": "Memory identifier (required for 'save', 'get', and 'delete'). Short snake_case key."
},
"content": {
"type": "string",
+86 -138
View File
@@ -29,7 +29,7 @@ function Pane(wsId) {
this.retryDelay = 1000;
this.model = "";
this.modelAlias = "";
this.statusText = "";
this._lastStatusEvt = null;
this._cancelTimeout = null;
this._forceTimeout = null;
this._pendingEditSend = null;
@@ -134,6 +134,37 @@ Pane.prototype._createDOM = function () {
this.messagesEl.setAttribute("aria-label", "Chat messages");
this.el.appendChild(this.messagesEl);
// Per-workstream status bar (above input)
this.statusBarEl = document.createElement("div");
this.statusBarEl.className = "ws-status-bar";
this.statusBarEl.setAttribute("role", "status");
this.statusBarEl.setAttribute("aria-live", "polite");
this.statusBarEl.setAttribute("aria-atomic", "true");
this.statusBarEl.setAttribute("aria-label", "Workstream status");
this._sbModel = document.createElement("span");
this._sbModel.className = "ws-sb-model";
this._sbModel.textContent = "\u2014";
this._sbModel.setAttribute("aria-label", "Model");
this._sbTokens = document.createElement("span");
this._sbTokens.className = "ws-sb-tokens";
this._sbTokens.textContent = "0 / \u2014";
this._sbTokens.setAttribute("aria-label", "Token usage");
this._sbTools = document.createElement("span");
this._sbTools.className = "ws-sb-tools";
this._sbTools.textContent = "0 tools";
this._sbTools.setAttribute("aria-label", "Tool calls this turn");
this._sbTurns = document.createElement("span");
this._sbTurns.className = "ws-sb-turns";
this._sbTurns.textContent = "turn 0";
this._sbTurns.setAttribute("aria-label", "Conversation turn");
this.statusBarEl.appendChild(this._sbModel);
this.statusBarEl.appendChild(this._sbTokens);
this.statusBarEl.appendChild(this._sbTools);
this.statusBarEl.appendChild(this._sbTurns);
this.el.appendChild(this.statusBarEl);
// Input area
var inputArea = document.createElement("div");
inputArea.className = "pane-input-area";
@@ -248,11 +279,8 @@ Pane.prototype.connectSSE = function (wsId) {
this.evtSource.onopen = function () {
self.retryDelay = 1000;
if (self.id === focusedPaneId) {
var statusBar = document.getElementById("status-bar");
statusBar.classList.remove("disconnected");
statusBar.textContent = self.statusText || "";
}
self.statusBarEl.classList.remove("ws-sb-disconnected");
if (self._lastStatusEvt) self.updateStatus(self._lastStatusEvt);
};
this.evtSource.onmessage = function (e) {
@@ -265,11 +293,8 @@ Pane.prototype.connectSSE = function (wsId) {
self.evtSource = null;
var loginOverlay = document.getElementById("login-overlay");
if (loginOverlay && loginOverlay.style.display !== "none") return;
if (self.id === focusedPaneId) {
var statusBar = document.getElementById("status-bar");
statusBar.textContent = "Reconnecting\u2026";
statusBar.classList.add("disconnected");
}
self.statusBarEl.classList.add("ws-sb-disconnected");
self._sbTokens.textContent = "Reconnecting\u2026";
// Only the focused pane refreshes the global workstream list to avoid
// race conditions when multiple panes disconnect simultaneously.
if (self.id === focusedPaneId) {
@@ -514,9 +539,8 @@ Pane.prototype.handleEvent = function (evt) {
case "connected":
this.model = evt.model || "";
this.modelAlias = evt.model_alias || evt.model || "";
if (this.id === focusedPaneId) {
updateHeaderForFocusedPane();
}
this._sbModel.textContent = this.modelAlias || this.model || "";
this._sbModel.title = this.model || "";
if (evt.skip_permissions) {
var existing = document.querySelector(".skip-permissions-warning");
if (!existing) {
@@ -1314,19 +1338,32 @@ Pane.prototype.addErrorMessage = function (text) {
};
Pane.prototype.updateStatus = function (evt) {
var parts = [
this._sbModel.textContent = this.modelAlias || this.model || "";
this._sbModel.title = this.model || "";
var tokenText =
evt.total_tokens.toLocaleString() +
" / " +
evt.context_window.toLocaleString() +
" tokens (" +
evt.pct +
"%)",
];
if (evt.effort !== "medium") parts.push("reasoning: " + evt.effort);
this.statusText = parts.join(" \u00b7 ");
if (this.id === focusedPaneId) {
document.getElementById("status-bar").textContent = this.statusText;
}
" / " +
evt.context_window.toLocaleString() +
" (" +
evt.pct +
"%)";
if (evt.effort && evt.effort !== "medium")
tokenText += " \u00b7 " + evt.effort;
if (evt.pct >= 95) tokenText = "\u26a0 " + tokenText;
else if (evt.pct >= 80) tokenText = "\u25b2 " + tokenText;
this._sbTokens.textContent = tokenText;
var tc = evt.tool_calls_this_turn || 0;
this._sbTools.textContent = tc + " tool" + (tc !== 1 ? "s" : "");
var turns = evt.turn_count || 0;
this._sbTurns.textContent = "turn " + turns;
this.statusBarEl.classList.toggle("ws-sb-warn", evt.pct >= 80);
this.statusBarEl.classList.toggle("ws-sb-danger", evt.pct >= 95);
this._lastStatusEvt = evt;
};
Pane.prototype.isNearBottom = function () {
@@ -1437,7 +1474,6 @@ function setFocusedPane(paneId) {
panes[paneId].el.classList.add("focused");
currentWsId = panes[paneId].wsId;
renderTabBar();
updateHeaderForFocusedPane();
}
}
@@ -1447,17 +1483,6 @@ function createPane(wsId) {
return p;
}
function updateHeaderForFocusedPane() {
var pane = getFocusedPane();
var modelName = document.getElementById("model-name");
var statusBar = document.getElementById("status-bar");
if (pane) {
modelName.textContent = pane.modelAlias || pane.model || "";
modelName.title = pane.model || "";
statusBar.textContent = pane.statusText || "";
}
}
function updatePaneHeaders() {
var root = document.getElementById("split-root");
var leafCount = countLeaves(splitRoot);
@@ -2126,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
@@ -2381,7 +2339,8 @@ function showNewWsModal() {
// Populate model dropdown
var modelSelect = document.getElementById("new-ws-model");
var curModel = document.getElementById("model-name").textContent;
var fp = getFocusedPane();
var curModel = fp ? fp.modelAlias || fp.model || "" : "";
modelSelect.textContent = "";
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
@@ -2615,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;
@@ -3324,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 -18
View File
@@ -13,26 +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="model-name"></span>
<span id="mcp-status" role="status" aria-live="polite"></span>
<span id="status-bar"></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">
@@ -134,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>' }
+65 -68
View File
@@ -6,12 +6,6 @@
/* ==========================================================================
Header server-specific elements
========================================================================== */
#model-name {
font-size: 11px;
color: var(--fg-dim);
font-family: var(--font-display);
letter-spacing: 0.02em;
}
#mcp-status {
color: var(--magenta);
font-size: 11px;
@@ -49,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; }
}
@@ -849,6 +789,63 @@ body { position: static; }
.pane-stop:focus-visible { outline: 2px solid var(--fg-bright, #e8ecf4); outline-offset: 2px; }
[data-theme="light"] .pane-stop { color: #fff; }
/* ==========================================================================
Per-workstream status bar above input
========================================================================== */
.ws-status-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 16px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 10px;
color: var(--fg-dim);
flex-shrink: 0;
min-height: 22px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
overflow: hidden;
transition: background 0.3s, border-color 0.3s;
}
.ws-sb-model {
font-family: var(--font-display);
font-weight: 500;
color: var(--accent);
font-size: 10px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-sb-tokens { color: var(--fg-dim); white-space: nowrap; }
.ws-sb-tools { color: var(--fg-dim); white-space: nowrap; }
.ws-sb-turns { color: var(--fg-dim); white-space: nowrap; margin-left: auto; }
/* Context warning states */
.ws-status-bar.ws-sb-warn .ws-sb-tokens { color: var(--yellow); font-weight: 600; }
.ws-status-bar.ws-sb-danger .ws-sb-tokens {
color: var(--red);
font-weight: 600;
text-shadow: 0 0 4px var(--red-glow);
}
[data-theme="light"] .ws-status-bar.ws-sb-danger .ws-sb-tokens { text-shadow: none; }
/* Disconnected state */
.ws-status-bar.ws-sb-disconnected {
border-top: 2px solid var(--red);
background: rgba(248, 113, 113, 0.04);
}
.ws-status-bar.ws-sb-disconnected .ws-sb-tokens { color: var(--red); }
.ws-status-bar.ws-sb-disconnected .ws-sb-model,
.ws-status-bar.ws-sb-disconnected .ws-sb-tools,
.ws-status-bar.ws-sb-disconnected .ws-sb-turns { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) {
.ws-status-bar { transition: none; }
}
/* ==========================================================================
Inline approval blocks
========================================================================== */
@@ -1463,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
+112 -112
View File
@@ -1457,81 +1457,81 @@ wheels = [
[[package]]
name = "numpy"
version = "2.4.3"
version = "2.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" },
{ url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" },
{ url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" },
{ url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" },
{ url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" },
{ url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" },
{ url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" },
{ url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" },
{ url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" },
{ url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" },
{ url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
{ url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
{ url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
{ url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
{ url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
{ url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
{ url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
{ url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
{ url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
{ url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
{ url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
{ url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
{ url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
{ url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
{ url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" },
{ url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" },
{ url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" },
{ url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" },
{ url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" },
{ url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
{ url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
{ url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
{ url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
{ url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
{ url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
{ url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
{ url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
{ url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
{ url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
{ url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
{ url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
{ url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
{ url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
{ url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
{ url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
{ url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
{ url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
{ url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
{ url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
{ url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
{ url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
{ url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
{ url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
{ url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
{ url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
{ url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
{ url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
{ url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
{ url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
{ url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
{ url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
{ url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
{ url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
{ url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
{ url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
{ url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
{ url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
{ url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
{ url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
{ url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
{ url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
{ url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
{ url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
{ url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
{ url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
{ url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
]
[[package]]
@@ -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]]
@@ -1923,11 +1923,11 @@ wheels = [
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
@@ -2393,15 +2393,15 @@ wheels = [
[[package]]
name = "sse-starlette"
version = "3.3.3"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" }
sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
]
[[package]]
@@ -2506,7 +2506,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "0.9.4"
version = "0.9.6"
source = { editable = "." }
dependencies = [
{ name = "alembic" },