mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
30 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 |
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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
|
||||
|
||||
+32
-10
@@ -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,10 @@ 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 (default 120s). Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
|
||||
- **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).
|
||||
@@ -222,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.
|
||||
|
||||
@@ -239,17 +242,36 @@ Replace exact strings in a file, or apply multiple replacements atomically.
|
||||
| `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). |
|
||||
| `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`. |
|
||||
|
||||
\* 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` is provided to pick the nearest match). Requires a prior `read_file` call on the same path.
|
||||
- **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.
|
||||
@@ -607,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.
|
||||
@@ -650,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.3"
|
||||
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"
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface StatusEvent {
|
||||
effort: string;
|
||||
cache_creation_tokens?: number;
|
||||
cache_read_tokens?: number;
|
||||
tool_calls_this_turn?: number;
|
||||
turn_count?: number;
|
||||
}
|
||||
|
||||
export interface PlanReviewEvent {
|
||||
|
||||
+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:
|
||||
|
||||
@@ -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
|
||||
@@ -1407,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")
|
||||
|
||||
@@ -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.3"
|
||||
__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}",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -267,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 []
|
||||
@@ -331,6 +331,18 @@ def save_structured_memory(
|
||||
return "", None
|
||||
|
||||
|
||||
def get_structured_memory_by_name(
|
||||
name: str, scope: str = "global", scope_id: str = ""
|
||||
) -> dict[str, str] | None:
|
||||
"""Retrieve a single structured memory by name+scope. Returns full content."""
|
||||
name = normalize_key(name)
|
||||
try:
|
||||
return get_storage().get_structured_memory_by_name(name, scope, scope_id)
|
||||
except Exception:
|
||||
log.warning("Failed to get structured memory name=%s", name, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def delete_structured_memory(name: str, scope: str = "global", scope_id: str = "") -> bool:
|
||||
"""Delete a structured memory by name+scope. Returns True if existed."""
|
||||
name = normalize_key(name)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -423,17 +405,42 @@ class AnthropicProvider:
|
||||
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)
|
||||
result_block: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_msg.get("tool_call_id", ""),
|
||||
"tool_use_id": tc_id,
|
||||
"content": content,
|
||||
}
|
||||
if tool_msg.get("is_error"):
|
||||
@@ -444,7 +451,8 @@ class AnthropicProvider:
|
||||
if pending_orphan_results:
|
||||
tool_results.extend(pending_orphan_results)
|
||||
pending_orphan_results = []
|
||||
converted.append({"role": "user", "content": tool_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()
|
||||
|
||||
+511
-101
@@ -12,6 +12,7 @@ import base64
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
@@ -39,6 +40,7 @@ from turnstone.core.memory import (
|
||||
delete_structured_memory,
|
||||
delete_workstream,
|
||||
get_skill_by_name,
|
||||
get_structured_memory_by_name,
|
||||
get_workstream_display_name,
|
||||
list_default_skills,
|
||||
list_skills_by_activation,
|
||||
@@ -1460,7 +1462,27 @@ class ChatSession:
|
||||
# text would corrupt the payload.
|
||||
_tc_by_id = {c["id"]: c for c in tool_calls}
|
||||
_repeat_detected = False
|
||||
_error_prefixes = ("Error", "JSON parse error", "Unknown tool", "Command timed out")
|
||||
_error_prefixes = (
|
||||
"Error",
|
||||
"JSON parse error",
|
||||
"Unknown tool",
|
||||
"Command timed out",
|
||||
"Blocked:",
|
||||
"Denied",
|
||||
)
|
||||
|
||||
# Clear dedup sigs when a write tool executed successfully —
|
||||
# the state has changed so re-running a read tool is valid.
|
||||
_write_tools = frozenset({"write_file", "edit_file", "bash"})
|
||||
if any(
|
||||
tc["function"]["name"] in _write_tools
|
||||
and not any(
|
||||
cid == tc["id"] and isinstance(out, str) and out.startswith(_error_prefixes)
|
||||
for cid, out in results
|
||||
)
|
||||
for tc in tool_calls
|
||||
):
|
||||
self._recent_tool_sigs.clear()
|
||||
for i, (tc_id, output) in enumerate(results):
|
||||
tc = _tc_by_id.get(tc_id)
|
||||
if tc and isinstance(output, str) and not output.startswith(_error_prefixes):
|
||||
@@ -1604,19 +1626,11 @@ class ChatSession:
|
||||
if content:
|
||||
save_message(self._ws_id, "assistant", content)
|
||||
else:
|
||||
# Cancelled during tool execution — roll back incomplete results
|
||||
while self.messages and self.messages[-1]["role"] == "tool":
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
while (
|
||||
self.messages
|
||||
and self.messages[-1]["role"] == "assistant"
|
||||
and self.messages[-1].get("tool_calls")
|
||||
):
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
# Cancelled during tool execution — synthesize cancelled
|
||||
# tool_result for any tool_calls that lack a matching result.
|
||||
# This keeps the conversation valid for both providers while
|
||||
# preserving the full tool call structure in history.
|
||||
self._synthesize_cancelled_results("Cancelled by user.")
|
||||
# No need to clear _cancel_event — it's replaced per-generation
|
||||
# in send(), so this generation's event is simply discarded.
|
||||
self.ui.on_info("[Generation cancelled]")
|
||||
@@ -1624,26 +1638,54 @@ class ChatSession:
|
||||
# Do NOT re-raise — return normally so server worker thread
|
||||
# completes cleanly.
|
||||
except KeyboardInterrupt:
|
||||
# Remove any partial tool results, then the originating assistant
|
||||
# message with unanswered tool_calls — keep _msg_tokens in sync
|
||||
while self.messages and self.messages[-1]["role"] == "tool":
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
while (
|
||||
self.messages
|
||||
and self.messages[-1]["role"] == "assistant"
|
||||
and self.messages[-1].get("tool_calls")
|
||||
):
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
self._synthesize_cancelled_results("Interrupted by user.")
|
||||
self._emit_state("error")
|
||||
raise
|
||||
except Exception:
|
||||
self._emit_state("error")
|
||||
raise
|
||||
|
||||
def _synthesize_cancelled_results(self, reason: str) -> None:
|
||||
"""Synthesize tool_result messages for orphaned tool_calls after cancel.
|
||||
|
||||
Finds the last assistant message with tool_calls, collects the IDs of
|
||||
tool_calls that already have matching tool results, and synthesizes
|
||||
cancelled results for any that don't. This keeps the conversation
|
||||
valid (both providers require matching tool_results) while preserving
|
||||
the full tool call structure so the model knows what was attempted.
|
||||
"""
|
||||
# Find the last assistant message with tool_calls
|
||||
assistant_idx = None
|
||||
for i in range(len(self.messages) - 1, -1, -1):
|
||||
msg = self.messages[i]
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
assistant_idx = i
|
||||
break
|
||||
if assistant_idx is None:
|
||||
return
|
||||
|
||||
# Collect tool_call IDs that already have results
|
||||
answered_ids: set[str] = set()
|
||||
for msg in self.messages[assistant_idx + 1 :]:
|
||||
if msg.get("role") == "tool":
|
||||
answered_ids.add(msg.get("tool_call_id", ""))
|
||||
|
||||
# Synthesize results for unanswered tool_calls
|
||||
for tc in self.messages[assistant_idx].get("tool_calls", []):
|
||||
tc_id = tc.get("id", "")
|
||||
func_name = tc.get("function", {}).get("name", "")
|
||||
if tc_id and tc_id not in answered_ids:
|
||||
self.messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"content": reason,
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
self._msg_tokens.append(1)
|
||||
save_message(self._ws_id, "tool", reason, func_name, tool_call_id=tc_id)
|
||||
|
||||
# -- Rewind / retry -------------------------------------------------------
|
||||
|
||||
def _find_turn_boundaries(self) -> list[int]:
|
||||
@@ -2714,6 +2756,7 @@ class ChatSession:
|
||||
"bash": self._prepare_bash,
|
||||
"read_file": self._prepare_read_file,
|
||||
"search": self._prepare_search,
|
||||
"diff_file": self._prepare_diff,
|
||||
"write_file": self._prepare_write_file,
|
||||
"edit_file": self._prepare_edit_file,
|
||||
"math": self._prepare_math,
|
||||
@@ -2779,17 +2822,38 @@ class ChatSession:
|
||||
"error": blocked,
|
||||
}
|
||||
display_cmd = command.split("\n")[0]
|
||||
if "\n" in command:
|
||||
display_cmd += f" ... ({command.count(chr(10))} more lines)"
|
||||
is_multiline = "\n" in command
|
||||
if is_multiline:
|
||||
extra = command.count(chr(10))
|
||||
display_cmd += f" ... ({extra} more {'line' if extra == 1 else 'lines'})"
|
||||
timeout = args.get("timeout")
|
||||
try:
|
||||
timeout = int(timeout) if timeout is not None else None
|
||||
except (ValueError, TypeError):
|
||||
timeout = None
|
||||
if timeout is not None:
|
||||
timeout = max(1, min(timeout, 600)) # clamp 1-600s
|
||||
|
||||
# Show full command in preview for multi-line scripts
|
||||
preview = ""
|
||||
if is_multiline:
|
||||
preview = f"{DIM}{textwrap.indent(command, ' ')}{RESET}"
|
||||
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "bash",
|
||||
"header": f"\u2699 bash: {display_cmd}",
|
||||
"preview": "",
|
||||
"header": (
|
||||
f"\u2699 bash ({timeout}s): {display_cmd}"
|
||||
if timeout is not None
|
||||
else f"\u2699 bash: {display_cmd}"
|
||||
),
|
||||
"preview": preview,
|
||||
"needs_approval": True,
|
||||
"approval_label": "bash",
|
||||
"execute": self._exec_bash,
|
||||
"command": command,
|
||||
"timeout": timeout,
|
||||
"stop_on_error": args.get("stop_on_error") is True,
|
||||
}
|
||||
|
||||
def _prepare_read_file(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -2890,6 +2954,62 @@ class ChatSession:
|
||||
"path": path,
|
||||
}
|
||||
|
||||
def _prepare_diff(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
path_a = args.get("path_a", "")
|
||||
path_b = args.get("path_b", "")
|
||||
content_b = args.get("content_b")
|
||||
if not path_a:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "diff_file",
|
||||
"header": "\u2717 diff_file: missing path_a",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: path_a is required",
|
||||
}
|
||||
if path_b and content_b is not None:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "diff_file",
|
||||
"header": "\u2717 diff_file: ambiguous params",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: provide path_b or content_b, not both",
|
||||
}
|
||||
if not path_b and content_b is None:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "diff_file",
|
||||
"header": "\u2717 diff_file: missing comparison target",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: provide path_b (another file) or content_b (string to compare against)",
|
||||
}
|
||||
ctx = args.get("context_lines")
|
||||
try:
|
||||
ctx = int(ctx) if ctx is not None else 3
|
||||
except (ValueError, TypeError):
|
||||
ctx = 3
|
||||
ctx = max(0, min(ctx, 20))
|
||||
path_a = os.path.expanduser(path_a)
|
||||
path_b = os.path.expanduser(path_b) if path_b else ""
|
||||
if path_b:
|
||||
header = f"\u2699 diff_file: {path_a} vs {path_b}"
|
||||
else:
|
||||
header = f"\u2699 diff_file: {path_a} vs provided content"
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "diff_file",
|
||||
"header": header,
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"execute": self._exec_diff,
|
||||
"path_a": path_a,
|
||||
"path_b": path_b,
|
||||
"content_b": content_b,
|
||||
"context_lines": ctx,
|
||||
}
|
||||
|
||||
def _prepare_write_file(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
path = args.get("path", "")
|
||||
content = args.get("content", "")
|
||||
@@ -2904,28 +3024,46 @@ class ChatSession:
|
||||
}
|
||||
path = os.path.expanduser(path)
|
||||
resolved = os.path.realpath(path)
|
||||
is_symlink = os.path.abspath(path) != resolved
|
||||
exists = os.path.exists(resolved)
|
||||
is_overwrite = exists and resolved not in self._read_files
|
||||
raw_mode = args.get("mode")
|
||||
mode = str(raw_mode).strip().lower() if raw_mode else "overwrite"
|
||||
if mode not in ("overwrite", "append"):
|
||||
mode = "overwrite"
|
||||
is_append = mode == "append"
|
||||
is_overwrite = exists and resolved not in self._read_files and not is_append
|
||||
|
||||
# Build preview
|
||||
preview_parts = []
|
||||
if is_symlink:
|
||||
preview_parts.append(f" {YELLOW}Warning: symlink — actual target: {resolved}{RESET}")
|
||||
if is_overwrite:
|
||||
preview_parts.append(
|
||||
f" {YELLOW}Warning: overwriting existing file not previously read{RESET}"
|
||||
)
|
||||
if is_append:
|
||||
preview_parts.append(f" {YELLOW}(append mode){RESET}")
|
||||
preview_parts.append(f"{DIM}{textwrap.indent(content, ' ')}{RESET}")
|
||||
|
||||
verb = "append" if is_append else "write"
|
||||
header = f"\u2699 write_file ({verb}): {path} ({len(content)} chars)"
|
||||
if is_symlink:
|
||||
header = f"\u2699 write_file ({verb}): {path} \u2192 {resolved} ({len(content)} chars)"
|
||||
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "write_file",
|
||||
"header": f"\u2699 write_file: {path} ({len(content)} chars)",
|
||||
"header": header,
|
||||
"preview": "\n".join(preview_parts),
|
||||
"needs_approval": True,
|
||||
"approval_label": "overwrite_file" if is_overwrite else "write_file",
|
||||
"approval_label": "append_file"
|
||||
if is_append
|
||||
else ("overwrite_file" if is_overwrite else "write_file"),
|
||||
"execute": self._exec_write_file,
|
||||
"path": path,
|
||||
"resolved": resolved,
|
||||
"content": content,
|
||||
"append": is_append,
|
||||
}
|
||||
|
||||
def _validate_edit_entry(self, e: dict[str, Any], idx: int | None) -> dict[str, Any] | None:
|
||||
@@ -3018,8 +3156,29 @@ class ChatSession:
|
||||
return err
|
||||
edits = [self._normalize_edit_entry(args)]
|
||||
|
||||
replace_all = bool(args.get("replace_all"))
|
||||
if replace_all and has_batch:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "edit_file",
|
||||
"header": "\u2717 edit_file: invalid params",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: replace_all cannot be used with edits array",
|
||||
}
|
||||
if replace_all and edits[0].get("near_line") is not None:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "edit_file",
|
||||
"header": "\u2717 edit_file: invalid params",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: replace_all cannot be used with near_line",
|
||||
}
|
||||
|
||||
path = os.path.expanduser(path)
|
||||
resolved = os.path.realpath(path)
|
||||
is_symlink = os.path.abspath(path) != resolved
|
||||
|
||||
if resolved not in self._read_files:
|
||||
return {
|
||||
@@ -3033,7 +3192,7 @@ class ChatSession:
|
||||
|
||||
# Pre-read to validate all edits and build diff preview
|
||||
try:
|
||||
with open(path) as f:
|
||||
with open(resolved) as f:
|
||||
content = f.read()
|
||||
|
||||
for i, edit in enumerate(edits):
|
||||
@@ -3053,7 +3212,7 @@ class ChatSession:
|
||||
"The file may have changed — re-read it before retrying."
|
||||
),
|
||||
}
|
||||
if len(occurrences) > 1 and nl is None:
|
||||
if len(occurrences) > 1 and nl is None and not replace_all:
|
||||
line_list = ", ".join(str(ln) for ln in occurrences)
|
||||
return {
|
||||
"call_id": call_id,
|
||||
@@ -3063,7 +3222,7 @@ class ChatSession:
|
||||
"needs_approval": False,
|
||||
"error": (
|
||||
f"Error: {label}old_string found {len(occurrences)} times "
|
||||
f"at lines {line_list} — use near_line to pick one"
|
||||
f"at lines {line_list} — use near_line or replace_all"
|
||||
),
|
||||
}
|
||||
except FileNotFoundError:
|
||||
@@ -3087,6 +3246,11 @@ class ChatSession:
|
||||
|
||||
# Build diff preview
|
||||
preview_parts = []
|
||||
if is_symlink:
|
||||
preview_parts.append(f" {YELLOW}Warning: symlink — actual target: {resolved}{RESET}")
|
||||
if replace_all:
|
||||
occ = content.count(edits[0]["old_string"])
|
||||
preview_parts.append(f" {YELLOW}(replace_all: {occ} occurrences){RESET}")
|
||||
for i, edit in enumerate(edits):
|
||||
if len(edits) > 1:
|
||||
preview_parts.append(f" {YELLOW}--- edit {i + 1}/{len(edits)} ---{RESET}")
|
||||
@@ -3100,11 +3264,18 @@ class ChatSession:
|
||||
preview_parts.append(f" {YELLOW}(deletion — {n} chars removed){RESET}")
|
||||
|
||||
count = len(edits)
|
||||
header = (
|
||||
f"\u2699 edit_file: {path} ({count} edits)"
|
||||
if count > 1
|
||||
else f"\u2699 edit_file: {path}"
|
||||
)
|
||||
if is_symlink:
|
||||
header = (
|
||||
f"\u2699 edit_file: {path} \u2192 {resolved} ({count} edits)"
|
||||
if count > 1
|
||||
else f"\u2699 edit_file: {path} \u2192 {resolved}"
|
||||
)
|
||||
else:
|
||||
header = (
|
||||
f"\u2699 edit_file: {path} ({count} edits)"
|
||||
if count > 1
|
||||
else f"\u2699 edit_file: {path}"
|
||||
)
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "edit_file",
|
||||
@@ -3116,6 +3287,7 @@ class ChatSession:
|
||||
"path": path,
|
||||
"resolved": resolved,
|
||||
"edits": edits,
|
||||
"replace_all": replace_all,
|
||||
}
|
||||
|
||||
def _prepare_math(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -3455,21 +3627,30 @@ class ChatSession:
|
||||
return None
|
||||
|
||||
def _prepare_memory(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare a memory tool action (save/search/delete/list)."""
|
||||
"""Prepare a memory tool action (save/get/search/delete/list)."""
|
||||
action = (args.get("action") or "").strip().lower()
|
||||
|
||||
if action == "save":
|
||||
name = (args.get("name") or args.get("key") or "").strip()
|
||||
content = (args.get("content") or args.get("value") or "").strip()
|
||||
name = normalize_key(name)
|
||||
if not name or not content:
|
||||
if not name:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": "\u2717 memory save: requires name and content",
|
||||
"header": "\u2717 memory save: missing name",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: both 'name' and 'content' are required for save",
|
||||
"error": "Error: 'name' is required for save",
|
||||
}
|
||||
if not content:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": "\u2717 memory save: missing content",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: 'content' must be non-empty for save",
|
||||
}
|
||||
if len(content) > self._mem_cfg.max_content:
|
||||
return {
|
||||
@@ -3507,6 +3688,51 @@ class ChatSession:
|
||||
"scope_id": scope_id,
|
||||
}
|
||||
|
||||
if action == "get":
|
||||
name = normalize_key((args.get("name") or args.get("key") or "").strip())
|
||||
if not name:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": "\u2717 memory get: missing name",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: 'name' is required for get",
|
||||
}
|
||||
explicit_scope = (args.get("scope") or "").strip().lower()
|
||||
valid_scopes = ("global", "workstream", "user")
|
||||
if explicit_scope and explicit_scope not in valid_scopes:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": "\u2717 memory get: invalid scope",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": f"Error: invalid scope '{explicit_scope}'. Valid: {', '.join(valid_scopes)}",
|
||||
}
|
||||
if explicit_scope:
|
||||
scope_err = self._validate_scope(explicit_scope, call_id)
|
||||
if scope_err:
|
||||
return scope_err
|
||||
scopes_to_try = [(explicit_scope, self._resolve_scope_id(explicit_scope))]
|
||||
else:
|
||||
scopes_to_try = []
|
||||
for s in ("workstream", "user", "global"):
|
||||
sid = self._resolve_scope_id(s)
|
||||
if sid or s == "global":
|
||||
scopes_to_try.append((s, sid))
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": f"\u2699 memory get: {name}",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"execute": self._exec_memory,
|
||||
"action": "get",
|
||||
"name": name,
|
||||
"scopes_to_try": scopes_to_try,
|
||||
}
|
||||
|
||||
if action == "delete":
|
||||
name = normalize_key((args.get("name") or args.get("key") or "").strip())
|
||||
if not name:
|
||||
@@ -3518,13 +3744,33 @@ class ChatSession:
|
||||
"needs_approval": False,
|
||||
"error": "Error: name is required for delete",
|
||||
}
|
||||
scope = (args.get("scope") or "global").strip().lower()
|
||||
if scope not in ("global", "workstream", "user"):
|
||||
scope = "global"
|
||||
scope_err = self._validate_scope(scope, call_id)
|
||||
if scope_err:
|
||||
return scope_err
|
||||
scope_id = self._resolve_scope_id(scope)
|
||||
explicit_scope = (args.get("scope") or "").strip().lower()
|
||||
valid_scopes = ("global", "workstream", "user")
|
||||
if explicit_scope and explicit_scope not in valid_scopes:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": "\u2717 memory delete: invalid scope",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": (
|
||||
f"Error: invalid scope '{explicit_scope}'. "
|
||||
f"Valid scopes: {', '.join(valid_scopes)}"
|
||||
),
|
||||
}
|
||||
if explicit_scope:
|
||||
scope_err = self._validate_scope(explicit_scope, call_id)
|
||||
if scope_err:
|
||||
return scope_err
|
||||
scope_id = self._resolve_scope_id(explicit_scope)
|
||||
scopes_to_try = [(explicit_scope, scope_id)]
|
||||
else:
|
||||
# No scope specified — try narrowest first: workstream → user → global
|
||||
scopes_to_try = []
|
||||
for s in ("workstream", "user", "global"):
|
||||
sid = self._resolve_scope_id(s)
|
||||
if sid or s == "global":
|
||||
scopes_to_try.append((s, sid))
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
@@ -3534,8 +3780,7 @@ class ChatSession:
|
||||
"execute": self._exec_memory,
|
||||
"action": "delete",
|
||||
"name": name,
|
||||
"scope": scope,
|
||||
"scope_id": scope_id,
|
||||
"scopes_to_try": scopes_to_try,
|
||||
}
|
||||
|
||||
if action == "search":
|
||||
@@ -3602,7 +3847,7 @@ class ChatSession:
|
||||
"header": "\u2717 memory: invalid action",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": f"Error: action must be save/search/delete/list, got '{action}'",
|
||||
"error": f"Error: action must be save/get/search/delete/list, got '{action}'",
|
||||
}
|
||||
|
||||
def _prepare_recall(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -3617,12 +3862,14 @@ class ChatSession:
|
||||
"needs_approval": False,
|
||||
"error": "Error: query is required",
|
||||
}
|
||||
limit = args.get("limit", 20)
|
||||
if isinstance(limit, str):
|
||||
try:
|
||||
limit = int(limit)
|
||||
except ValueError:
|
||||
limit = 20
|
||||
try:
|
||||
limit = int(args.get("limit", 20))
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
try:
|
||||
offset = int(args.get("offset", 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "recall",
|
||||
@@ -3632,6 +3879,7 @@ class ChatSession:
|
||||
"execute": self._exec_recall,
|
||||
"query": query,
|
||||
"limit": max(1, min(limit, 50)),
|
||||
"offset": max(0, offset),
|
||||
}
|
||||
|
||||
# -- skill prepare/execute -------------------------------------------------
|
||||
@@ -4017,9 +4265,13 @@ class ChatSession:
|
||||
# _cancel_event with a fresh instance) doesn't disarm this check.
|
||||
cancel = self._cancel_event
|
||||
call_id, command = item["call_id"], item["command"]
|
||||
timeout = item.get("timeout") or self.tool_timeout
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
|
||||
f.write("set -o pipefail\n" + command)
|
||||
preamble = "set -o pipefail\n"
|
||||
if item.get("stop_on_error"):
|
||||
preamble += "set -e\n"
|
||||
f.write(preamble + command)
|
||||
script_path = f.name
|
||||
try:
|
||||
from turnstone.core.env import scrubbed_env
|
||||
@@ -4060,7 +4312,7 @@ class ChatSession:
|
||||
with contextlib.suppress(OSError, ProcessLookupError):
|
||||
proc.kill()
|
||||
|
||||
timer = threading.Timer(self.tool_timeout, _on_timeout)
|
||||
timer = threading.Timer(timeout, _on_timeout)
|
||||
timer.start()
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
@@ -4093,7 +4345,14 @@ class ChatSession:
|
||||
os.unlink(script_path)
|
||||
|
||||
if timed_out.is_set():
|
||||
raise subprocess.TimeoutExpired(cmd="bash", timeout=self.tool_timeout)
|
||||
raise subprocess.TimeoutExpired(cmd="bash", timeout=timeout)
|
||||
|
||||
# Distinguish user cancel from unexpected SIGKILL.
|
||||
# Popen.returncode is negative of the signal number when killed.
|
||||
if cancel.is_set() and proc.returncode == -signal.SIGKILL:
|
||||
msg = "Cancelled by user."
|
||||
self._report_tool_result(call_id, "bash", msg)
|
||||
return call_id, msg
|
||||
|
||||
output = "".join(stdout_parts)
|
||||
if stderr_lines:
|
||||
@@ -4102,7 +4361,13 @@ class ChatSession:
|
||||
output = output.strip()
|
||||
output = self._truncate_output(output)
|
||||
|
||||
bash_error = proc.returncode not in (0, 1)
|
||||
# With stop_on_error, any non-zero exit is a real failure (set -e
|
||||
# killed the script). Without it, exit code 1 is often benign
|
||||
# (e.g. grep no-match).
|
||||
if item.get("stop_on_error"):
|
||||
bash_error = proc.returncode != 0
|
||||
else:
|
||||
bash_error = proc.returncode not in (0, 1)
|
||||
if proc.returncode != 0:
|
||||
output += f"\n[exit code: {proc.returncode}]"
|
||||
|
||||
@@ -4111,7 +4376,7 @@ class ChatSession:
|
||||
return call_id, output if output else "(no output)"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
msg = f"Command timed out after {self.tool_timeout}s"
|
||||
msg = f"Command timed out after {timeout}s"
|
||||
self._report_tool_result(call_id, "bash", msg, is_error=True)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
@@ -4119,6 +4384,33 @@ class ChatSession:
|
||||
self._report_tool_result(call_id, "bash", msg, is_error=True)
|
||||
return call_id, msg
|
||||
|
||||
@staticmethod
|
||||
def _read_text_lines(path: str) -> tuple[list[str], str, str | None]:
|
||||
"""Read a text file with binary detection and symlink resolution.
|
||||
|
||||
Returns (lines, resolved_path, error_msg). On success error_msg is
|
||||
None. On failure lines is empty and error_msg describes the problem.
|
||||
"""
|
||||
resolved = os.path.realpath(os.path.expanduser(path))
|
||||
try:
|
||||
with open(resolved, "rb") as fb:
|
||||
sample = fb.read(8192)
|
||||
if b"\x00" in sample:
|
||||
return (
|
||||
[],
|
||||
resolved,
|
||||
(
|
||||
f"Error: {path} appears to be a binary file "
|
||||
"(contains null bytes). Use bash to inspect binary files."
|
||||
),
|
||||
)
|
||||
with open(resolved) as f:
|
||||
return f.readlines(), resolved, None
|
||||
except FileNotFoundError:
|
||||
return [], resolved, f"Error: {path} not found"
|
||||
except Exception as e:
|
||||
return [], resolved, f"Error reading {path}: {e}"
|
||||
|
||||
def _exec_read_file(self, item: dict[str, Any]) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
"""Read a file and return numbered lines, or image content parts."""
|
||||
call_id, path = item["call_id"], item["path"]
|
||||
@@ -4131,19 +4423,11 @@ class ChatSession:
|
||||
if ext in _IMAGE_EXTENSIONS:
|
||||
return self._exec_read_image(call_id, path, resolved)
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
all_lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
all_lines, _, err = self._read_text_lines(path)
|
||||
if err:
|
||||
self._read_files.discard(resolved)
|
||||
msg = f"Error: {path} not found"
|
||||
self._report_tool_result(call_id, "read_file", msg, is_error=True)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
self._read_files.discard(resolved)
|
||||
msg = f"Error reading {path}: {e}"
|
||||
self._report_tool_result(call_id, "read_file", msg, is_error=True)
|
||||
return call_id, msg
|
||||
self._report_tool_result(call_id, "read_file", err, is_error=True)
|
||||
return call_id, err
|
||||
|
||||
self._read_files.add(resolved)
|
||||
total_lines = len(all_lines)
|
||||
@@ -4176,7 +4460,7 @@ class ChatSession:
|
||||
caps = self._get_capabilities()
|
||||
if not caps.supports_vision:
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
size = os.path.getsize(resolved)
|
||||
except OSError as e:
|
||||
self._read_files.discard(resolved)
|
||||
msg = f"Error: {path}: {e}"
|
||||
@@ -4191,7 +4475,7 @@ class ChatSession:
|
||||
)
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
with open(resolved, "rb") as f:
|
||||
raw = f.read()
|
||||
except FileNotFoundError:
|
||||
self._read_files.discard(resolved)
|
||||
@@ -4248,6 +4532,21 @@ class ChatSession:
|
||||
"-m",
|
||||
"200", # max matches per file
|
||||
"--color=never", # no ANSI codes in output
|
||||
# Skip common build/vendor/VCS directories
|
||||
"--exclude-dir=.git",
|
||||
"--exclude-dir=node_modules",
|
||||
"--exclude-dir=target",
|
||||
"--exclude-dir=__pycache__",
|
||||
"--exclude-dir=.mypy_cache",
|
||||
"--exclude-dir=.ruff_cache",
|
||||
"--exclude-dir=.pytest_cache",
|
||||
"--exclude-dir=dist",
|
||||
"--exclude-dir=build",
|
||||
"--exclude-dir=*.egg-info",
|
||||
"--exclude-dir=.tox",
|
||||
"--exclude-dir=.venv",
|
||||
"--exclude-dir=venv",
|
||||
"--exclude-dir=vendor",
|
||||
"--",
|
||||
pattern,
|
||||
path, # -- prevents pattern as flag
|
||||
@@ -4263,10 +4562,18 @@ class ChatSession:
|
||||
elif result.returncode > 1:
|
||||
output = result.stderr.strip() or f"grep error (exit {result.returncode})"
|
||||
|
||||
# Count matches BEFORE truncation
|
||||
# Count matches and files BEFORE truncation
|
||||
match_count = output.count("\n") + 1 if result.returncode == 0 and output else 0
|
||||
if match_count:
|
||||
files = {line.split(":", 1)[0] for line in output.splitlines() if ":" in line}
|
||||
file_count = len(files)
|
||||
else:
|
||||
file_count = 0
|
||||
|
||||
# Append summary footer before truncation so it counts toward the limit
|
||||
original_len = len(output)
|
||||
if match_count:
|
||||
output += f"\n\n({match_count} matches across {file_count} files)"
|
||||
output = self._truncate_output(output)
|
||||
|
||||
desc = f"{match_count} matches" if match_count else "no matches"
|
||||
@@ -4285,6 +4592,47 @@ class ChatSession:
|
||||
self._report_tool_result(call_id, "search", msg, is_error=True)
|
||||
return call_id, msg
|
||||
|
||||
def _exec_diff(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Show unified diff between two files or a file and provided content."""
|
||||
call_id = item["call_id"]
|
||||
path_a = item["path_a"]
|
||||
path_b = item.get("path_b", "")
|
||||
content_b = item.get("content_b")
|
||||
ctx = item.get("context_lines", 3)
|
||||
|
||||
lines_a, resolved_a, err = self._read_text_lines(path_a)
|
||||
if err:
|
||||
self._report_tool_result(call_id, "diff_file", err, is_error=True)
|
||||
return call_id, err
|
||||
self._read_files.add(resolved_a)
|
||||
|
||||
if path_b:
|
||||
label_b = path_b
|
||||
lines_b, resolved_b, err = self._read_text_lines(path_b)
|
||||
if err:
|
||||
self._report_tool_result(call_id, "diff_file", err, is_error=True)
|
||||
return call_id, err
|
||||
self._read_files.add(resolved_b)
|
||||
else:
|
||||
label_b = "(provided content)"
|
||||
lines_b = (content_b or "").splitlines(keepends=True)
|
||||
|
||||
# Stream diff with early cutoff to avoid large allocations
|
||||
max_chars = self.tool_truncation or 262_144
|
||||
chunks: list[str] = []
|
||||
total_chars = 0
|
||||
line_count = 0
|
||||
for line in difflib.unified_diff(lines_a, lines_b, fromfile=path_a, tofile=label_b, n=ctx):
|
||||
line_count += 1
|
||||
if total_chars < max_chars:
|
||||
chunks.append(line)
|
||||
total_chars += len(line)
|
||||
output = "".join(chunks) if chunks else "(no differences)"
|
||||
output = self._truncate_output(output)
|
||||
desc = f"{line_count} diff lines" if line_count else "identical"
|
||||
self._report_tool_result(call_id, "diff_file", desc)
|
||||
return call_id, output
|
||||
|
||||
def _run_agent(
|
||||
self,
|
||||
agent_messages: list[dict[str, Any]],
|
||||
@@ -4773,14 +5121,45 @@ class ChatSession:
|
||||
self._report_tool_result(call_id, "memory", msg)
|
||||
return call_id, msg
|
||||
|
||||
if action == "get":
|
||||
scopes = item["scopes_to_try"]
|
||||
mem = None
|
||||
found_scope = ""
|
||||
for scope, scope_id in scopes:
|
||||
mem = get_structured_memory_by_name(item["name"], scope, scope_id)
|
||||
if mem:
|
||||
found_scope = scope
|
||||
break
|
||||
if mem:
|
||||
content = mem.get("content", "")
|
||||
desc = mem.get("description", "")
|
||||
mem_type = mem.get("type", "")
|
||||
header = f"[{mem_type}:{found_scope}] {item['name']}"
|
||||
if desc:
|
||||
header += f" — {desc}"
|
||||
msg = f"{header}\n\n{content}"
|
||||
else:
|
||||
tried = ", ".join(s for s, _ in scopes)
|
||||
msg = f"Error: memory '{item['name']}' not found (searched scopes: {tried})"
|
||||
self._report_tool_result(call_id, "memory", msg, is_error=mem is None)
|
||||
return call_id, msg
|
||||
|
||||
if action == "delete":
|
||||
deleted = delete_structured_memory(item["name"], item["scope"], item["scope_id"])
|
||||
scopes = item["scopes_to_try"]
|
||||
deleted = False
|
||||
deleted_scope = ""
|
||||
for scope, scope_id in scopes:
|
||||
if delete_structured_memory(item["name"], scope, scope_id):
|
||||
deleted = True
|
||||
deleted_scope = scope
|
||||
break
|
||||
if not deleted:
|
||||
msg = f"Error: memory '{item['name']}' not found (scope={item['scope']})"
|
||||
tried = ", ".join(s for s, _ in scopes)
|
||||
msg = f"Error: memory '{item['name']}' not found (searched scopes: {tried})"
|
||||
self._report_tool_result(call_id, "memory", msg, is_error=True)
|
||||
else:
|
||||
self._init_system_messages()
|
||||
msg = f"Deleted memory '{item['name']}'"
|
||||
msg = f"Deleted memory '{item['name']}' (scope={deleted_scope})"
|
||||
self._report_tool_result(call_id, "memory", msg)
|
||||
return call_id, msg
|
||||
|
||||
@@ -4796,11 +5175,14 @@ class ChatSession:
|
||||
lines = []
|
||||
for m in rows:
|
||||
desc = f" — {m['description']}" if m.get("description") else ""
|
||||
preview = m["content"][:200]
|
||||
if len(m["content"]) > 200:
|
||||
preview += "..."
|
||||
lines.append(
|
||||
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n"
|
||||
f" {m['content'][:500]}"
|
||||
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n {preview}"
|
||||
)
|
||||
msg = f"Memories ({len(rows)} results):\n" + "\n".join(lines)
|
||||
msg += "\n\nUse memory(action='get', name='...') for full content."
|
||||
else:
|
||||
msg = (
|
||||
f"No memories found for '{item['query']}'."
|
||||
@@ -4821,11 +5203,14 @@ class ChatSession:
|
||||
lines = []
|
||||
for m in rows:
|
||||
desc = f" — {m['description']}" if m.get("description") else ""
|
||||
preview = m["content"][:200]
|
||||
if len(m["content"]) > 200:
|
||||
preview += "..."
|
||||
lines.append(
|
||||
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n"
|
||||
f" {m['content'][:500]}"
|
||||
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n {preview}"
|
||||
)
|
||||
msg = f"Memories ({len(rows)}):\n" + "\n".join(lines)
|
||||
msg += "\n\nUse memory(action='get', name='...') for full content."
|
||||
else:
|
||||
msg = "No memories stored."
|
||||
self._report_tool_result(call_id, "memory", msg)
|
||||
@@ -4843,21 +5228,26 @@ class ChatSession:
|
||||
def _exec_recall(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Search conversation history."""
|
||||
call_id = item["call_id"]
|
||||
query, limit = item["query"], item["limit"]
|
||||
query, limit, offset = item["query"], item["limit"], item.get("offset", 0)
|
||||
|
||||
conv_rows = search_history(query, limit)
|
||||
conv_rows = search_history(query, limit, offset)
|
||||
if conv_rows:
|
||||
lines = []
|
||||
for ts, sid, role, content, tool_name in conv_rows:
|
||||
label = f"{role}({tool_name})" if tool_name else role
|
||||
text = (content or "")[:500]
|
||||
if content and len(content) > 500:
|
||||
text += "..."
|
||||
text = (content or "")[:2000]
|
||||
if content and len(content) > 2000:
|
||||
text += f"... ({len(content)} chars total)"
|
||||
lines.append(f"[{ts} {sid}] {label}: {text}")
|
||||
output = f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines)
|
||||
header = f"Conversations ({len(conv_rows)} matches"
|
||||
if offset:
|
||||
header += f", offset {offset}"
|
||||
header += "):"
|
||||
output = header + "\n" + "\n".join(lines)
|
||||
else:
|
||||
output = f"No conversation history found for '{query}'."
|
||||
|
||||
output = self._truncate_output(output)
|
||||
self._report_tool_result(call_id, "recall", output)
|
||||
return call_id, output
|
||||
|
||||
@@ -5340,12 +5730,14 @@ class ChatSession:
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
path, content, resolved = item["path"], item["content"], item["resolved"]
|
||||
is_append = item.get("append", False)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)
|
||||
with open(resolved, "a" if is_append else "w") as f:
|
||||
f.write(content)
|
||||
self._read_files.add(resolved)
|
||||
msg = f"Wrote {len(content)} chars to {path}"
|
||||
verb = "Appended" if is_append else "Wrote"
|
||||
msg = f"{verb} {len(content)} chars to {path}"
|
||||
self._report_tool_result(call_id, "write_file", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
@@ -5362,11 +5754,29 @@ class ChatSession:
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
path = item["path"]
|
||||
resolved = item.get("resolved", os.path.realpath(os.path.expanduser(path)))
|
||||
edits: list[dict[str, Any]] = item["edits"]
|
||||
try:
|
||||
with open(path) as f:
|
||||
with open(resolved) as f:
|
||||
content = f.read()
|
||||
|
||||
# replace_all mode: simple str.replace, skip offset logic
|
||||
do_replace_all = item.get("replace_all", False)
|
||||
if do_replace_all and len(edits) == 1:
|
||||
old = edits[0]["old_string"]
|
||||
new = edits[0]["new_string"]
|
||||
count = content.count(old)
|
||||
if count == 0:
|
||||
msg = f"Error: old_string not found in {path}"
|
||||
self._report_tool_result(call_id, "edit_file", msg, is_error=True)
|
||||
return call_id, msg
|
||||
content = content.replace(old, new)
|
||||
with open(resolved, "w") as f:
|
||||
f.write(content)
|
||||
msg = f"Edited {path}: replaced {count} occurrences"
|
||||
self._report_tool_result(call_id, "edit_file", msg)
|
||||
return call_id, msg
|
||||
|
||||
# Resolve each edit to a (start_idx, end_idx, new_string) replacement
|
||||
replacements: list[tuple[int, int, str]] = []
|
||||
for i, edit in enumerate(edits):
|
||||
@@ -5406,7 +5816,7 @@ class ChatSession:
|
||||
for start, end, new in reversed(replacements):
|
||||
content = content[:start] + new + content[end:]
|
||||
|
||||
with open(path, "w") as f:
|
||||
with open(resolved, "w") as f:
|
||||
f.write(content)
|
||||
count = len(replacements)
|
||||
noun = "edit" if count == 1 else "edits"
|
||||
|
||||
@@ -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),
|
||||
@@ -406,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:
|
||||
@@ -422,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:
|
||||
@@ -434,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()
|
||||
)
|
||||
|
||||
|
||||
@@ -189,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),
|
||||
@@ -494,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(
|
||||
@@ -507,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(
|
||||
@@ -517,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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+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
|
||||
|
||||
@@ -102,6 +102,7 @@ class WebUI:
|
||||
self._ws_tool_calls: dict[str, int] = {}
|
||||
self._ws_tool_calls_reported: int = 0 # last cumulative total sent to usage
|
||||
self._ws_context_ratio: float = 0.0
|
||||
self._ws_turn_tool_calls: int = 0
|
||||
# Activity tracking for dashboard (current tool / thinking / approval)
|
||||
self._ws_current_activity: str = ""
|
||||
self._ws_activity_state: str = "" # "tool" | "approval" | "thinking" | ""
|
||||
@@ -393,6 +394,7 @@ class WebUI:
|
||||
_metrics.record_tool_call(name)
|
||||
with self._ws_lock:
|
||||
self._ws_tool_calls[name] = self._ws_tool_calls.get(name, 0) + 1
|
||||
self._ws_turn_tool_calls += 1
|
||||
self._ws_current_activity = ""
|
||||
self._ws_activity_state = ""
|
||||
self._broadcast_activity()
|
||||
@@ -424,6 +426,8 @@ class WebUI:
|
||||
tool_total = sum(self._ws_tool_calls.values())
|
||||
tool_count = tool_total - self._ws_tool_calls_reported
|
||||
self._ws_tool_calls_reported = tool_total
|
||||
turn_tool_calls = self._ws_turn_tool_calls
|
||||
turn_count = self._ws_messages
|
||||
self._enqueue(
|
||||
{
|
||||
"type": "status",
|
||||
@@ -435,6 +439,8 @@ class WebUI:
|
||||
"effort": effort,
|
||||
"cache_creation_tokens": cache_creation,
|
||||
"cache_read_tokens": cache_read,
|
||||
"tool_calls_this_turn": turn_tool_calls,
|
||||
"turn_count": turn_count,
|
||||
}
|
||||
)
|
||||
# Record usage event for governance dashboard
|
||||
@@ -856,6 +862,32 @@ async def events_sse(request: Request) -> Response:
|
||||
}
|
||||
)
|
||||
}
|
||||
# Replay last status so the per-pane status bar populates on resume
|
||||
if session._last_usage is not None:
|
||||
u = session._last_usage
|
||||
total_tok = u["prompt_tokens"] + u["completion_tokens"]
|
||||
cw = session.context_window
|
||||
pct = total_tok / cw * 100 if cw > 0 else 0
|
||||
with ui._ws_lock:
|
||||
turn_tool_calls = ui._ws_turn_tool_calls
|
||||
turn_count = ui._ws_messages
|
||||
yield {
|
||||
"data": json.dumps(
|
||||
{
|
||||
"type": "status",
|
||||
"prompt_tokens": u["prompt_tokens"],
|
||||
"completion_tokens": u["completion_tokens"],
|
||||
"total_tokens": total_tok,
|
||||
"context_window": cw,
|
||||
"pct": round(pct, 1),
|
||||
"effort": session.reasoning_effort,
|
||||
"cache_creation_tokens": u.get("cache_creation_tokens", 0),
|
||||
"cache_read_tokens": u.get("cache_read_tokens", 0),
|
||||
"tool_calls_this_turn": turn_tool_calls,
|
||||
"turn_count": turn_count,
|
||||
}
|
||||
)
|
||||
}
|
||||
# History replay
|
||||
history = _build_history(session, has_pending_approval=ui._pending_approval is not None)
|
||||
if history:
|
||||
@@ -1240,6 +1272,7 @@ async def send_message(request: Request) -> JSONResponse:
|
||||
_metrics.record_message_sent()
|
||||
with ui._ws_lock:
|
||||
ui._ws_messages += 1
|
||||
ui._ws_turn_tool_calls = 0
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+65
-39
@@ -29,7 +29,7 @@ function Pane(wsId) {
|
||||
this.retryDelay = 1000;
|
||||
this.model = "";
|
||||
this.modelAlias = "";
|
||||
this.statusText = "";
|
||||
this._lastStatusEvt = null;
|
||||
this._cancelTimeout = null;
|
||||
this._forceTimeout = null;
|
||||
this._pendingEditSend = null;
|
||||
@@ -134,6 +134,37 @@ Pane.prototype._createDOM = function () {
|
||||
this.messagesEl.setAttribute("aria-label", "Chat messages");
|
||||
this.el.appendChild(this.messagesEl);
|
||||
|
||||
// Per-workstream status bar (above input)
|
||||
this.statusBarEl = document.createElement("div");
|
||||
this.statusBarEl.className = "ws-status-bar";
|
||||
this.statusBarEl.setAttribute("role", "status");
|
||||
this.statusBarEl.setAttribute("aria-live", "polite");
|
||||
this.statusBarEl.setAttribute("aria-atomic", "true");
|
||||
this.statusBarEl.setAttribute("aria-label", "Workstream status");
|
||||
|
||||
this._sbModel = document.createElement("span");
|
||||
this._sbModel.className = "ws-sb-model";
|
||||
this._sbModel.textContent = "\u2014";
|
||||
this._sbModel.setAttribute("aria-label", "Model");
|
||||
this._sbTokens = document.createElement("span");
|
||||
this._sbTokens.className = "ws-sb-tokens";
|
||||
this._sbTokens.textContent = "0 / \u2014";
|
||||
this._sbTokens.setAttribute("aria-label", "Token usage");
|
||||
this._sbTools = document.createElement("span");
|
||||
this._sbTools.className = "ws-sb-tools";
|
||||
this._sbTools.textContent = "0 tools";
|
||||
this._sbTools.setAttribute("aria-label", "Tool calls this turn");
|
||||
this._sbTurns = document.createElement("span");
|
||||
this._sbTurns.className = "ws-sb-turns";
|
||||
this._sbTurns.textContent = "turn 0";
|
||||
this._sbTurns.setAttribute("aria-label", "Conversation turn");
|
||||
|
||||
this.statusBarEl.appendChild(this._sbModel);
|
||||
this.statusBarEl.appendChild(this._sbTokens);
|
||||
this.statusBarEl.appendChild(this._sbTools);
|
||||
this.statusBarEl.appendChild(this._sbTurns);
|
||||
this.el.appendChild(this.statusBarEl);
|
||||
|
||||
// Input area
|
||||
var inputArea = document.createElement("div");
|
||||
inputArea.className = "pane-input-area";
|
||||
@@ -248,11 +279,8 @@ Pane.prototype.connectSSE = function (wsId) {
|
||||
|
||||
this.evtSource.onopen = function () {
|
||||
self.retryDelay = 1000;
|
||||
if (self.id === focusedPaneId) {
|
||||
var statusBar = document.getElementById("status-bar");
|
||||
statusBar.classList.remove("disconnected");
|
||||
statusBar.textContent = self.statusText || "";
|
||||
}
|
||||
self.statusBarEl.classList.remove("ws-sb-disconnected");
|
||||
if (self._lastStatusEvt) self.updateStatus(self._lastStatusEvt);
|
||||
};
|
||||
|
||||
this.evtSource.onmessage = function (e) {
|
||||
@@ -265,11 +293,8 @@ Pane.prototype.connectSSE = function (wsId) {
|
||||
self.evtSource = null;
|
||||
var loginOverlay = document.getElementById("login-overlay");
|
||||
if (loginOverlay && loginOverlay.style.display !== "none") return;
|
||||
if (self.id === focusedPaneId) {
|
||||
var statusBar = document.getElementById("status-bar");
|
||||
statusBar.textContent = "Reconnecting\u2026";
|
||||
statusBar.classList.add("disconnected");
|
||||
}
|
||||
self.statusBarEl.classList.add("ws-sb-disconnected");
|
||||
self._sbTokens.textContent = "Reconnecting\u2026";
|
||||
// Only the focused pane refreshes the global workstream list to avoid
|
||||
// race conditions when multiple panes disconnect simultaneously.
|
||||
if (self.id === focusedPaneId) {
|
||||
@@ -514,9 +539,8 @@ Pane.prototype.handleEvent = function (evt) {
|
||||
case "connected":
|
||||
this.model = evt.model || "";
|
||||
this.modelAlias = evt.model_alias || evt.model || "";
|
||||
if (this.id === focusedPaneId) {
|
||||
updateHeaderForFocusedPane();
|
||||
}
|
||||
this._sbModel.textContent = this.modelAlias || this.model || "";
|
||||
this._sbModel.title = this.model || "";
|
||||
if (evt.skip_permissions) {
|
||||
var existing = document.querySelector(".skip-permissions-warning");
|
||||
if (!existing) {
|
||||
@@ -1314,19 +1338,32 @@ Pane.prototype.addErrorMessage = function (text) {
|
||||
};
|
||||
|
||||
Pane.prototype.updateStatus = function (evt) {
|
||||
var parts = [
|
||||
this._sbModel.textContent = this.modelAlias || this.model || "";
|
||||
this._sbModel.title = this.model || "";
|
||||
|
||||
var tokenText =
|
||||
evt.total_tokens.toLocaleString() +
|
||||
" / " +
|
||||
evt.context_window.toLocaleString() +
|
||||
" tokens (" +
|
||||
evt.pct +
|
||||
"%)",
|
||||
];
|
||||
if (evt.effort !== "medium") parts.push("reasoning: " + evt.effort);
|
||||
this.statusText = parts.join(" \u00b7 ");
|
||||
if (this.id === focusedPaneId) {
|
||||
document.getElementById("status-bar").textContent = this.statusText;
|
||||
}
|
||||
" / " +
|
||||
evt.context_window.toLocaleString() +
|
||||
" (" +
|
||||
evt.pct +
|
||||
"%)";
|
||||
if (evt.effort && evt.effort !== "medium")
|
||||
tokenText += " \u00b7 " + evt.effort;
|
||||
if (evt.pct >= 95) tokenText = "\u26a0 " + tokenText;
|
||||
else if (evt.pct >= 80) tokenText = "\u25b2 " + tokenText;
|
||||
this._sbTokens.textContent = tokenText;
|
||||
|
||||
var tc = evt.tool_calls_this_turn || 0;
|
||||
this._sbTools.textContent = tc + " tool" + (tc !== 1 ? "s" : "");
|
||||
|
||||
var turns = evt.turn_count || 0;
|
||||
this._sbTurns.textContent = "turn " + turns;
|
||||
|
||||
this.statusBarEl.classList.toggle("ws-sb-warn", evt.pct >= 80);
|
||||
this.statusBarEl.classList.toggle("ws-sb-danger", evt.pct >= 95);
|
||||
|
||||
this._lastStatusEvt = evt;
|
||||
};
|
||||
|
||||
Pane.prototype.isNearBottom = function () {
|
||||
@@ -1437,7 +1474,6 @@ function setFocusedPane(paneId) {
|
||||
panes[paneId].el.classList.add("focused");
|
||||
currentWsId = panes[paneId].wsId;
|
||||
renderTabBar();
|
||||
updateHeaderForFocusedPane();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,17 +1483,6 @@ function createPane(wsId) {
|
||||
return p;
|
||||
}
|
||||
|
||||
function updateHeaderForFocusedPane() {
|
||||
var pane = getFocusedPane();
|
||||
var modelName = document.getElementById("model-name");
|
||||
var statusBar = document.getElementById("status-bar");
|
||||
if (pane) {
|
||||
modelName.textContent = pane.modelAlias || pane.model || "";
|
||||
modelName.title = pane.model || "";
|
||||
statusBar.textContent = pane.statusText || "";
|
||||
}
|
||||
}
|
||||
|
||||
function updatePaneHeaders() {
|
||||
var root = document.getElementById("split-root");
|
||||
var leafCount = countLeaves(splitRoot);
|
||||
@@ -2381,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;
|
||||
@@ -849,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.3"
|
||||
version = "0.9.5"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
||||
Reference in New Issue
Block a user