Compare commits

...

30 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Includes get_structured_memory_by_name wrapper in memory.py and
4 tests.

* Update turnstone/tools/memory.json

* fix: include 'get' in _prepare_memory docstring and invalid-action error
2026-03-29 20:53:48 -07:00
Patrick Buckley cce292f793 fix: strip NUL bytes in both storage backends via shared sanitize_text
PostgreSQL text fields cannot store NUL (0x00) bytes, and SQLite
stores them but they cause downstream issues (API payloads, web UI).
Add sanitize_text() to _utils.py and apply it in both backends'
save_message to content and provider_data fields.
2026-03-29 19:20:42 -07:00
Patrick Buckley 6adc577d30 release: v0.9.4 2026-03-29 18:32:44 -07:00
Patrick Buckley 02c50b81c1 docs: update tool counts, add diff_file docs, new params (#244)
* docs: update tool counts, add diff_file docs, new params

- Tool count 17/18 → 19 across tools.md, architecture.md, and
  PlantUML diagrams (02-package-structure, 05-tool-pipeline)
- Add diff_file tool documentation section
- Document new params: bash timeout + stop_on_error, write_file
  mode (append), edit_file replace_all
- Add diff_file, watch, skill to tool pipeline dispatch table
- Regenerate diagram PNGs

* fix: remove slim dpkg exclusion so man pages are actually installed

The python:3.14-slim image excludes /usr/share/man/* via dpkg config.
man-db was installed but had no pages to serve.  Remove the exclusion
before installing packages, and add manpages package for coreutils
documentation.  Dropped info (rarely used, man covers the same).

* fix: redact DB connection strings and URL-based secrets in output guard

The output redactor missed TURNSTONE_DB_URL and DATABASE_URL because
the env secret key pattern only matched SECRET/TOKEN/PASSWORD/KEY,
not URL-based credential keys.  Also the connection string regex
didn't cover the postgresql+psycopg:// scheme used by psycopg3.

- Add DATABASE_URL, TURNSTONE_DB_URL, DB_URL to explicit env key matches
- Add psycopg and sqlite to connection string scheme pattern

* fix: address Copilot review on docs — tool names, counts, approval

- Fix remaining 17→19 count in tools.md execution pipeline section
- Dispatch table: task→task_agent, plan→plan_agent (match actual names)
- Dispatch table: header clarifies "19 built-in + tool_search"
- watch/skill: show conditional approval (create only / load only)
- Regenerate pipeline diagram PNG
2026-03-29 18:28:21 -07:00
Patrick Buckley 753cd04b4e Fix/orphaned tool results (#243)
* fix: drop orphaned tool_results with no matching tool_use in _convert_messages

The context window increase from 200K to 1M for Claude 4.6 means
conversations that previously triggered auto-compaction now send their
full history.  Older messages with orphaned tool_results (from
pre-fix cancels or compaction boundaries) are now visible to the API,
causing "unexpected tool_use_id in tool_result blocks" errors.

The existing repair code handles orphaned tool_use (synthesizes
missing results), but not the reverse.  Now validates each
tool_result against the preceding assistant message's tool_use IDs
and silently drops results with no match.

* fix: filter empty IDs from prev_tool_use_ids, document pass-through

Code review: empty-ID tool_use blocks were added to the filter set,
and the intentional pass-through when prev_tool_use_ids is empty
needed documentation.
2026-03-29 18:26:25 -07:00
Patrick Buckley c3217748dc fix: block math sandbox escape via getattr/setattr/type reflection (#239)
* fix: block math sandbox escape via getattr/setattr/type reflection

getattr() with runtime-constructed strings bypassed the AST validator,
allowing full os/subprocess access from the sandboxed math tool via
module.__builtins__['__import__']('os').

Three-layer fix:
- Block getattr, setattr, delattr, type, __import__ in
  _MATH_BLOCKED_BUILTINS (prevents direct calls)
- Add AST validation for getattr/setattr/delattr call nodes
  (catches them even if builtins dict is bypassed)
- Strip __builtins__ from all pre-imported modules in the execution
  namespace (runtime defense — even if AST is somehow bypassed,
  module.__builtins__ returns empty dict)

Normal math, sympy, numpy, scipy operations unaffected.

* fix: harden _safe_import to strip __builtins__ from runtime imports

Copilot review: modules imported at runtime via _safe_import still
had their original __builtins__ dict, accessible via
operator.attrgetter('__builtins__').  Now _safe_import strips
__builtins__ from every module it returns.  Also blocks
operator.attrgetter/itemgetter at the AST level, and removes the
redundant duplicate getattr check in visit_Call.

* fix: add type ignore for module __builtins__ assignment
2026-03-29 17:37:59 -07:00
Patrick Buckley 8cbff49694 fix: block /proc/*/environ access in bash filter and judge heuristic (#240)
* fix: block /proc/*/environ access in bash filter and judge heuristic

/proc/1/environ leaks the full server environment including DB
credentials, API keys, and JWT secrets.  Env scrubbing in env.py
only affects subprocess calls, not procfs reads.

- Add /proc/1/environ and /proc/self/environ to BLOCKED_PATTERNS
  in safety.py (hard block)
- Add proc-environ-exfil heuristic rule at critical severity with
  deny recommendation (catches /proc/<pid>/environ patterns)

* fix: move proc-environ-exfil rule to _CRITICAL_RULES list

Copilot review: rule had risk_level=critical but was placed in
_HIGH_RULES.  Move to _CRITICAL_RULES for consistency with the
first-match-wins severity ordering.
2026-03-29 17:37:46 -07:00
Patrick Buckley 4f26d63c14 perf: trim judge context to messages from last user turn onward (#241)
The intent judge was receiving up to 50% of the context window in
conversation history (FIFO from end), which grows linearly with
conversation length and causes increasing latency.  The judge only
needs the immediate request context to evaluate a tool call's safety.

Now trims to messages from the last user message onward before
applying the FIFO budget cap.  Keeps the user's request, the
assistant's response with tool calls, and any recent tool results
while discarding earlier conversation that isn't relevant to the
current intent evaluation.
2026-03-29 17:34:41 -07:00
Patrick Buckley 74347fb29f fix: update Claude 4.6 context windows to 1M, remove EOL 4.0 models (#242)
Claude 4.6 (Opus + Sonnet) unified on 1M token context windows.
Update capabilities table from 200K to 1M for both models.  Remove
claude-opus-4 and claude-sonnet-4 entries (end of life).  4.5 models
remain at 200K.  Default fallback stays at 200K for unknown models.
2026-03-29 17:28:45 -07:00
Patrick Buckley 2ace8cccc8 fix: distinguish user cancel from crash in bash tool results (#235)
* fix: distinguish user cancel from crash in bash tool results

When a user cancels a running bash command, the process is killed
with SIGKILL (exit code -9).  Previously this showed as an error,
causing the model to retry.  Now checks cancel.is_set() after proc
exit and returns "Cancelled by user." as a non-error result so the
model knows to stop rather than retry.

* fix: use -signal.SIGKILL instead of magic -9

Copilot review: replace hard-coded -9 with -signal.SIGKILL for
clarity.  Popen.returncode is negative of signal number when killed.
2026-03-29 17:09:44 -07:00
Patrick Buckley e95b8f5ca1 feat: add stop_on_error param to bash tool for set -e behavior (#236)
* feat: add stop_on_error param to bash tool for set -e behavior

New boolean parameter enables 'set -e' in the bash preamble so
multi-step scripts exit on the first command failure instead of
silently continuing.  Default false (existing behavior preserved).
pipefail remains always-on.

* fix: strict bool parsing for stop_on_error, treat exit 1 as error with set -e

Copilot review: bool("false") is True — use `is True` for strict
JSON boolean parsing.  Also, with stop_on_error enabled, any non-zero
exit code is now treated as an error (set -e means the script halted
on failure), whereas without it exit code 1 remains benign.
2026-03-29 17:09:29 -07:00
Patrick Buckley 7a32c51a1c fix: synthesize cancelled tool results instead of stripping turns (#237)
* fix: synthesize cancelled tool results instead of stripping turns

When a user cancels during tool execution, the model previously lost
all context about what was attempted (assistant message + tool_calls
stripped entirely).  Now synthesizes tool_result messages with
is_error=true and "Cancelled by user." content for any tool_calls
that lack matching results.  This keeps the conversation valid for
both providers while preserving the full tool call structure so the
model knows what was tried.

Also applies to KeyboardInterrupt with "Interrupted by user." text.

* fix: persist synthesized cancel results to DB, assert is_error in test

Copilot review: synthesized tool messages were in-memory only,
creating a mismatch with DB that could break rewind/retry.  Now
calls save_message() for each synthesized result.  Also adds
is_error=True assertion to the cancel test.
2026-03-29 17:09:17 -07:00
Patrick Buckley dce663105b feat: add pagination and longer content to recall tool (#238)
* feat: add pagination and longer content to recall tool

- New offset parameter for paginating through recall results
- Content preview increased from 500 to 2000 chars per match with
  total length indicator when truncated
- Output passed through _truncate_output for consistency
- OFFSET clause added to SQLite (FTS5 + LIKE) and PostgreSQL
  (tsvector + ILIKE) search queries

* fix: defensive int coercion for recall offset/limit

Copilot review: offset/limit could arrive as null, float, or other
non-int types from JSON.  Coerce with int() + try/except in prepare,
and int() at the storage layer before binding into SQL OFFSET/LIMIT.
2026-03-29 17:09:05 -07:00
Patrick Buckley cfef3616e6 feat: add diff_file tool for comparing files and content (#234)
* feat: add diff_file tool for comparing files and content

New read-only tool that shows unified diffs between two files or
between a file and provided content.  Useful for verifying edit_file
changes and comparing file versions.  Auto-approved (no side effects).
Configurable context lines (default 3).  Available to task agents.

* refactor: extract _read_text_lines helper, share across read_file and diff_file

Copilot review: diff_file duplicated file-loading and lacked binary
detection.  Extract _read_text_lines() that handles realpath
resolution, null-byte binary detection, and error handling.  Used by
both _exec_read_file and _exec_diff for consistent behavior.

* fix: address code review — agent flag, resolved shadowing, read_files

- Add agent: true to diff_file schema so plan agents can use it
- Fix resolved variable shadowing in _exec_read_file (use _ for
  unused return from _read_text_lines)
- Register diffed files in _read_files so edit_file read guard
  is satisfied after diff_file
- Move difflib import to module level (stdlib, no lazy-load needed)
- Fix description wording ("provided string" not "previous version")

* fix: stream diff with early cutoff, expand paths before header

- Stream difflib output and stop collecting after tool_truncation
  chars to avoid large intermediate allocations on big diffs
- Expand paths with expanduser before building the approval header
  so display matches actual execution paths
2026-03-29 16:17:48 -07:00
Patrick Buckley c22d39a798 docs: tool descriptions, bash timeout param, multi-line preview (#233)
* docs: tool descriptions, bash timeout param, multi-line preview

- task_agent/plan_agent: document the tool subset limitation (no
  memory, recall, watch, skill, or further delegation)
- bash: add per-call timeout parameter (1-600s, defaults to 120s),
  shown in approval header when specified
- bash: show full command in preview for multi-line scripts so the
  approval flow displays the complete command, not just the first line
- bash: document 256KB output cap and stderr prefix in description

* fix: address Copilot review on tool descriptions

- bash: say "truncated" not "256KB" (limit is configurable), document
  timeout clamping range (1-600) and global fallback
- bash preview: fix "1 more lines" → "1 more line" singular
- plan_agent: remove bash from listed tools (not in AGENT_TOOLS)
2026-03-29 16:17:35 -07:00
Patrick Buckley 63921450b1 fix: improve memory save error message, narrow dd command filter (#232)
* fix: improve memory save error message, narrow dd command filter

Two minor fixes from harness shakedown:

- memory save: split "both name and content required" into separate
  errors for missing name vs empty content
- bash safety: replace blanket "dd if=" block with targeted patterns
  for writes to block devices (of=/dev/sd*, /dev/nvme*, /dev/disk/,
  etc.) and redirects to the same.  Legitimate dd use like generating
  test data or benchmarking reads is no longer blocked.

* fix: generalize > /dev/sda redirect pattern to > /dev/sd

Copilot review: only /dev/sda was blocked for redirects while
/dev/sdb, /dev/sdc etc were not.  Generalize to match any /dev/sd*
device, consistent with the of= patterns.
2026-03-29 16:17:22 -07:00
Patrick Buckley 929fad63be feat: edit_file replace_all, write_file append mode, search match count (#231)
* feat: edit_file replace_all, write_file append mode, search match count

Three tool enhancements from harness shakedown feedback:

- edit_file: new replace_all parameter replaces all occurrences of
  old_string instead of requiring a unique match.  Cannot combine with
  near_line or edits array.
- write_file: new mode parameter with "append" option.  Appends
  content to end of file instead of truncating.
- search: output now includes a summary footer showing total match
  count and file count (e.g. "47 matches across 12 files").

* fix: address Copilot review on tool enhancements

- replace_all: skip multi-occurrence rejection in pre-validation so
  the feature actually works; show occurrence count in preview
- write_file mode: coerce non-string types safely via str()
- search footer: append before truncation to respect output limits
- edit_file error: mention replace_all as alternative to near_line
2026-03-29 16:17:11 -07:00
Patrick Buckley 976e9df3b6 ci: suppress CVE-2026-25210 (libexpat1, no fix available) (#230)
Integer overflow in libexpat1 2.7.1-2 with no patched version in
Debian repos yet.  Suppress in Trivy until a fix is published.
2026-03-29 15:35:20 -07:00
Patrick Buckley 7cb21b84f1 fix: detect binary files in read_file instead of silent corruption (#227)
read_file silently converted null bytes to spaces, showing corrupted
content with no warning.  Now samples the first 8KB for null bytes and
returns a clear error directing the user to bash for binary inspection.
2026-03-29 15:32:04 -07:00
Patrick Buckley 7263edd48d fix: memory delete searches all scopes when scope not specified (#228)
* fix: memory delete searches all scopes when scope not specified

Previously delete defaulted to scope=global, so deleting a
workstream-scoped memory without explicitly passing scope=workstream
silently failed.  Now tries narrowest scope first (workstream → user
→ global) and deletes the first match.  Explicit scope still honored
when provided.

* fix: reject invalid scope on memory delete instead of silent fallback

Copilot review: invalid scope values were silently treated as
unspecified, which could cause accidental deletion from the wrong
scope.  Now returns a clear error listing valid scopes.
2026-03-29 15:29:14 -07:00
Patrick Buckley 6742c7e405 fix: exclude build/vendor/VCS directories from search tool (#226)
* fix: exclude build/vendor/VCS directories from search tool

grep -rn recursed into .git, node_modules, target, __pycache__, etc.
producing hundreds of noise hits from generated content.  Add
--exclude-dir flags for common directories that should never appear
in search results.

* fix: glob egg-info pattern and add vendor exclude

Copilot review: .egg-info misses turnstone.egg-info (named dirs),
use *.egg-info glob.  Also add vendor to the exclude list.
2026-03-29 15:28:52 -07:00
Patrick Buckley 1aa6982868 fix: add git, curl, jq, man-db, info to Docker image (#225)
Agent workflows need git for version control, curl for raw HTTP
requests, jq for JSON processing, and man/info for documentation
lookup.  All were missing from the slim base image, leaving the man
tool non-functional and standard dev workflows broken.
2026-03-29 15:28:38 -07:00
Patrick Buckley 42d1abbd04 fix: block IPv6 loopback/link-local/private in SSRF filter (#224)
* fix: block IPv6 loopback/link-local/private in SSRF filter

check_ssrf used gethostbyname which only resolves IPv4.  IPv6 addresses
like ::1, fe80::, fd00:: bypassed the filter entirely.  Switch to
getaddrinfo which resolves both address families and check all results.

* fix: handle IPv4-mapped IPv6 and zone IDs in SSRF filter

Copilot review caught two bypasses: ::ffff:127.0.0.1 (IPv4-mapped
IPv6) wasn't normalized before private/loopback checks, and fe80::1%lo0
(zone ID suffix) caused a ValueError that was silently swallowed.
Now normalizes IPv4-mapped addresses and strips zone IDs before parsing.
2026-03-29 15:28:27 -07:00
Patrick Buckley f543ed714a fix: resolve symlinks before file I/O to prevent path-based bypass (#223)
* fix: resolve symlinks before file I/O to prevent path-based bypass

write_file and edit_file followed symlinks silently — a symlink at
/data/link → /etc/passwd would show the /data path in the approval
header while writing to the real target.  Three changes:

- open() calls in _exec_write_file, _exec_edit_file, _exec_read_file
  now use the resolved (realpath) path instead of the raw symlink
- Approval headers show both paths when a symlink is detected
  (e.g. "⚙ write_file: /data/link → /etc/passwd")
- Judge _get_arg_text includes the resolved path so heuristic rules
  like write-system-path fire even through symlinks

* fix: address Copilot review — expanduser in fallback, pre-read, image paths

- edit_file exec fallback: add expanduser before realpath (tilde bypass)
- judge _get_arg_text: compare resolved against abspath(expanduser(path))
  so ~/ paths don't false-positive as symlinks
- edit_file pre-read: use resolved path instead of raw symlink path
- _exec_read_image: use resolved path for getsize and binary open
2026-03-29 15:28:13 -07:00
Patrick Buckley 2c6abb0fde fix: clear dedup sigs after write tools to avoid false repeat warnings (#229)
* fix: clear dedup sigs after write tools to avoid false repeat warnings

The read→edit→read workflow triggered "identical repeat" warnings
because the dedup tracker compared (tool_name, args) without
considering intervening state changes.  Now clears the signature set
when write_file, edit_file, or bash executes successfully, so
subsequent reads of the same file are not flagged.

* fix: use shared error prefixes for write-success detection in dedup

Copilot review: the error detection for write tools only checked
"Error" prefix, missing "Command timed out", "Blocked:", "Denied",
etc.  Now shares the same _error_prefixes tuple used by the repeat
detection below, ensuring consistent classification.
2026-03-29 15:27:59 -07:00
Patrick Buckley 491fc6748a fix: judge double tool conversion on Anthropic (#222)
The judge pre-converted tool schemas via convert_tools() before
passing them to create_completion(), which internally calls
convert_tools() again. The second conversion tried to extract
function.name from already-converted Anthropic-format tools,
producing empty tool names that the API rejected with
"tools.0.custom.name: String should have at least 1 character".

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