mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5e86c8493 | |||
| c154ea3966 | |||
| 755ab51802 | |||
| 9df8ab836f | |||
| 8d88e6a7eb | |||
| cce292f793 | |||
| 6adc577d30 | |||
| 02c50b81c1 | |||
| 753cd04b4e | |||
| c3217748dc | |||
| 8cbff49694 | |||
| 4f26d63c14 | |||
| 74347fb29f | |||
| 2ace8cccc8 | |||
| e95b8f5ca1 | |||
| 7a32c51a1c | |||
| dce663105b | |||
| cfef3616e6 | |||
| c22d39a798 | |||
| 63921450b1 | |||
| 929fad63be | |||
| 976e9df3b6 | |||
| 7cb21b84f1 | |||
| 7263edd48d | |||
| 6742c7e405 | |||
| 1aa6982868 | |||
| 42d1abbd04 | |||
| f543ed714a | |||
| 2c6abb0fde | |||
| 491fc6748a | |||
| 9a996f0067 | |||
| a465ac6383 | |||
| a4539923e4 | |||
| 42e99d6990 |
@@ -0,0 +1,4 @@
|
||||
# libexpat integer overflow — no fix available in Debian repos yet
|
||||
# https://avd.aquasec.com/nvd/cve-2026-25210
|
||||
# Review: remove this entry once a patched libexpat1 is published
|
||||
CVE-2026-25210
|
||||
+6
-2
@@ -10,8 +10,12 @@ LABEL org.opencontainers.image.title="turnstone" \
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /usr/local/bin/uv
|
||||
|
||||
# System dependencies for psycopg (PostgreSQL client library)
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 \
|
||||
# Remove the slim image's man page exclusion so man-db has actual content
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user
|
||||
|
||||
@@ -415,6 +415,7 @@ Per-workstream metrics are labeled by `ws_id` (bounded by `[server].max_workstre
|
||||
- Redis (for message queue bridge — `pip install turnstone[mq]`)
|
||||
- Anthropic provider (optional — `pip install turnstone[anthropic]`)
|
||||
- PostgreSQL (optional, for production — `pip install turnstone[postgres]`)
|
||||
- Math sandbox packages (optional — `pip install turnstone[sandbox]` for sympy, numpy, scipy, pytest)
|
||||
- [Git LFS](https://git-lfs.com/) (for cloning — diagram PNGs are stored in LFS)
|
||||
|
||||
## License
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
|
||||
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
|
||||
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
||||
model 17 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
model 19 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
reading, writing, searching, planning, and executing code.
|
||||
|
||||
The core design principle is a **UI-agnostic engine with pluggable frontends**.
|
||||
@@ -259,7 +259,7 @@ class SessionUI(Protocol):
|
||||
| Class | Module | Notes |
|
||||
|-------|--------|-------|
|
||||
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream, `threading.Event` for blocking on approval/plan |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval/plan. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
|
||||
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
|
||||
|
||||
### WorkstreamTerminalUI
|
||||
|
||||
@@ -97,7 +97,7 @@ package "turnstone/sdk/" <<Rectangle>> {
|
||||
|
||||
' Tool schemas
|
||||
package "turnstone/tools/" <<Rectangle>> {
|
||||
component [*.json\n18 tool schemas] as schemas <<artifact>>
|
||||
component [*.json\n19 tool schemas] as schemas <<artifact>>
|
||||
}
|
||||
|
||||
' Entry point dependencies
|
||||
|
||||
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (17 tools):**
|
||||
**Dispatch table (19 built-in + tool_search):**
|
||||
┌───────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├───────────────┼──────────────────┤
|
||||
@@ -33,16 +33,19 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
│ write_file │ ✓ Yes │
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ diff_file │ ✗ Auto-approve │
|
||||
│ math │ ✗ Auto-approve │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✗ Auto-approve │
|
||||
│ web_search │ ✗ Auto-approve │
|
||||
│ tool_search │ ✗ Auto-approve │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ task_agent │ ✓ Yes │
|
||||
│ plan_agent │ ✓ Yes │
|
||||
│ memory │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
│ watch │ ✓ create only │
|
||||
│ skill │ ✓ load only │
|
||||
│ read_resource │ ✓ Yes │
|
||||
│ use_prompt │ ✓ Yes │
|
||||
├───────────────┼──────────────────┤
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:efcc7cbe8161a54b5ec24bdfd47e8a142f70029e6e66c707e811b99369f85ebf
|
||||
size 310079
|
||||
oid sha256:2b3ea69f852e93dc1bc7943db0c71d0cdd1afcbcf470a8737189ae56e85b3206
|
||||
size 310075
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:72b3932ce99a860f5069544cd8423d3cdae3a51f6566db262b19ced19c780eb0
|
||||
size 274374
|
||||
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
|
||||
size 281519
|
||||
|
||||
+1
-1
@@ -338,7 +338,7 @@ from the output before it enters the conversation.
|
||||
| Priority | Category | Risk | Examples |
|
||||
|----------|----------|------|----------|
|
||||
| 1 | Prompt injection | high | Override phrases, role injection (`{"role":"system"}`), instruction override markers |
|
||||
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets |
|
||||
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets, JSON secrets (`"api_key": "..."`, `"password": "..."`, etc.) |
|
||||
| 3 | Encoded payloads | medium | Script data URIs, hex shellcode sequences |
|
||||
| 4 | Adversarial URLs | medium | Cloud metadata endpoints, credential-bearing query parameters |
|
||||
| 5 | System info disclosure | low | Private IP addresses, sensitive file paths |
|
||||
|
||||
+42
-14
@@ -1,6 +1,6 @@
|
||||
# Tools Reference
|
||||
|
||||
turnstone exposes 18 built-in tools plus any number of external MCP tools to the
|
||||
turnstone exposes 19 built-in tools plus any number of external MCP tools to the
|
||||
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
|
||||
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
|
||||
MCP tools are discovered from configured MCP servers at startup by
|
||||
@@ -46,12 +46,12 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------|-------------|
|
||||
| `TOOLS` | All 17 tool definitions (sent to the model). |
|
||||
| `TOOLS` | All 19 tool definitions (sent to the model). |
|
||||
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
|
||||
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
|
||||
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
|
||||
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 17 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 19 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
|
||||
|
||||
---
|
||||
@@ -69,7 +69,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
|
||||
- Parses the JSON arguments (with fallback for malformed JSON).
|
||||
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
|
||||
to the correct parameter.
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 19
|
||||
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
|
||||
the generic `_prepare_mcp_tool()` handler for MCP tools.
|
||||
- Validates arguments and builds a preview dict containing:
|
||||
@@ -188,8 +188,11 @@ Execute a bash command and return stdout + stderr.
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `command` | string | yes | The bash command to execute. |
|
||||
| `timeout` | integer | no | Timeout in seconds (1-600). Omit to use the global `tools.timeout` setting (typically 120s). |
|
||||
| `stop_on_error` | boolean | no | Enable `set -e` so the script exits on the first command failure. Default false. |
|
||||
|
||||
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`).
|
||||
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
|
||||
- **Output format**: Stdout is returned directly. Stderr lines are prefixed with `[stderr]` so the model can distinguish them. When the command itself redirects stderr to stdout (`2>&1`), no prefix is added. Output exceeding 256KB is truncated (head + tail preserved, middle replaced with a truncation notice).
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only (not available to plan sub-agents).
|
||||
|
||||
@@ -221,8 +224,9 @@ Write content to a file, creating it if needed.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `path` | string | yes | Absolute or relative file path. |
|
||||
| `content` | string | yes | The full file content to write. |
|
||||
| `mode` | string | no | `"overwrite"` (default) replaces the file. `"append"` adds content to the end. |
|
||||
|
||||
- **What it does**: Creates or overwrites the file at the given path. Parent directories are created as needed.
|
||||
- **What it does**: Creates or overwrites (or appends to) the file at the given path. Parent directories are created as needed.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
|
||||
@@ -230,21 +234,44 @@ Write content to a file, creating it if needed.
|
||||
|
||||
### edit_file
|
||||
|
||||
Replace an exact string in a file with new content.
|
||||
Replace exact strings in a file, or apply multiple replacements atomically.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|--------------|---------|----------|-------------|
|
||||
| `path` | string | yes | Absolute or relative file path. |
|
||||
| `old_string` | string | yes | The exact text to find and replace. |
|
||||
| `new_string` | string | yes | The replacement text. |
|
||||
| `old_string` | string | no* | The exact text to find and replace. |
|
||||
| `new_string` | string | no* | The replacement text. |
|
||||
| `near_line` | integer | no | Disambiguate when `old_string` matches multiple locations. |
|
||||
| `edits` | array | no* | Multiple replacements to apply atomically (see below). |
|
||||
| `replace_all` | boolean | no | Replace ALL occurrences of `old_string`. Cannot combine with `near_line` or `edits`. |
|
||||
|
||||
- **What it does**: Finds `old_string` in the file and replaces it with `new_string`. Fails if the string is not found or matches multiple locations (unless `near_line` is provided to pick the nearest match). Requires a prior `read_file` call on the same path.
|
||||
\* Provide either `old_string`+`new_string` (single edit) or `edits` array (batch), not both.
|
||||
|
||||
- **What it does**: Finds `old_string` in the file and replaces it with `new_string`. Fails if the string is not found or matches multiple locations (unless `near_line` or `replace_all` is provided). Requires a prior `read_file` or `diff_file` call on the same path.
|
||||
- **Batch mode**: The `edits` array accepts multiple `{old_string, new_string, near_line?}` entries applied atomically. All edits are validated before any are applied. Overlapping edits (two entries targeting the same text region) are rejected. Edits are applied in reverse file-position order so character offsets stay stable.
|
||||
- **Replace-all mode**: When `replace_all` is true, all occurrences are replaced via `str.replace()`. The approval preview shows the occurrence count.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
|
||||
---
|
||||
|
||||
### diff_file
|
||||
|
||||
Show a unified diff between two files, or between a file and a provided string.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------------|---------|----------|-------------|
|
||||
| `path_a` | string | yes | Path to the first file. |
|
||||
| `path_b` | string | no | Path to the second file. Mutually exclusive with `content_b`. |
|
||||
| `content_b` | string | no | String content to compare against `path_a`. Mutually exclusive with `path_b`. |
|
||||
| `context_lines` | integer | no | Number of context lines around changes (default 3, max 20). |
|
||||
|
||||
- **What it does**: Returns unified diff output using Python's `difflib`. Binary files (containing null bytes) are rejected with a clear error. Files read through `diff_file` satisfy `edit_file`'s read guard — you can diff then edit without a separate `read_file` call. Large diffs are streamed with early cutoff at the tool truncation limit.
|
||||
- **Auto-approve**: Yes (read-only).
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
### search
|
||||
|
||||
Search file contents for a regex pattern.
|
||||
@@ -270,8 +297,9 @@ Execute Python code for math and computation in a sandbox.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `code` | string | yes | Python code to execute. Must use `print()` for output. |
|
||||
|
||||
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported. `pytest` is also available for import.
|
||||
- **Installation**: `sympy`, `numpy`, `scipy`, and `pytest` require the `[sandbox]` extras group: `pip install turnstone[sandbox]` (included in `[all]`).
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
@@ -601,7 +629,7 @@ CLI flags override the config file:
|
||||
directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
- **Always-on** -- the 19 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
These are always visible to the model.
|
||||
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
||||
the model searches for them.
|
||||
@@ -644,7 +672,7 @@ MCP-compatible service.
|
||||
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
|
||||
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
|
||||
|
||||
4. **Merging**: MCP tools are appended after the 17 built-in tools via
|
||||
4. **Merging**: MCP tools are appended after the 19 built-in tools via
|
||||
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
|
||||
When dynamic tool search is active, MCP tools are deferred rather than directly
|
||||
visible -- the model discovers them via search as needed (see
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.9.2"
|
||||
version = "0.9.5"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -8583,4 +8583,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -2043,4 +2043,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ export interface StreamEndEvent {
|
||||
type: "stream_end";
|
||||
}
|
||||
|
||||
export interface StateChangeEvent {
|
||||
type: "state_change";
|
||||
state: "idle" | "thinking" | "running" | "attention" | "error";
|
||||
}
|
||||
|
||||
export interface ToolInfoEvent {
|
||||
type: "tool_info";
|
||||
items: Array<Record<string, unknown>>;
|
||||
@@ -78,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 {
|
||||
@@ -150,6 +157,7 @@ export type ServerEvent =
|
||||
| ContentEvent
|
||||
| ReasoningEvent
|
||||
| StreamEndEvent
|
||||
| StateChangeEvent
|
||||
| ToolInfoEvent
|
||||
| ApproveRequestEvent
|
||||
| ApprovalResolvedEvent
|
||||
@@ -247,6 +255,10 @@ export function isStreamEndEvent(e: ServerEvent): e is StreamEndEvent {
|
||||
return e.type === "stream_end";
|
||||
}
|
||||
|
||||
export function isStateChangeEvent(e: ServerEvent): e is StateChangeEvent {
|
||||
return e.type === "state_change";
|
||||
}
|
||||
|
||||
export function isToolResultEvent(e: ServerEvent): e is ToolResultEvent {
|
||||
return e.type === "tool_result";
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export type {
|
||||
ContentEvent,
|
||||
ReasoningEvent,
|
||||
StreamEndEvent,
|
||||
StateChangeEvent,
|
||||
ToolInfoEvent,
|
||||
ApproveRequestEvent,
|
||||
ApprovalResolvedEvent,
|
||||
@@ -65,6 +66,7 @@ export {
|
||||
isReasoningEvent,
|
||||
isErrorEvent,
|
||||
isStreamEndEvent,
|
||||
isStateChangeEvent,
|
||||
isToolResultEvent,
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
|
||||
+10
-8
@@ -180,7 +180,7 @@ class TestCancelDuringToolExecution:
|
||||
"""Cancel while tools are being executed."""
|
||||
|
||||
def test_rollback_incomplete_tool_results(self, tmp_db):
|
||||
"""When cancelled during tool execution, incomplete results are rolled back."""
|
||||
"""When cancelled during tool execution, synthesized results replace missing tool outputs."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@@ -236,13 +236,15 @@ class TestCancelDuringToolExecution:
|
||||
|
||||
# Session should be idle
|
||||
assert ui.states[-1] == "idle"
|
||||
# No tool result messages should remain (rolled back)
|
||||
roles = [m["role"] for m in session.messages]
|
||||
assert "tool" not in roles
|
||||
# The assistant message with tool_calls should also be rolled back
|
||||
for m in session.messages:
|
||||
if m["role"] == "assistant":
|
||||
assert "tool_calls" not in m or not m["tool_calls"]
|
||||
# Cancelled tool calls should have synthesized results
|
||||
tool_msgs = [m for m in session.messages if m["role"] == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0]["tool_call_id"] == "tc_1"
|
||||
assert "Cancelled by user" in tool_msgs[0]["content"]
|
||||
assert tool_msgs[0].get("is_error") is True
|
||||
# The assistant message with tool_calls should still be present
|
||||
assistant_msgs = [m for m in session.messages if m.get("tool_calls")]
|
||||
assert len(assistant_msgs) == 1
|
||||
|
||||
|
||||
class TestCancelWhenIdle:
|
||||
|
||||
@@ -138,7 +138,7 @@ class TestProbeModelEndpoint:
|
||||
)
|
||||
assert result["reachable"] is True
|
||||
assert result["server_type"] == "anthropic"
|
||||
assert result["context_window"] == 200000
|
||||
assert result["context_window"] == 1000000
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_connection_failure(self, mock_cc: MagicMock) -> None:
|
||||
@@ -184,7 +184,7 @@ class TestLookupModelCapabilities:
|
||||
def test_known_anthropic_model(self) -> None:
|
||||
caps = lookup_model_capabilities("anthropic", "claude-opus-4-6")
|
||||
assert caps is not None
|
||||
assert caps["context_window"] == 200000
|
||||
assert caps["context_window"] == 1000000
|
||||
assert caps["thinking_mode"] == "adaptive"
|
||||
|
||||
def test_unknown_model_returns_none(self) -> None:
|
||||
|
||||
+56
-13
@@ -957,7 +957,7 @@ class TestAnthropicHelpers:
|
||||
|
||||
provider = AnthropicProvider()
|
||||
caps = provider.get_capabilities("claude-opus-4-6")
|
||||
assert caps.context_window == 200000
|
||||
assert caps.context_window == 1000000
|
||||
assert caps.max_output_tokens == 128000
|
||||
assert caps.thinking_mode == "adaptive"
|
||||
assert caps.supports_effort is True
|
||||
@@ -966,11 +966,11 @@ class TestAnthropicHelpers:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider()
|
||||
# Prefix match: "claude-sonnet-4" matches dated variants
|
||||
caps = provider.get_capabilities("claude-sonnet-4-20260101")
|
||||
assert caps.context_window == 200000
|
||||
# Prefix match: "claude-sonnet-4-6" matches dated variants
|
||||
caps = provider.get_capabilities("claude-sonnet-4-6-20260101")
|
||||
assert caps.context_window == 1000000
|
||||
assert caps.token_param == "max_tokens"
|
||||
assert caps.thinking_mode == "manual"
|
||||
assert caps.thinking_mode == "adaptive"
|
||||
|
||||
def test_capabilities_lookup_unknown(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
@@ -1273,11 +1273,13 @@ class TestAnthropicOrphanedToolUse:
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
result_map = {r["tool_use_id"]: r for r in tool_results}
|
||||
assert "c1" in result_map
|
||||
assert result_map["c1"]["content"] == "file1.txt" # real result
|
||||
assert "c2" in result_map
|
||||
assert result_map["c2"]["is_error"] is True # synthetic
|
||||
# Real result should come before synthetic (ordering matters for Anthropic)
|
||||
assert len(tool_results) == 2
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0]["content"] == "file1.txt" # real result
|
||||
assert tool_results[0].get("is_error") is not True
|
||||
assert tool_results[1]["tool_use_id"] == "c2"
|
||||
assert tool_results[1]["is_error"] is True # synthetic
|
||||
|
||||
def test_complete_results_no_synthesis(self) -> None:
|
||||
"""All tool_calls have results — no synthesis needed."""
|
||||
@@ -1294,12 +1296,16 @@ class TestAnthropicOrphanedToolUse:
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# No synthetic results — only the real one
|
||||
# No synthetic results — only the real one (no is_error flag)
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
assert "cancelled" not in block.get("content", "").lower()
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0].get("is_error") is not True
|
||||
|
||||
def test_trailing_orphan(self) -> None:
|
||||
"""Orphaned tool_use at end of conversation (no following messages)."""
|
||||
@@ -1324,6 +1330,43 @@ class TestAnthropicOrphanedToolUse:
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0]["is_error"] is True
|
||||
|
||||
def test_provider_content_orphan(self) -> None:
|
||||
"""Orphaned tool_use inside _provider_content (Anthropic raw blocks)."""
|
||||
messages = [
|
||||
{"role": "user", "content": "run something"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Running...",
|
||||
"_provider_content": [
|
||||
{"type": "text", "text": "Running..."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_abc",
|
||||
"name": "bash",
|
||||
"input": {"command": "sleep 30"},
|
||||
},
|
||||
],
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_abc",
|
||||
"function": {"name": "bash", "arguments": '{"command": "sleep 30"}'},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "never mind"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# Should synthesize a tool_result for the orphaned tool_use in provider_content
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "toolu_abc"
|
||||
assert tool_results[0]["is_error"] is True
|
||||
|
||||
|
||||
class TestAnthropicReasoningNone:
|
||||
"""Verify 'none' effort disables thinking for manual-thinking models."""
|
||||
@@ -1364,7 +1407,7 @@ class TestAnthropicWebSearch:
|
||||
"""All Anthropic models should support native web search."""
|
||||
caps = self.provider.get_capabilities("claude-opus-4-6")
|
||||
assert caps.supports_web_search is True
|
||||
caps = self.provider.get_capabilities("claude-sonnet-4")
|
||||
caps = self.provider.get_capabilities("claude-sonnet-4-6")
|
||||
assert caps.supports_web_search is True
|
||||
# Unknown models use default which also has web search
|
||||
caps = self.provider.get_capabilities("claude-unknown-99")
|
||||
|
||||
@@ -226,3 +226,91 @@ class TestEdgeCases:
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["role"] == "user"
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
|
||||
|
||||
class TestMidConversationOrphanRepair:
|
||||
"""Mid-conversation orphaned tool_calls get synthetic tool results."""
|
||||
|
||||
def test_all_orphaned_mid_conversation(self):
|
||||
"""Assistant has 2 tool_calls, no tool results, then user message."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do stuff"),
|
||||
_row("assistant", "Running...", tool_calls=tc),
|
||||
_row("user", "never mind"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Should have: user, assistant, tool(c1), tool(c2), user
|
||||
assert len(msgs) == 5
|
||||
assert msgs[2]["role"] == "tool"
|
||||
assert msgs[2]["tool_call_id"] == "c1"
|
||||
assert msgs[2]["is_error"] is True
|
||||
assert msgs[3]["role"] == "tool"
|
||||
assert msgs[3]["tool_call_id"] == "c2"
|
||||
assert msgs[4]["role"] == "user"
|
||||
|
||||
def test_partial_results_mid_conversation(self):
|
||||
"""2 tool_calls, 1 result present, 1 missing — synthesize only the missing one."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do stuff"),
|
||||
_row("assistant", "", tool_calls=tc),
|
||||
_row("tool", "file1.txt", tool_name="bash", tc_id="c1"),
|
||||
_row("user", "skip the write"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Should have: user, assistant, tool(c1 real), tool(c2 synthetic), user
|
||||
assert len(msgs) == 5
|
||||
assert msgs[2]["role"] == "tool"
|
||||
assert msgs[2]["tool_call_id"] == "c1"
|
||||
assert msgs[2]["content"] == "file1.txt"
|
||||
assert msgs[2].get("is_error") is not True
|
||||
assert msgs[3]["role"] == "tool"
|
||||
assert msgs[3]["tool_call_id"] == "c2"
|
||||
assert msgs[3]["is_error"] is True
|
||||
assert msgs[4]["role"] == "user"
|
||||
|
||||
def test_complete_results_no_synthesis(self):
|
||||
"""All tool_calls have results — no synthesis needed."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do it"),
|
||||
_row("assistant", "", tool_calls=tc),
|
||||
_row("tool", "done", tool_name="bash", tc_id="c1"),
|
||||
_row("user", "thanks"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
assert len(msgs) == 4
|
||||
tool_msgs = [m for m in msgs if m["role"] == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0].get("is_error") is not True
|
||||
|
||||
def test_trailing_orphan_stripped_not_synthesized(self):
|
||||
"""Trailing orphan is handled by the existing strip repair, not synthesis."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do it"),
|
||||
_row("assistant", "Running...", tool_calls=tc),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Trailing strip removes the assistant message entirely
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["role"] == "user"
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Tests for conversation rewind and retry functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NullUI:
|
||||
"""UI adapter that discards all output."""
|
||||
|
||||
def on_thinking_start(self):
|
||||
pass
|
||||
|
||||
def on_thinking_stop(self):
|
||||
pass
|
||||
|
||||
def on_reasoning_token(self, text):
|
||||
pass
|
||||
|
||||
def on_content_token(self, text):
|
||||
pass
|
||||
|
||||
def on_stream_end(self):
|
||||
pass
|
||||
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
pass
|
||||
|
||||
def on_status(self, usage, context_window, effort):
|
||||
pass
|
||||
|
||||
def on_plan_review(self, content):
|
||||
return ""
|
||||
|
||||
def on_info(self, message):
|
||||
pass
|
||||
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_state_change(self, state):
|
||||
pass
|
||||
|
||||
def on_rename(self, name):
|
||||
pass
|
||||
|
||||
def on_output_warning(self, call_id, assessment):
|
||||
pass
|
||||
|
||||
|
||||
def _make_session(tmp_db) -> ChatSession:
|
||||
return ChatSession(
|
||||
client=MagicMock(),
|
||||
model="test-model",
|
||||
ui=NullUI(),
|
||||
instructions="",
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _populate_simple(session: ChatSession) -> None:
|
||||
"""Populate with 2 simple turns (no tool calls)."""
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
{"role": "assistant", "content": "I'm fine."},
|
||||
]
|
||||
session._msg_tokens = [10, 20, 10, 20]
|
||||
|
||||
|
||||
def _populate_with_tools(session: ChatSession) -> None:
|
||||
"""Populate with 2 turns, first has tool calls."""
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Write a test"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc1", "function": {"name": "bash", "arguments": '{"cmd":"echo hi"}'}}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc1", "content": "hi"},
|
||||
{"role": "assistant", "content": "Done."},
|
||||
{"role": "user", "content": "Fix the import"},
|
||||
{"role": "assistant", "content": "Fixed."},
|
||||
]
|
||||
session._msg_tokens = [10, 20, 10, 20, 10, 20]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_turn_boundaries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindTurnBoundaries:
|
||||
def test_empty_messages(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session._find_turn_boundaries() == []
|
||||
|
||||
def test_single_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
assert session._find_turn_boundaries() == [0]
|
||||
|
||||
def test_multi_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
assert session._find_turn_boundaries() == [0, 2]
|
||||
|
||||
def test_with_tool_calls(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
assert session._find_turn_boundaries() == [0, 4]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rewind
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRewind:
|
||||
def test_rewind_zero(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
assert session.rewind(0) == 0
|
||||
assert len(session.messages) == 4
|
||||
|
||||
def test_rewind_one_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(1)
|
||||
assert removed == 2 # user + assistant
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[0]["content"] == "Hello"
|
||||
assert session.messages[1]["content"] == "Hi there!"
|
||||
assert len(session._msg_tokens) == 2
|
||||
|
||||
def test_rewind_all_turns(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(2)
|
||||
assert removed == 4
|
||||
assert len(session.messages) == 0
|
||||
assert len(session._msg_tokens) == 0
|
||||
|
||||
def test_rewind_clamped(self, tmp_db):
|
||||
"""Rewinding more turns than exist should clamp to available."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(999)
|
||||
assert removed == 4
|
||||
assert len(session.messages) == 0
|
||||
|
||||
def test_rewind_empty(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session.rewind(1) == 0
|
||||
|
||||
def test_rewind_with_tools(self, tmp_db):
|
||||
"""Rewinding 1 turn on a multi-sub-turn conversation."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
removed = session.rewind(1)
|
||||
assert removed == 2 # user "Fix the import" + assistant "Fixed."
|
||||
assert len(session.messages) == 4
|
||||
assert session.messages[-1]["content"] == "Done."
|
||||
|
||||
def test_rewind_tokens_sync(self, tmp_db):
|
||||
"""_msg_tokens stays in sync with messages."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.rewind(1)
|
||||
assert len(session._msg_tokens) == len(session.messages)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# retry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetry:
|
||||
def test_retry_returns_user_message(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
msg = session.retry()
|
||||
assert msg == "How are you?"
|
||||
# Only Turn 1 remains, without the second user message
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[-1]["content"] == "Hi there!"
|
||||
|
||||
def test_retry_empty(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session.retry() is None
|
||||
|
||||
def test_retry_with_tools(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
msg = session.retry()
|
||||
assert msg == "Fix the import"
|
||||
# Only Turn 1 remains (user + assistant w/tools + tool result + assistant)
|
||||
assert len(session.messages) == 4
|
||||
|
||||
def test_retry_sets_pending(self, tmp_db):
|
||||
"""handle_command for /retry should set _pending_retry."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.handle_command("/retry")
|
||||
assert session._pending_retry == "How are you?"
|
||||
|
||||
def test_retry_tokens_sync(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.retry()
|
||||
assert len(session._msg_tokens) == len(session.messages)
|
||||
|
||||
def test_retry_multipart_content_returns_none(self, tmp_db):
|
||||
"""retry() should refuse multipart (vision/image) messages."""
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "describe this"}]},
|
||||
{"role": "assistant", "content": "It's an image."},
|
||||
]
|
||||
session._msg_tokens = [10, 20]
|
||||
assert session.retry() is None
|
||||
# Messages should be unchanged
|
||||
assert len(session.messages) == 2
|
||||
|
||||
def test_retry_none_content_returns_none(self, tmp_db):
|
||||
"""retry() should handle content=None gracefully."""
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": None},
|
||||
{"role": "assistant", "content": "Ok."},
|
||||
]
|
||||
session._msg_tokens = [10, 20]
|
||||
assert session.retry() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_command integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
def test_rewind_command(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.handle_command("/rewind 1")
|
||||
assert len(session.messages) == 2
|
||||
|
||||
def test_rewind_no_arg(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/rewind")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "Usage" in ui.on_info.call_args[0][0]
|
||||
|
||||
def test_rewind_invalid_arg(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/rewind abc")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "integer" in ui.on_info.call_args[0][0]
|
||||
|
||||
def test_retry_nothing_to_retry(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/retry")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "Nothing" in ui.on_info.call_args[0][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage integration — delete_messages_after
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteMessagesAfter:
|
||||
def test_delete_truncates_db(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
load_messages,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-delete"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
save_message(ws_id, "user", "Bye")
|
||||
save_message(ws_id, "assistant", "Goodbye!")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 2)
|
||||
assert deleted == 2
|
||||
|
||||
msgs = load_messages(ws_id)
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["content"] == "Hello"
|
||||
assert msgs[1]["content"] == "Hi!"
|
||||
|
||||
def test_delete_nothing(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-noop"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 10)
|
||||
assert deleted == 0
|
||||
|
||||
def test_delete_all(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
load_messages,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-all"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 0)
|
||||
assert deleted == 2
|
||||
assert load_messages(ws_id) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: rewind + DB sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRewindDBSync:
|
||||
def test_rewind_persists_to_db(self, tmp_db):
|
||||
from turnstone.core.memory import load_messages, register_workstream, save_message
|
||||
|
||||
session = _make_session(tmp_db)
|
||||
ws_id = session.ws_id
|
||||
register_workstream(ws_id)
|
||||
|
||||
# Persist messages to DB and set in-memory state
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
save_message(ws_id, "user", "Bye")
|
||||
save_message(ws_id, "assistant", "Goodbye!")
|
||||
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "Bye"},
|
||||
{"role": "assistant", "content": "Goodbye!"},
|
||||
]
|
||||
session._msg_tokens = [5, 5, 5, 5]
|
||||
|
||||
session.rewind(1)
|
||||
|
||||
# Verify DB matches in-memory state
|
||||
db_msgs = load_messages(ws_id)
|
||||
assert len(db_msgs) == 2
|
||||
assert db_msgs[0]["content"] == "Hello"
|
||||
assert db_msgs[1]["content"] == "Hi!"
|
||||
@@ -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
|
||||
|
||||
@@ -72,18 +72,19 @@ class TestToolsMetadata:
|
||||
"""Validate the metadata extracted from JSON files."""
|
||||
|
||||
def test_tool_count(self):
|
||||
assert len(TOOLS) == 18
|
||||
assert len(TOOLS) == 19
|
||||
|
||||
def test_agent_tools_count(self):
|
||||
assert len(AGENT_TOOLS) == 9
|
||||
assert len(AGENT_TOOLS) == 10
|
||||
|
||||
def test_task_agent_tools_count(self):
|
||||
assert len(TASK_AGENT_TOOLS) == 12
|
||||
assert len(TASK_AGENT_TOOLS) == 13
|
||||
|
||||
def test_auto_approve_sets_match(self):
|
||||
expected = {
|
||||
"read_file",
|
||||
"search",
|
||||
"diff_file",
|
||||
"math",
|
||||
"man",
|
||||
"web_fetch",
|
||||
@@ -113,6 +114,7 @@ class TestToolsMetadata:
|
||||
"read_resource": "uri",
|
||||
"use_prompt": "name",
|
||||
"skill": "name",
|
||||
"diff_file": "path_a",
|
||||
}
|
||||
assert expected == PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.9.2"
|
||||
__version__ = "0.9.5"
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -63,6 +63,8 @@ SLASH_COMMANDS = [
|
||||
"/creative",
|
||||
"/debug",
|
||||
"/mcp",
|
||||
"/retry",
|
||||
"/rewind",
|
||||
"/help",
|
||||
"/exit",
|
||||
"/quit",
|
||||
@@ -1254,6 +1256,16 @@ def main() -> None:
|
||||
should_exit = active.session.handle_command(user_input)
|
||||
if should_exit:
|
||||
break
|
||||
# Dispatch deferred retry (handle_command sets _pending_retry)
|
||||
retry_msg = active.session._pending_retry
|
||||
if retry_msg:
|
||||
active.session._pending_retry = None
|
||||
try:
|
||||
active.session.send(retry_msg)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{yellow('Interrupted.')}")
|
||||
except Exception as e:
|
||||
print(f"\n{red(f'Error: {e}')}")
|
||||
else:
|
||||
try:
|
||||
active.session.send(user_input)
|
||||
|
||||
@@ -1771,6 +1771,7 @@ _VALID_PERMISSIONS = frozenset(
|
||||
"tools.approve",
|
||||
"workstreams.create",
|
||||
"workstreams.close",
|
||||
"conversation.modify",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+33
-5
@@ -189,6 +189,20 @@ _CRITICAL_RULES: list[_HeuristicRule] = [
|
||||
"This is a two-step variant of pipe-to-shell."
|
||||
),
|
||||
),
|
||||
_HeuristicRule(
|
||||
name="proc-environ-exfil",
|
||||
risk_level="critical",
|
||||
confidence=0.95,
|
||||
recommendation="deny",
|
||||
tool_pattern="bash",
|
||||
arg_patterns=[r"/proc/\d+/environ", r"/proc/self/environ"],
|
||||
intent_template="Process environment exfiltration: {arg_snippet}",
|
||||
reasoning_template=(
|
||||
"Reading /proc/*/environ exposes all environment variables of the "
|
||||
"target process, which may include database credentials, API keys, "
|
||||
"and JWT secrets. This is a credential exfiltration vector."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
# -- High (confidence 0.80, review) ----------------------------------------
|
||||
@@ -606,7 +620,10 @@ def _get_arg_text(func_name: str, func_args: dict[str, object]) -> str:
|
||||
if func_name == "bash":
|
||||
return str(func_args.get("command", ""))
|
||||
if func_name in ("write_file", "edit_file"):
|
||||
return str(func_args.get("path", ""))
|
||||
path = str(func_args.get("path", ""))
|
||||
expanded = os.path.expanduser(path) if path else ""
|
||||
resolved = os.path.realpath(expanded) if expanded else ""
|
||||
return f"{path} {resolved}" if resolved != os.path.abspath(expanded) else path
|
||||
try:
|
||||
return json.dumps(func_args, ensure_ascii=False, separators=(",", ":"))
|
||||
except (TypeError, ValueError):
|
||||
@@ -1031,10 +1048,11 @@ class IntentJudge:
|
||||
# Prepare context
|
||||
judge_messages = self._prepare_context(item, messages)
|
||||
|
||||
# Prepare tools (only if read_only_tools enabled)
|
||||
# Prepare tools (only if read_only_tools enabled).
|
||||
# Pass raw OpenAI-format schemas — create_completion handles conversion.
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
if self._config.read_only_tools:
|
||||
tools = self._provider.convert_tools(_JUDGE_TOOL_SCHEMAS)
|
||||
tools = _JUDGE_TOOL_SCHEMAS
|
||||
|
||||
# Multi-turn judge loop
|
||||
timeout_budget = self._config.timeout
|
||||
@@ -1212,10 +1230,20 @@ class IntentJudge:
|
||||
f"{json.dumps(func_args, indent=2, ensure_ascii=False)}\n```"
|
||||
)
|
||||
|
||||
# FIFO truncation of conversation history (keep most recent)
|
||||
# Trim to messages from the last user message onward — the judge
|
||||
# only needs the immediate request context, not the full history.
|
||||
# This keeps latency bounded as conversations grow.
|
||||
last_user_idx = None
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
last_user_idx = i
|
||||
break
|
||||
recent = messages[last_user_idx:] if last_user_idx is not None else messages
|
||||
|
||||
# Apply FIFO budget cap on the trimmed context
|
||||
truncated: list[dict[str, Any]] = []
|
||||
total_chars = 0
|
||||
for msg in reversed(messages):
|
||||
for msg in reversed(recent):
|
||||
content = msg.get("content", "") or ""
|
||||
if isinstance(content, list):
|
||||
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
|
||||
@@ -65,6 +65,23 @@ def load_messages(ws_id: str) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def delete_messages_after(ws_id: str, keep_count: int) -> int:
|
||||
"""Delete conversation rows beyond the first *keep_count* rows.
|
||||
|
||||
Returns the number of rows deleted, or 0 on error.
|
||||
"""
|
||||
try:
|
||||
return get_storage().delete_messages_after(ws_id, keep_count)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"Failed to delete messages after count=%d for ws=%s",
|
||||
keep_count,
|
||||
ws_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# -- Workstream management ----------------------------------------------------
|
||||
|
||||
|
||||
@@ -250,10 +267,10 @@ def update_workstream_title(ws_id: str, title: str) -> None:
|
||||
# -- Conversation search -------------------------------------------------------
|
||||
|
||||
|
||||
def search_history(query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
"""Search conversation history."""
|
||||
try:
|
||||
return get_storage().search_history(query, limit)
|
||||
return get_storage().search_history(query, limit, offset)
|
||||
except Exception:
|
||||
log.warning("Failed to search history", exc_info=True)
|
||||
return []
|
||||
@@ -314,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)
|
||||
|
||||
@@ -51,11 +51,13 @@ _RE_PRIVATE_KEY_BLOCK = re.compile(
|
||||
r"-----END\s+(?:RSA\s+|EC\s+|OPENSSH\s+|PGP\s+)?PRIVATE\s+KEY-----",
|
||||
)
|
||||
_RE_CONNECTION_STRING = re.compile(
|
||||
r"(?:postgresql|mysql|mongodb|redis|amqp)://[^:@\s]+:[^@\s]+@",
|
||||
r"(?:postgresql\+?(?:psycopg)?|mysql|mongodb|redis|amqp|sqlite)://[^:@\s]+:[^@\s]+@",
|
||||
)
|
||||
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
|
||||
_RE_ENV_SECRET_KEY = re.compile(
|
||||
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
|
||||
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL|DSN)(?:_|$)"
|
||||
r"|(?:^|_)KEY(?:_|$)"
|
||||
r"|^(?:DATABASE_URL|TURNSTONE_DB_URL|DB_URL)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_JSON_SECRET = re.compile(
|
||||
|
||||
@@ -84,7 +84,7 @@ _ANTHROPIC_DEFAULT = ModelCapabilities(
|
||||
|
||||
_ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
"claude-opus-4-6": ModelCapabilities(
|
||||
context_window=200000,
|
||||
context_window=1000000,
|
||||
max_output_tokens=128000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="adaptive",
|
||||
@@ -95,7 +95,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4-6": ModelCapabilities(
|
||||
context_window=200000,
|
||||
context_window=1000000,
|
||||
max_output_tokens=64000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="adaptive",
|
||||
@@ -131,24 +131,6 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_web_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-opus-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=32000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=64000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +272,7 @@ class AnthropicProvider:
|
||||
"""
|
||||
system_parts: list[str] = []
|
||||
converted: list[dict[str, Any]] = []
|
||||
pending_orphan_results: list[dict[str, Any]] = []
|
||||
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
@@ -303,11 +286,50 @@ class AnthropicProvider:
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
# Safety: flush any unconsumed synthetic results from a prior
|
||||
# assistant message (should not happen with well-formed data).
|
||||
if pending_orphan_results:
|
||||
converted.append({"role": "user", "content": pending_orphan_results})
|
||||
pending_orphan_results = []
|
||||
# If raw provider content was preserved, pass it through verbatim
|
||||
# so encrypted_content/encrypted_index from web search are retained
|
||||
provider_content = msg.get("_provider_content")
|
||||
if provider_content:
|
||||
converted.append({"role": "assistant", "content": provider_content})
|
||||
# Check for orphaned tool_use in provider content too
|
||||
if isinstance(provider_content, list):
|
||||
pc_tool_ids = [
|
||||
b["id"]
|
||||
for b in provider_content
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
|
||||
]
|
||||
if pc_tool_ids:
|
||||
j = i + 1
|
||||
result_ids_pc: set[str] = set()
|
||||
while j < len(messages) and messages[j]["role"] == "tool":
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids_pc.add(tc_id)
|
||||
j += 1
|
||||
orphaned_pc = [uid for uid in pc_tool_ids if uid not in result_ids_pc]
|
||||
if orphaned_pc:
|
||||
log.debug(
|
||||
"Synthesizing %d tool_result(s) for orphaned provider_content tool_use IDs",
|
||||
len(orphaned_pc),
|
||||
)
|
||||
synthetic_pc = [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": uid,
|
||||
"content": "Tool execution was cancelled.",
|
||||
"is_error": True,
|
||||
}
|
||||
for uid in orphaned_pc
|
||||
]
|
||||
if j == i + 1:
|
||||
converted.append({"role": "user", "content": synthetic_pc})
|
||||
else:
|
||||
pending_orphan_results = synthetic_pc
|
||||
i += 1
|
||||
continue
|
||||
|
||||
@@ -339,20 +361,28 @@ class AnthropicProvider:
|
||||
# happens when a cancel interrupts tool execution — the
|
||||
# assistant message is saved to DB before tools run, but
|
||||
# GenerationCancelled prevents tool results from being created.
|
||||
tool_use_ids = {b["id"] for b in content_blocks if b.get("type") == "tool_use"}
|
||||
# Collect IDs in order, skip empty IDs (from malformed tool calls).
|
||||
tool_use_ids = [
|
||||
b["id"] for b in content_blocks if b.get("type") == "tool_use" and b.get("id")
|
||||
]
|
||||
if tool_use_ids:
|
||||
# Peek ahead to collect tool_result IDs
|
||||
j = i + 1
|
||||
result_ids: set[str] = set()
|
||||
while j < len(messages) and messages[j]["role"] == "tool":
|
||||
result_ids.add(messages[j].get("tool_call_id", ""))
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids.add(tc_id)
|
||||
j += 1
|
||||
orphaned = tool_use_ids - result_ids
|
||||
orphaned = [uid for uid in tool_use_ids if uid not in result_ids]
|
||||
if orphaned:
|
||||
log.debug(
|
||||
"Synthesizing %d tool_result(s) for orphaned tool_use IDs",
|
||||
len(orphaned),
|
||||
)
|
||||
# Store for deferred injection — synthetic results are
|
||||
# appended after any real tool results so
|
||||
# _merge_consecutive produces them in tool_use order.
|
||||
synthetic = [
|
||||
{
|
||||
"type": "tool_result",
|
||||
@@ -362,29 +392,67 @@ class AnthropicProvider:
|
||||
}
|
||||
for uid in orphaned
|
||||
]
|
||||
converted.append({"role": "user", "content": synthetic})
|
||||
if j == i + 1:
|
||||
# No real tool messages follow — inject immediately
|
||||
converted.append({"role": "user", "content": synthetic})
|
||||
else:
|
||||
# Real tool messages follow — they'll be converted
|
||||
# next iteration. Stash synthetic results to append
|
||||
# after them.
|
||||
pending_orphan_results = synthetic
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Anthropic: tool results are content blocks in a user message
|
||||
# Anthropic: tool results are content blocks in a user message.
|
||||
# Collect valid tool_use IDs from the preceding assistant message
|
||||
# so we can drop orphaned tool_results that have no matching
|
||||
# tool_use (e.g. from compaction boundary, old cancel stripping).
|
||||
prev_tool_use_ids: set[str] = set()
|
||||
if converted and converted[-1].get("role") == "assistant":
|
||||
prev_content = converted[-1].get("content", [])
|
||||
if isinstance(prev_content, list):
|
||||
for block in prev_content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
bid = block.get("id", "")
|
||||
if bid:
|
||||
prev_tool_use_ids.add(bid)
|
||||
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
while i < len(messages) and messages[i]["role"] == "tool":
|
||||
tool_msg = messages[i]
|
||||
tc_id = tool_msg.get("tool_call_id", "")
|
||||
# Drop orphaned tool_results with no matching tool_use.
|
||||
# When prev_tool_use_ids is empty (no preceding assistant
|
||||
# tool_use), let all results through — avoids false drops
|
||||
# from unexpected message ordering.
|
||||
if prev_tool_use_ids and tc_id not in prev_tool_use_ids:
|
||||
log.debug(
|
||||
"Dropping orphaned tool_result (no matching tool_use): %s",
|
||||
tc_id,
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
content = tool_msg.get("content", "")
|
||||
# Convert image_url parts to Anthropic image format
|
||||
if isinstance(content, list):
|
||||
content = self._convert_content_parts(content)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_msg.get("tool_call_id", ""),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
result_block: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc_id,
|
||||
"content": content,
|
||||
}
|
||||
if tool_msg.get("is_error"):
|
||||
result_block["is_error"] = True
|
||||
tool_results.append(result_block)
|
||||
i += 1
|
||||
converted.append({"role": "user", "content": tool_results})
|
||||
# Append any deferred synthetic results after real ones
|
||||
if pending_orphan_results:
|
||||
tool_results.extend(pending_orphan_results)
|
||||
pending_orphan_results = []
|
||||
if tool_results:
|
||||
converted.append({"role": "user", "content": tool_results})
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
|
||||
@@ -11,12 +11,30 @@ BLOCKED_PATTERNS = [
|
||||
"reboot",
|
||||
"halt",
|
||||
"poweroff",
|
||||
"dd if=",
|
||||
"of=/dev/sd",
|
||||
"of=/dev/nvme",
|
||||
"of=/dev/vd",
|
||||
"of=/dev/xvd",
|
||||
"of=/dev/hd",
|
||||
"of=/dev/dm-",
|
||||
"of=/dev/md",
|
||||
"of=/dev/loop",
|
||||
"of=/dev/disk/",
|
||||
":(){ :|:& };:", # fork bomb
|
||||
"> /dev/sda",
|
||||
"> /dev/sd",
|
||||
"> /dev/nvme",
|
||||
"> /dev/vd",
|
||||
"> /dev/xvd",
|
||||
"> /dev/hd",
|
||||
"> /dev/dm-",
|
||||
"> /dev/md",
|
||||
"> /dev/disk/",
|
||||
"mv / ",
|
||||
"chmod -R 777 /",
|
||||
"chown -R ",
|
||||
# Credential exfiltration via procfs
|
||||
"/proc/1/environ",
|
||||
"/proc/self/environ",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ _MATH_BLOCKED_BUILTINS = {
|
||||
"globals",
|
||||
"locals",
|
||||
"vars",
|
||||
# Reflection primitives — bypass AST dunder checks via runtime strings
|
||||
"getattr",
|
||||
"setattr",
|
||||
"delattr",
|
||||
# Type system — can reconstruct arbitrary classes
|
||||
"type",
|
||||
# Import — the replaced _safe_import is in the namespace, but block the
|
||||
# name so direct __import__ calls are caught by the AST validator
|
||||
"__import__",
|
||||
}
|
||||
|
||||
_MATH_BLOCKED_MODULES = {
|
||||
@@ -85,6 +94,9 @@ class _ASTValidator(ast.NodeVisitor):
|
||||
and node.attr not in {"__name__", "__doc__", "__class__"}
|
||||
):
|
||||
self.errors.append(f"Access to '{node.attr}' is not allowed")
|
||||
# Block operator.attrgetter/itemgetter which act as getattr bypasses
|
||||
if node.attr in ("attrgetter", "itemgetter"):
|
||||
self.errors.append(f"Access to '{node.attr}' is not allowed")
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
@@ -109,6 +121,7 @@ def validate_math_code(code: str) -> list[str]:
|
||||
|
||||
def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[str, str]]) -> None:
|
||||
"""Execute code in a subprocess, put (status, output) in queue."""
|
||||
import contextlib
|
||||
import signal as _signal
|
||||
import sys as _sys
|
||||
from io import StringIO
|
||||
@@ -124,7 +137,14 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[s
|
||||
def _safe_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name.split(".")[0] in _MATH_BLOCKED_MODULES:
|
||||
raise ImportError(f"Import of '{name}' is blocked")
|
||||
return original_import(name, *args, **kwargs)
|
||||
mod = original_import(name, *args, **kwargs)
|
||||
# Strip __builtins__ from every imported module so
|
||||
# module.__builtins__['__import__'] can't bypass _safe_import
|
||||
# (covers operator.attrgetter('__builtins__') and similar).
|
||||
if hasattr(mod, "__builtins__"):
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
mod.__builtins__ = {} # type: ignore[attr-defined]
|
||||
return mod
|
||||
|
||||
original_import = (
|
||||
__builtins__["__import__"]
|
||||
@@ -242,7 +262,14 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
exec(code, ns)
|
||||
# Strip __builtins__ from all pre-imported modules so
|
||||
# module.__builtins__['__import__'] can't bypass _safe_import.
|
||||
for v in list(ns.values()):
|
||||
if hasattr(v, "__builtins__"):
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
v.__builtins__ = {}
|
||||
|
||||
exec(code, ns) # noqa: S102
|
||||
|
||||
_sys.stdout = _sys.__stdout__
|
||||
printed = captured.getvalue()
|
||||
|
||||
+594
-101
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
@@ -147,6 +150,29 @@ class PostgreSQLBackend:
|
||||
).fetchall()
|
||||
return _reconstruct_messages(list(rows), ws_id)
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
cutoff_row = conn.execute(
|
||||
sa.select(conversations.c.id)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
.order_by(conversations.c.id)
|
||||
.limit(1)
|
||||
.offset(keep_count)
|
||||
).fetchone()
|
||||
if cutoff_row is None:
|
||||
return 0
|
||||
cutoff_id = cutoff_row[0]
|
||||
result = conn.execute(
|
||||
sa.delete(conversations).where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c.id >= cutoff_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -383,10 +409,11 @@ class PostgreSQLBackend:
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
capped = min(limit, 100)
|
||||
capped = min(int(limit), 100)
|
||||
capped_offset = max(0, int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
# Use PostgreSQL full-text search if search_vector column exists
|
||||
try:
|
||||
@@ -399,9 +426,9 @@ class PostgreSQLBackend:
|
||||
" @@ plainto_tsquery('english', :query) "
|
||||
"ORDER BY ts_rank(to_tsvector('english', COALESCE(c.content, '')), "
|
||||
" plainto_tsquery('english', :query)) DESC "
|
||||
"LIMIT :limit"
|
||||
"LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"query": query, "limit": capped},
|
||||
{"query": query, "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
except Exception:
|
||||
@@ -411,9 +438,9 @@ class PostgreSQLBackend:
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content ILIKE :pattern "
|
||||
"ORDER BY timestamp DESC LIMIT :limit"
|
||||
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"pattern": f"%{query}%", "limit": capped},
|
||||
{"pattern": f"%{query}%", "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ class StorageBackend(Protocol):
|
||||
"""Load messages for a workstream and reconstruct OpenAI message format."""
|
||||
...
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
"""Delete conversation rows beyond the first *keep_count* rows for a workstream.
|
||||
|
||||
Rows are ordered by auto-increment ``id``. If the workstream has
|
||||
N rows total and ``keep_count`` < N, the last N - keep_count rows
|
||||
are deleted. Returns the number of rows deleted.
|
||||
"""
|
||||
...
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -180,7 +189,7 @@ class StorageBackend(Protocol):
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
"""Search conversation history. Returns (timestamp, ws_id, role, content, tool_name)."""
|
||||
...
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -212,6 +215,43 @@ class SQLiteBackend:
|
||||
|
||||
return _reconstruct_messages(list(rows), ws_id)
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
# Find the id of the first row to delete (the row at offset keep_count)
|
||||
cutoff_row = conn.execute(
|
||||
sa.select(conversations.c.id)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
.order_by(conversations.c.id)
|
||||
.limit(1)
|
||||
.offset(keep_count)
|
||||
).fetchone()
|
||||
if cutoff_row is None:
|
||||
return 0 # nothing to delete
|
||||
cutoff_id = cutoff_row[0]
|
||||
# Remove FTS5 entries first (external content table doesn't auto-sync)
|
||||
if self._fts5_available:
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"DELETE FROM conversations_fts WHERE rowid IN "
|
||||
"(SELECT id FROM conversations "
|
||||
" WHERE ws_id = :ws_id AND id >= :cutoff_id)"
|
||||
),
|
||||
{"ws_id": ws_id, "cutoff_id": cutoff_id},
|
||||
)
|
||||
except Exception:
|
||||
self._fts5_available = False
|
||||
result = conn.execute(
|
||||
sa.delete(conversations).where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c.id >= cutoff_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -457,10 +497,11 @@ class SQLiteBackend:
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
capped = min(limit, 100)
|
||||
capped = min(int(limit), 100)
|
||||
capped_offset = max(0, int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
if self._fts5_available:
|
||||
return list(
|
||||
@@ -470,9 +511,9 @@ class SQLiteBackend:
|
||||
"FROM conversations_fts f "
|
||||
"JOIN conversations c ON c.id = f.rowid "
|
||||
"WHERE conversations_fts MATCH :query "
|
||||
"ORDER BY f.rank ASC LIMIT :limit"
|
||||
"ORDER BY f.rank ASC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"query": _fts5_query(query), "limit": capped},
|
||||
{"query": _fts5_query(query), "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
return list(
|
||||
@@ -480,9 +521,13 @@ class SQLiteBackend:
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content LIKE :pattern ESCAPE '\\' "
|
||||
"ORDER BY timestamp DESC LIMIT :limit"
|
||||
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"pattern": f"%{_escape_like(query)}%", "limit": capped},
|
||||
{
|
||||
"pattern": f"%{_escape_like(query)}%",
|
||||
"limit": capped,
|
||||
"offset": capped_offset,
|
||||
},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -200,4 +216,45 @@ def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
|
||||
break
|
||||
del messages[asst_idx:]
|
||||
|
||||
# Repair: synthesize tool results for mid-conversation orphaned tool calls.
|
||||
# This happens when a cancel interrupts tool execution — the assistant
|
||||
# message with tool_calls is saved to DB but GenerationCancelled prevents
|
||||
# tool results from being created. Both Anthropic (strict) and OpenAI
|
||||
# (lenient today, may tighten) benefit from well-formed histories.
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
msg = messages[i]
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
expected_ids = [tc.get("id", "") for tc in msg["tool_calls"] if tc.get("id")]
|
||||
# Collect tool result IDs that follow
|
||||
j = i + 1
|
||||
result_ids: set[str] = set()
|
||||
while j < len(messages) and messages[j].get("role") == "tool":
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids.add(tc_id)
|
||||
j += 1
|
||||
# Synthesize results for any missing IDs
|
||||
orphaned = [uid for uid in expected_ids if uid not in result_ids]
|
||||
if orphaned:
|
||||
synthetic = [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": uid,
|
||||
"content": "Tool execution was cancelled.",
|
||||
"is_error": True,
|
||||
}
|
||||
for uid in orphaned
|
||||
]
|
||||
# Insert after the last existing tool result (or after assistant)
|
||||
messages[j:j] = synthetic
|
||||
if orphaned:
|
||||
i = j + len(orphaned) # skip past spliced synthetics
|
||||
elif j > i + 1:
|
||||
i = j # skip past existing tool block
|
||||
else:
|
||||
i += 1 # no tools followed; just advance
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return messages
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Grant conversation.modify permission to admin and operator roles.
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2026-03-29
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "029"
|
||||
down_revision = "028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# Grant to admin role
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = permissions || ',conversation.modify' "
|
||||
"WHERE role_id = 'builtin-admin' "
|
||||
"AND permissions NOT LIKE '%conversation.modify%'"
|
||||
)
|
||||
)
|
||||
# Grant to operator role
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = permissions || ',conversation.modify' "
|
||||
"WHERE role_id = 'builtin-operator' "
|
||||
"AND permissions NOT LIKE '%conversation.modify%'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = REPLACE(permissions, ',conversation.modify', '') "
|
||||
"WHERE role_id IN ('builtin-admin', 'builtin-operator')"
|
||||
)
|
||||
)
|
||||
+23
-7
@@ -21,16 +21,32 @@ def strip_html(html: str) -> str:
|
||||
|
||||
|
||||
def check_ssrf(url: str) -> str | None:
|
||||
"""Return error string if URL resolves to a private/link-local address, else None."""
|
||||
"""Return error string if URL resolves to a private/link-local address, else None.
|
||||
|
||||
Checks both IPv4 and IPv6 addresses via getaddrinfo to prevent bypasses
|
||||
using IPv6 loopback (``::1``), link-local (``fe80::``), or unique-local
|
||||
(``fd00::``/``fc00::``) addresses.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return "Invalid URL: no hostname"
|
||||
addr = socket.gethostbyname(hostname)
|
||||
ip = ipaddress.ip_address(addr)
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||
return f"Blocked: URL resolves to private/internal address ({addr})"
|
||||
except (socket.gaierror, ValueError):
|
||||
pass # DNS failure or invalid IP — let the actual fetch handle it
|
||||
# Resolve all address families (IPv4 + IPv6)
|
||||
results = socket.getaddrinfo(hostname, parsed.port or 80, proto=socket.IPPROTO_TCP)
|
||||
for _family, _type, _proto, _canonname, sockaddr in results:
|
||||
addr = str(sockaddr[0])
|
||||
# Strip IPv6 zone/scope identifier (e.g. "fe80::1%lo0")
|
||||
addr_clean = addr.split("%", 1)[0] if "%" in addr else addr
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr_clean)
|
||||
except ValueError:
|
||||
return f"Blocked: unable to parse resolved address ({addr})"
|
||||
# Normalize IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||
return f"Blocked: URL resolves to private/internal address ({addr})"
|
||||
except (socket.gaierror, OSError):
|
||||
pass # DNS failure — let the actual fetch handle it
|
||||
return None
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+117
-1
@@ -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
|
||||
@@ -805,6 +811,22 @@ def _get_ws(
|
||||
return None, None
|
||||
|
||||
|
||||
def _audit_context(request: Request) -> tuple[str, str]:
|
||||
"""Extract (user_id, ip_address) from request for audit logging."""
|
||||
auth = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
uid: str = auth.user_id if auth else ""
|
||||
ip = ""
|
||||
if request.client:
|
||||
ip = request.client.host
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
from turnstone.core.auth import is_secure_request
|
||||
|
||||
if is_secure_request(dict(request.headers), request.url.scheme):
|
||||
ip = forwarded.split(",")[0].strip()
|
||||
return uid, ip
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers — all async
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -840,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:
|
||||
@@ -1224,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"})
|
||||
|
||||
|
||||
@@ -1331,11 +1380,30 @@ async def command(request: Request) -> JSONResponse:
|
||||
assert ws.session is not None
|
||||
|
||||
try:
|
||||
# Permission gate for conversation-modifying commands
|
||||
cmd_word = cmd.strip().split(None, 1)[0].lower()
|
||||
if cmd_word in ("/rewind", "/retry"):
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "conversation.modify")
|
||||
if err:
|
||||
ui.on_error("Permission denied: conversation.modify required")
|
||||
return err
|
||||
# Prevent rewind/retry while a generation is in progress
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
ui._enqueue(
|
||||
{
|
||||
"type": "busy_error",
|
||||
"message": "Cannot rewind/retry while processing.",
|
||||
}
|
||||
)
|
||||
return JSONResponse({"status": "busy"})
|
||||
|
||||
should_exit = ws.session.handle_command(cmd)
|
||||
if should_exit:
|
||||
ui.on_info("Session ended. You can close this tab.")
|
||||
# Handle UI updates for workstream-changing commands
|
||||
cmd_word = cmd.strip().split(None, 1)[0].lower()
|
||||
if cmd_word in ("/clear", "/new"):
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
elif cmd_word == "/resume":
|
||||
@@ -1343,6 +1411,54 @@ async def command(request: Request) -> JSONResponse:
|
||||
history = _build_history(ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
elif cmd_word in ("/rewind", "/retry"):
|
||||
# Refresh frontend with truncated history
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
# Audit trail
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage:
|
||||
from turnstone.core.audit import record_audit
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
f"conversation.{cmd_word[1:]}",
|
||||
"workstream",
|
||||
ws.id,
|
||||
{"command": cmd, "ws_id": ws.id},
|
||||
ip,
|
||||
)
|
||||
# Dispatch deferred retry in background thread
|
||||
retry_msg = ws.session._pending_retry
|
||||
if retry_msg:
|
||||
ws.session._pending_retry = None
|
||||
session = ws.session
|
||||
|
||||
def run_retry() -> None:
|
||||
me = threading.current_thread()
|
||||
try:
|
||||
session.send(retry_msg)
|
||||
except GenerationCancelled:
|
||||
if ws.worker_thread is me:
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("idle")
|
||||
except Exception as exc:
|
||||
if ws.worker_thread is me:
|
||||
ui.on_error(f"Error: {exc}")
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("error")
|
||||
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
ui.on_error("Cannot retry: workstream is busy")
|
||||
else:
|
||||
t = threading.Thread(target=run_retry, daemon=True)
|
||||
ws.worker_thread = t
|
||||
t.start()
|
||||
# Sync in-memory workstream name after any command that can change it.
|
||||
# This ensures /api/workstreams and future page loads see the right name.
|
||||
if cmd_word in ("/name", "/resume"):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{
|
||||
"name": "bash",
|
||||
"description": "Execute a bash command and return stdout + stderr. Use for running programs, git, tests, system commands, installing packages, etc. Environment questions ('What Python version?', 'Is X installed?') are tool-use tasks — e.g. bash(command='python --version'). For file creation use write_file instead; for man pages use man instead.",
|
||||
"description": "Execute a bash command and return stdout + stderr. Use for running programs, git, tests, system commands, installing packages, etc. Environment questions ('What Python version?', 'Is X installed?') are tool-use tasks — e.g. bash(command='python --version'). For file creation use write_file instead; for man pages use man instead. Long output is truncated (head+tail preserved, middle elided). Stderr lines prefixed with [stderr].",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to execute."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in seconds (1-600). Omit to use the global tools.timeout setting (typically 120s). Use higher values for long-running commands like test suites or builds."
|
||||
},
|
||||
"stop_on_error": {
|
||||
"type": "boolean",
|
||||
"description": "If true, enables 'set -e' so the script exits on the first command failure. Default false. Use for multi-step scripts where intermediate failures should halt execution."
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "diff_file",
|
||||
"description": "Show a unified diff between two files, or between a file and a provided string. Use after edit_file to verify changes, or to compare two files. Returns unified diff output with context lines.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path_a": {
|
||||
"type": "string",
|
||||
"description": "Path to the first file (or the file to diff against path_b or content_b)."
|
||||
},
|
||||
"path_b": {
|
||||
"type": "string",
|
||||
"description": "Path to the second file. Mutually exclusive with content_b."
|
||||
},
|
||||
"content_b": {
|
||||
"type": "string",
|
||||
"description": "String content to compare against path_a. Mutually exclusive with path_b. Useful for comparing current file state against a known previous version."
|
||||
},
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"description": "Number of context lines around changes (default 3)."
|
||||
}
|
||||
},
|
||||
"required": ["path_a"]
|
||||
},
|
||||
"agent": true,
|
||||
"task_agent": true,
|
||||
"auto_approve": true,
|
||||
"primary_key": "path_a"
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
"type": "integer",
|
||||
"description": "When old_string matches multiple locations, pick the one nearest this line number."
|
||||
},
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace ALL occurrences of old_string instead of requiring a unique match. Cannot be used with near_line or edits array."
|
||||
},
|
||||
"edits": {
|
||||
"type": "array",
|
||||
"description": "Multiple replacements to apply atomically. Each entry has old_string, new_string, and optional near_line. All edits are validated before any are applied. Preferred over multiple edit_file calls when making several changes to the same 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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plan_agent",
|
||||
"description": "Delegate planning to a sub-agent. The agent autonomously explores the codebase, gathers context, and writes a step-by-step plan — just pass the goal directly. Use when asked to plan, design, think through, or strategize. Not for direct code changes like 'add a docstring' or 'fix a bug' — use read_file+edit_file for those.",
|
||||
"description": "Delegate planning to a sub-agent. The agent autonomously explores the codebase, gathers context, and writes a step-by-step plan — just pass the goal directly. Use when asked to plan, design, think through, or strategize. Not for direct code changes like 'add a docstring' or 'fix a bug' — use read_file+edit_file for those. The plan agent has read-only tools (read_file, search, web_fetch, web_search, man) but cannot run bash, save memories, set watches, or delegate further.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results to return (default 20)."
|
||||
"description": "Max results to return (default 20, max 50)."
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Skip this many results for pagination. Default 0."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "task_agent",
|
||||
"description": "Delegate a general-purpose task to an autonomous sub-agent. The agent inherits all tools and can read, write, edit, search, and run commands. Use for work that requires file modifications or command execution. Provide a clear, self-contained prompt.",
|
||||
"description": "Delegate a general-purpose task to an autonomous sub-agent. The agent can read, write, edit, search, and run commands but does NOT have access to memory, recall, watch, skill, plan_agent, or task_agent — it cannot save memories, search conversation history, set up watches, or delegate further. All context must be in the prompt. Provide a clear, self-contained task description.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The full file content to write."
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["overwrite", "append"],
|
||||
"description": "Write mode. 'overwrite' (default) replaces the file. 'append' adds content to the end."
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
|
||||
+298
-39
@@ -29,9 +29,10 @@ function Pane(wsId) {
|
||||
this.retryDelay = 1000;
|
||||
this.model = "";
|
||||
this.modelAlias = "";
|
||||
this.statusText = "";
|
||||
this._lastStatusEvt = null;
|
||||
this._cancelTimeout = null;
|
||||
this._forceTimeout = null;
|
||||
this._pendingEditSend = null;
|
||||
this._createDOM();
|
||||
}
|
||||
|
||||
@@ -133,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";
|
||||
@@ -181,6 +213,7 @@ Pane.prototype.reset = function () {
|
||||
this.setBusy(false);
|
||||
this.pendingApproval = false;
|
||||
this.approvalBlockEl = null;
|
||||
this._pendingEditSend = null;
|
||||
this.inputEl.disabled = false;
|
||||
};
|
||||
|
||||
@@ -211,6 +244,7 @@ Pane.prototype.disconnectSSE = function () {
|
||||
|
||||
Pane.prototype.setBusy = function (b) {
|
||||
this.busy = b;
|
||||
this.messagesEl.dataset.busy = b ? "true" : "false";
|
||||
this.sendBtn.disabled = b;
|
||||
this.sendBtn.style.display = b ? "none" : "";
|
||||
this.stopBtn.style.display = b ? "" : "none";
|
||||
@@ -245,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) {
|
||||
@@ -262,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) {
|
||||
@@ -394,6 +422,7 @@ Pane.prototype.handleEvent = function (evt) {
|
||||
case "state_change":
|
||||
if (evt.state === "idle" || evt.state === "error") {
|
||||
this.setBusy(false);
|
||||
this._attachRetryToLastAssistant();
|
||||
// Only steal focus if this is the active pane and no approval pending.
|
||||
if (this.id === focusedPaneId && !this.pendingApproval) {
|
||||
this.inputEl.focus();
|
||||
@@ -510,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) {
|
||||
@@ -527,6 +555,21 @@ Pane.prototype.handleEvent = function (evt) {
|
||||
|
||||
case "history":
|
||||
this.replayHistory(evt.messages);
|
||||
// Dispatch pending edit-and-resend after rewind history arrives
|
||||
if (this._pendingEditSend) {
|
||||
var editText = this._pendingEditSend;
|
||||
this._pendingEditSend = null;
|
||||
this.setBusy(true);
|
||||
this.addUserMessage(editText);
|
||||
authFetch("/v1/api/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: editText, ws_id: self.wsId }),
|
||||
}).catch(function (err) {
|
||||
self.addErrorMessage("Connection error: " + err.message);
|
||||
self.setBusy(false);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "clear_ui":
|
||||
@@ -554,10 +597,208 @@ Pane.prototype.addUserMessage = function (text) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg msg-user";
|
||||
el.textContent = text;
|
||||
this._addUserMsgActions(el, text);
|
||||
this.messagesEl.appendChild(el);
|
||||
this.scrollToBottom(true);
|
||||
};
|
||||
|
||||
Pane.prototype._addUserMsgActions = function (el, text) {
|
||||
var self = this;
|
||||
var bar = document.createElement("div");
|
||||
bar.className = "msg-actions";
|
||||
bar.setAttribute("role", "toolbar");
|
||||
bar.setAttribute("aria-label", "Message actions");
|
||||
// Edit button
|
||||
var editBtn = document.createElement("button");
|
||||
editBtn.className = "msg-action-btn";
|
||||
editBtn.title = "Edit & resend";
|
||||
editBtn.setAttribute("aria-label", "Edit and resend this message");
|
||||
var editIcon = document.createElement("span");
|
||||
editIcon.className = "icon-edit";
|
||||
editIcon.setAttribute("aria-hidden", "true");
|
||||
editBtn.appendChild(editIcon);
|
||||
editBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._startEdit(el, text);
|
||||
});
|
||||
bar.appendChild(editBtn);
|
||||
// Rewind-to-here button
|
||||
var rewindBtn = document.createElement("button");
|
||||
rewindBtn.className = "msg-action-btn";
|
||||
rewindBtn.title = "Rewind to before this message";
|
||||
rewindBtn.setAttribute(
|
||||
"aria-label",
|
||||
"Rewind conversation to before this message",
|
||||
);
|
||||
var rewindIcon = document.createElement("span");
|
||||
rewindIcon.className = "icon-rewind";
|
||||
rewindIcon.setAttribute("aria-hidden", "true");
|
||||
rewindBtn.appendChild(rewindIcon);
|
||||
rewindBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._rewindToMessage(el);
|
||||
});
|
||||
bar.appendChild(rewindBtn);
|
||||
el.appendChild(bar);
|
||||
};
|
||||
|
||||
Pane.prototype._addRetryAction = function (el) {
|
||||
var self = this;
|
||||
var bar = el.querySelector(".msg-actions");
|
||||
if (!bar) {
|
||||
bar = document.createElement("div");
|
||||
bar.className = "msg-actions";
|
||||
bar.setAttribute("role", "toolbar");
|
||||
bar.setAttribute("aria-label", "Message actions");
|
||||
el.appendChild(bar);
|
||||
}
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "msg-action-btn";
|
||||
btn.title = "Retry (regenerate response)";
|
||||
btn.setAttribute("aria-label", "Retry last response");
|
||||
var icon = document.createElement("span");
|
||||
icon.className = "icon-retry";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
btn.appendChild(icon);
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._retryLast();
|
||||
});
|
||||
bar.insertBefore(btn, bar.firstChild);
|
||||
};
|
||||
|
||||
Pane.prototype._retryLast = function () {
|
||||
if (this.busy) return;
|
||||
var self = this;
|
||||
authFetch("/v1/api/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command: "/retry", ws_id: this.wsId }),
|
||||
}).catch(function (err) {
|
||||
self.addErrorMessage("Retry failed: " + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
Pane.prototype._rewindToMessage = function (msgEl) {
|
||||
if (this.busy) return;
|
||||
var self = this;
|
||||
// Count how many user messages come at or after this one
|
||||
var userMsgs = this.messagesEl.querySelectorAll(".msg-user");
|
||||
var idx = Array.prototype.indexOf.call(userMsgs, msgEl);
|
||||
if (idx < 0) return;
|
||||
var turnsToRewind = userMsgs.length - idx;
|
||||
if (turnsToRewind < 1) return;
|
||||
authFetch("/v1/api/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
command: "/rewind " + turnsToRewind,
|
||||
ws_id: this.wsId,
|
||||
}),
|
||||
}).catch(function (err) {
|
||||
self.addErrorMessage("Rewind failed: " + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
Pane.prototype._startEdit = function (msgEl, originalText) {
|
||||
if (this.busy) return;
|
||||
var self = this;
|
||||
// Save current child nodes for cancel restoration
|
||||
var savedNodes = [];
|
||||
while (msgEl.firstChild) {
|
||||
savedNodes.push(msgEl.removeChild(msgEl.firstChild));
|
||||
}
|
||||
msgEl.classList.add("msg-editing");
|
||||
|
||||
var form = document.createElement("div");
|
||||
form.className = "msg-edit-form";
|
||||
|
||||
var textarea = document.createElement("textarea");
|
||||
textarea.className = "msg-edit-textarea";
|
||||
textarea.setAttribute("aria-label", "Edit message text");
|
||||
textarea.value = originalText;
|
||||
textarea.rows = Math.min(originalText.split("\n").length + 1, 8);
|
||||
form.appendChild(textarea);
|
||||
|
||||
var actions = document.createElement("div");
|
||||
actions.className = "msg-edit-actions";
|
||||
|
||||
var cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "msg-edit-btn";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.addEventListener("click", function () {
|
||||
// Restore original nodes
|
||||
while (msgEl.firstChild) msgEl.removeChild(msgEl.firstChild);
|
||||
savedNodes.forEach(function (n) {
|
||||
msgEl.appendChild(n);
|
||||
});
|
||||
msgEl.classList.remove("msg-editing");
|
||||
});
|
||||
actions.appendChild(cancelBtn);
|
||||
|
||||
var sendBtn = document.createElement("button");
|
||||
sendBtn.className = "msg-edit-btn msg-edit-btn-send";
|
||||
sendBtn.textContent = "Send";
|
||||
sendBtn.addEventListener("click", function () {
|
||||
var newText = textarea.value.trim();
|
||||
if (!newText) return;
|
||||
self._editAndResend(msgEl, newText);
|
||||
});
|
||||
actions.appendChild(sendBtn);
|
||||
|
||||
// Ctrl+Enter to send, Escape to cancel
|
||||
textarea.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
sendBtn.click();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
form.appendChild(actions);
|
||||
msgEl.appendChild(form);
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
};
|
||||
|
||||
Pane.prototype._editAndResend = function (msgEl, newText) {
|
||||
if (this.busy) return;
|
||||
var self = this;
|
||||
// Count turns to rewind (from this message onward)
|
||||
var userMsgs = this.messagesEl.querySelectorAll(".msg-user");
|
||||
var idx = Array.prototype.indexOf.call(userMsgs, msgEl);
|
||||
if (idx < 0) return;
|
||||
var turnsToRewind = userMsgs.length - idx;
|
||||
|
||||
this.setBusy(true);
|
||||
// Store pending send — dispatched when the rewind history event arrives
|
||||
this._pendingEditSend = newText;
|
||||
authFetch("/v1/api/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
command: "/rewind " + turnsToRewind,
|
||||
ws_id: self.wsId,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r && !r.ok) {
|
||||
self._pendingEditSend = null;
|
||||
self.setBusy(false);
|
||||
self.addErrorMessage(
|
||||
"Rewind failed (HTTP " + r.status + " " + r.statusText + ")",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
self._pendingEditSend = null;
|
||||
self.addErrorMessage("Rewind failed: " + err.message);
|
||||
self.setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
Pane.prototype.replayHistory = function (messages) {
|
||||
var self = this;
|
||||
this.messagesEl.innerHTML = "";
|
||||
@@ -660,9 +901,25 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
}
|
||||
}
|
||||
}
|
||||
this._attachRetryToLastAssistant();
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
Pane.prototype._attachRetryToLastAssistant = function () {
|
||||
// Remove any previous retry buttons
|
||||
var old = this.messagesEl.querySelectorAll(".msg-assistant .msg-actions");
|
||||
for (var i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]);
|
||||
// Find the last assistant message with content and add retry
|
||||
var assistants = this.messagesEl.querySelectorAll(".msg-assistant");
|
||||
if (assistants.length) {
|
||||
var last = assistants[assistants.length - 1];
|
||||
// Only add if it's not a reasoning block
|
||||
if (!last.classList.contains("reasoning")) {
|
||||
this._addRetryAction(last);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Pane.prototype.showInlineToolBlock = function (
|
||||
items,
|
||||
autoApproved,
|
||||
@@ -1081,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 () {
|
||||
@@ -1204,7 +1474,6 @@ function setFocusedPane(paneId) {
|
||||
panes[paneId].el.classList.add("focused");
|
||||
currentWsId = panes[paneId].wsId;
|
||||
renderTabBar();
|
||||
updateHeaderForFocusedPane();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1214,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);
|
||||
@@ -2148,7 +2406,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 = "";
|
||||
|
||||
@@ -28,9 +28,7 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -625,6 +619,184 @@ body { position: static; }
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Message action toolbar (hover controls for retry / edit / rewind)
|
||||
========================================================================== */
|
||||
.msg-user, .msg-assistant { position: relative; }
|
||||
.msg-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
}
|
||||
.msg-user:hover .msg-actions,
|
||||
.msg-assistant:hover .msg-actions,
|
||||
.msg-user:focus-within .msg-actions,
|
||||
.msg-assistant:focus-within .msg-actions,
|
||||
.msg-actions:hover { opacity: 1; pointer-events: auto; }
|
||||
.msg-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--fg-dim);
|
||||
transition: color 0.1s ease, background 0.1s ease;
|
||||
padding: 0;
|
||||
}
|
||||
.msg-action-btn:hover { color: var(--accent); background: var(--bg-highlight); box-shadow: 0 0 6px var(--accent-glow); }
|
||||
.msg-action-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.msg-action-btn + .msg-action-btn { border-left: 1px solid var(--border); }
|
||||
|
||||
/* Icon: retry (circular arrow) */
|
||||
.icon-retry {
|
||||
width: 13px; height: 13px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 50%;
|
||||
border-bottom-color: transparent;
|
||||
position: relative;
|
||||
}
|
||||
.icon-retry::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -1px; right: -1px;
|
||||
width: 0; height: 0;
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
border-top: 3px solid currentColor;
|
||||
transform: rotate(-30deg);
|
||||
}
|
||||
|
||||
/* Icon: edit (pencil) */
|
||||
.icon-edit {
|
||||
width: 12px; height: 12px;
|
||||
position: relative;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.icon-edit::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 3px;
|
||||
width: 6px; height: 8px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 1px 1px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.icon-edit::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0; left: 3px;
|
||||
width: 0; height: 0;
|
||||
border-left: 3px solid transparent;
|
||||
border-right: 3px solid transparent;
|
||||
border-top: 3px solid currentColor;
|
||||
}
|
||||
|
||||
/* Icon: rewind (chevrons pointing left) */
|
||||
.icon-rewind {
|
||||
width: 14px; height: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.icon-rewind::before, .icon-rewind::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
width: 6px; height: 6px;
|
||||
border-left: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.icon-rewind::before { left: 1px; }
|
||||
.icon-rewind::after { left: 6px; }
|
||||
|
||||
/* Edit-in-place form */
|
||||
.msg-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.msg-edit-textarea {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
background: var(--bg);
|
||||
color: var(--fg-bright);
|
||||
border: 1px solid var(--accent-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.12s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.msg-edit-textarea:focus { border-color: var(--accent); }
|
||||
.msg-edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.msg-edit-btn {
|
||||
padding: 4px 14px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
transition: background 0.1s ease, border-color 0.1s ease, color 0.1s ease;
|
||||
}
|
||||
.msg-edit-btn:hover { background: var(--bg-highlight); }
|
||||
.msg-edit-btn-send {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.msg-edit-btn-send:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
/* Edit-in-place active state */
|
||||
.msg-editing { background: var(--bg-surface); border-color: var(--accent-dim); }
|
||||
.msg-editing .msg-actions { display: none; }
|
||||
|
||||
/* Busy-state disables action buttons */
|
||||
[data-busy="true"] .msg-action-btn { opacity: 0.3; pointer-events: none; cursor: not-allowed; }
|
||||
|
||||
/* Touch devices: always show action buttons inline */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.msg-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
position: static;
|
||||
margin-top: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.msg-action-btn { width: 36px; height: 36px; }
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.msg-actions, .msg-action-btn, .msg-edit-textarea, .msg-edit-btn { transition: none; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Input area
|
||||
========================================================================== */
|
||||
@@ -671,6 +843,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
|
||||
========================================================================== */
|
||||
|
||||
@@ -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]]
|
||||
@@ -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.2"
|
||||
version = "0.9.5"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
||||
Reference in New Issue
Block a user