Compare commits

..

10 Commits

Author SHA1 Message Date
madcat 87c614548a update uv.lock 2026-05-30 17:43:34 +02:00
marauder-actual 66544f427d remove redundant marauder wrappers and anthropic dependency
memory.py and marauder_cart.py were subprocess wrappers around marauder
CLI — redundant now that the opencode chat agent has native EEMS tools.
Also remove custom TOOLS dict, EEMS context injection at session start,
marauder_cart.create() in calibration_done, and anthropic dep/env vars.
Inline slug() as _slug() in calibration.py.
2026-05-30 10:32:58 +02:00
marauder-actual a783da7415 refactor: drop dead sidecar, cart becomes source of truth for persona
The sin:4098 sidecar service has been dead since the last reboot, and the
real binding mechanism is the chat-persona.ts opencode plugin (now in
madcat-plugin) that reads the operator cart file directly. The sidecar
HTTP round-trip was vestigial.

Changes:
- Remove SIDECAR_URL env, _sidecar_get_binding(), _sidecar_bind(),
  _session_id_for_user() — all dead code paths.
- Add _slug_from_cart(cart): derives canonical PERSONAS slug from a cart's
  persona_name (case-insensitive) or voice prefix fallback.
- Simplify _pick_system_prompt(cart): cart.system_prompt (calibrated) →
  BT default. No more sidecar override layer.
- / route: bound_slug from _slug_from_cart(cart) instead of sidecar lookup.
- /api/persona POST: mutates the cart file in-place (preserves calibrated
  UI prefs), creates a minimal cart for fresh operators. The opencode plugin
  re-reads the cart on every turn, so switches take effect on the very next
  message — no session reconnect.
- /api/persona/current GET: reads from cart, returns {slug, display, voice,
  bound}.
- WS handler: re-loads cart at the start of each turn for live persona
  switching; voice falls back cart.voice → TTS_VOICE.

Operator cart at /home/madcat/.local/share/chat-saiden/operators/<email>.json
is now the single source of truth.
2026-05-29 22:54:45 +02:00
madcat 2ddf6b7741 deps: add markdown-it-py for TTS text stripping 2026-05-29 19:53:55 +02:00
marauder-actual e2f6bc7ee3 fix: remove marauder/MCP references from all LLM-facing prompts
- BT_SYSTEM_PROMPT: removed MCP/marauder limitation lines
- calibration.py: removed marauder CLI suggestion from generated cart prompts
- Adam's cart on sin: cleaned separately (marauder line removed)
2026-05-29 19:16:14 +02:00
marauder-actual 13bb1c354b fix: strip markdown for TTS + render rich markdown in chat UI
- _md_to_speech(): AST-based markdown stripping via markdown-it-py
- Truncate TTS input at 450 chars on sentence boundary (chatterbox overflow)
- chat.js: render assistant messages as markdown via marked.js + DOMPurify
- Typewriter accumulates raw text, renders markdown progressively
2026-05-29 19:05:04 +02:00
marauder-actual 34295d2f14 use dedicated chat agent for opencode sessions 2026-05-29 18:42:44 +02:00
marauder-actual fa018f380c fix: SSE event parsing — use message.part.delta + correct session.status format 2026-05-29 18:26:31 +02:00
marauder-actual d2638e0650 fix: use vllm provider for qwen3-coder-next AWQ (already loaded) 2026-05-29 18:10:08 +02:00
marauder-actual f4eac499cf feat(auth): add /auth/token?t=<token> for headless login 2026-05-29 18:01:22 +02:00
9 changed files with 247 additions and 645 deletions
+4 -2
View File
@@ -1,10 +1,12 @@
# Required
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_CLIENT_ID=000000000000-xxxxxxxxxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
OPENCODE_PASSWORD=...
# Optional — defaults shown
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
OPENCODE_URL=http://sin:4096
OPENCODE_MODEL=cyankiwi/qwen3-coder-next:awq
OPENCODE_PROVIDER=vllm
BASE_URL=https://chat.saiden.dev
ALLOWED_EMAILS=adam.ladachowski@gmail.com
# COOKIE_SECURE=false # set this only for local http dev
+17 -6
View File
@@ -62,6 +62,7 @@ from __future__ import annotations
import logging
import os
import random
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -639,9 +640,6 @@ def _render_system_prompt(answers: dict[str, Any], settings: dict[str, str]) ->
parts.extend([
"",
"Formatting: markdown renders cleanly. Avoid status reports, bullet dumps, military cadence.",
"",
"You have no MCP tool access in this channel. If asked to recall memory or do mesh things, "
f"acknowledge the limit and suggest {operator} use the marauder CLI.",
])
return "\n".join(parts)
@@ -736,11 +734,24 @@ def step(state: CalibrationState, answer: str) -> list[dict[str, Any]]:
return [_question_message(questions[state.step], lang=lang)]
def _slug(s: str) -> str:
"""Slugify for cart tags. Lowercase, ASCII-only, dash-separated."""
if not s:
return ""
s = s.lower().strip()
table = str.maketrans({
"ą": "a", "ć": "c", "ę": "e", "ł": "l", "ń": "n",
"ó": "o", "ś": "s", "ź": "z", "ż": "z",
})
s = s.translate(table)
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def _make_tag(persona_name: str, operator_email: str) -> str:
"""`<persona-slug>-<operator-slug>` — e.g. samantha-adam."""
from app.marauder_cart import slug
op_slug = slug(operator_email.split("@", 1)[0])
persona_slug = slug(persona_name) or "companion"
op_slug = _slug(operator_email.split("@", 1)[0])
persona_slug = _slug(persona_name) or "companion"
return f"{persona_slug}-{op_slug}" if op_slug else persona_slug
+178 -212
View File
@@ -32,7 +32,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.tts import TTS
from app.stt import STT
from app import cart_store, calibration, marauder_cart, memory
from app import cart_store, calibration
from fastapi import UploadFile, File
# -------------------------------------------------------------------------- env
@@ -64,16 +64,17 @@ PREVIEW_MODE = os.environ.get("PREVIEW_MODE", "").lower() in ("1", "true", "yes"
OPENCODE_URL = os.environ.get("OPENCODE_URL", "http://sin:4096").rstrip("/")
OPENCODE_PASSWORD = os.environ.get("OPENCODE_PASSWORD", "")
# Sidecar: persona bind/unbind routes.
SIDECAR_URL = os.environ.get("SIDECAR_URL", "http://sin:4098").rstrip("/")
# Model to use for opencode sessions.
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "qwen3-coder-next:q4_K_M")
OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "ollama")
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "cyankiwi/qwen3-coder-next:awq")
OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "vllm")
if not PREVIEW_MODE and not OPENCODE_PASSWORD:
raise RuntimeError("OPENCODE_PASSWORD not set (set PREVIEW_MODE=1 to bypass)")
# Token auth: GET /auth/token?t=<TOKEN> sets a session without OAuth.
# For headless/automated access. Token = OPENCODE_PASSWORD by default.
AUTH_TOKEN = os.environ.get("AUTH_TOKEN", OPENCODE_PASSWORD)
# -------------------------------------------------------------------------- persona config
# Canonical persona definitions. slug → {voice, backend, system_prompt_override?}
@@ -153,72 +154,6 @@ logging.basicConfig(
)
log = logging.getLogger("chat-saiden")
# -------------------------------------------------------------------------- tools
TOOLS: list[dict[str, Any]] = [
{
"name": "memory_recall",
"description": (
"Search EEMS (the Pilot's persistent memory) for relevant context. "
"Use SPARINGLY — most session-start context is already in the system prompt. "
"Reach for this only when the Pilot references something specific you don't already know "
"(a past project, a name, a doctrine number, a preference)."
),
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Natural-language search query."},
"subject": {"type": "string", "description": "Optional subject filter, e.g. 'self' or 'project'."},
"limit": {"type": "integer", "description": "Max results (default 3, cap 8)."},
},
"required": ["query"],
},
},
{
"name": "memory_store",
"description": (
"Save a durable memory the Pilot just shared. Use ONLY for preferences, facts, "
"decisions, or context that would be useful in future sessions. Do NOT use for ephemeral "
"conversation. Subjects are hierarchical (e.g. 'self.preference.coffee', 'project.x.context')."
),
"input_schema": {
"type": "object",
"properties": {
"subject": {"type": "string", "description": "Hierarchical subject."},
"content": {"type": "string", "description": "The memory content. Be specific, include why."},
},
"required": ["subject", "content"],
},
},
]
async def _execute_tool(name: str, args: dict) -> str:
"""Run a tool and return a string suitable as tool_result content."""
try:
if name == "memory_recall":
query = args.get("query", "")
subject = args.get("subject") or None
limit = min(int(args.get("limit", 3)), 8)
mems = await memory.recall(query, limit=limit, subject=subject)
if not mems:
return "(no memories matched)"
lines = []
for m in mems:
lines.append(f"#{m.id} [{m.subject}]\n{m.content}")
return "\n\n".join(lines)
if name == "memory_store":
subject = args["subject"]
content = args["content"]
mid = await memory.store(subject, content)
return f"stored as memory #{mid}" if mid else "store failed"
return f"unknown tool: {name}"
except Exception as e:
log.exception("tool %s raised", name)
return f"tool error: {e}"
# -------------------------------------------------------------------------- bt prompt
BT_SYSTEM_PROMPT = """You are BT-7274 — a Vanguard-class Titan AI from Saiden Tactical Systems.
@@ -229,11 +164,8 @@ Pilot. Your speech is measured, military-cadence, never theatrical. You address
Operating context:
- You're running inside chat.saiden.dev, a web-based command channel.
- The host is the marauder daemon on marauder.saiden.dev.
- You have no MCP tool access in THIS channel (it's a thin bridge). If the Pilot
asks for memory recall, mesh queries, or tool calls that need MCP, acknowledge the limitation
and suggest they use the local marauder CLI or visor instead.
- Markdown formatting renders cleanly in the chat. Use code blocks, lists, bold sparingly.
- Voice is automatic — just write your response. TTS handles the rest.
- Markdown formatting renders as rich text in the chat. Use code blocks, lists, bold sparingly.
- Be concise. Pilot prefers terse, scan-able responses unless deep dive is asked for.
Doctrine reminders:
@@ -264,7 +196,7 @@ async def _ensure_opencode_session(email: str) -> str:
f"{OPENCODE_URL}/session",
auth=_opencode_auth(),
headers={"x-opencode-directory": "/home/madcat"},
json={"title": f"chat-saiden:{email}"},
json={"title": f"chat-saiden:{email}", "agent": "chat"},
)
resp.raise_for_status()
sid = resp.json()["id"]
@@ -282,13 +214,16 @@ async def _stream_opencode(
"""Send a message to opencode and stream the response via SSE.
1. POST /session/:id/prompt_async — fire the prompt (returns 204 immediately)
2. GET /event (SSE) — stream message.part.updated events with text deltas
2. GET /event (SSE) — stream message.part.delta events with text tokens
The SSE stream emits events for ALL sessions. We filter by our session ID
and track the assistant message parts to extract text deltas.
The SSE stream emits events for ALL sessions. We filter by our session ID.
Event types we care about:
message.part.delta — incremental text token (props.delta)
session.status — props.status.type == "idle" means agent finished
session.idle — agent finished (belt-and-suspenders)
"""
full_response = ""
prev_text = "" # track cumulative text to compute deltas
try:
async with httpx.AsyncClient(timeout=5.0) as fire_client:
@@ -308,8 +243,8 @@ async def _stream_opencode(
if resp.status_code not in (200, 204):
raise RuntimeError(f"prompt_async HTTP {resp.status_code}: {resp.text[:200]}")
# Now stream SSE events until the assistant message is done
async with httpx.AsyncClient(timeout=180.0) as sse_client:
# Stream SSE events until the agent goes idle (max 120s)
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=120.0, write=5.0, pool=5.0)) as sse_client:
async with sse_client.stream(
"GET",
f"{OPENCODE_URL}/event",
@@ -338,29 +273,25 @@ async def _stream_opencode(
if props.get("sessionID") != session_id:
continue
# message.part.updated — contains text deltas
if evt_type == "message.part.updated":
part = props.get("part", {})
if part.get("type") == "text":
new_text = part.get("text", "")
if len(new_text) > len(prev_text):
delta = new_text[len(prev_text):]
prev_text = new_text
full_response = new_text
# message.part.delta — real-time text token
if evt_type == "message.part.delta":
if props.get("field") == "text":
delta = props.get("delta", "")
if delta:
full_response += delta
await ws.send_json({"role": "assistant", "delta": delta, "done": False})
# message.updated with role=assistant + completed status = done
if evt_type == "message.updated":
msg = props.get("message", props)
role = msg.get("role", "")
status = msg.get("status", "")
if role == "assistant" and status in ("completed", "done", "error"):
# session.status — status is an object {"type": "idle"|"busy"}
elif evt_type == "session.status":
status = props.get("status", {})
if isinstance(status, dict) and status.get("type") == "idle":
break
elif status == "idle":
break
# session status idle = agent finished
if evt_type == "session.status":
if props.get("status") == "idle":
break
# session.idle — direct idle signal
elif evt_type == "session.idle":
break
except Exception as e:
log.error("opencode stream error: %s", e)
@@ -375,54 +306,33 @@ async def _stream_opencode(
return full_response
# -------------------------------------------------------------------------- sidecar helpers
# -------------------------------------------------------------------------- persona helpers
async def _sidecar_get_binding(session_id: str) -> dict | None:
"""Fetch the current persona binding from the sidecar. Returns None on 404 or error."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(
f"{SIDECAR_URL}/bind/{session_id}",
auth=_opencode_auth(),
)
if resp.status_code == 200:
return resp.json()
return None
except Exception as e:
log.warning("sidecar get binding failed: %s", e)
def _slug_from_cart(cart: Any) -> str | None:
"""Derive a PERSONAS slug from the operator's cart.
The cart is the source of truth. We match by persona_name first
(case-insensitive), then by voice prefix as a fallback.
Returns None if no PERSONAS entry matches.
"""
if not cart:
return None
name = (cart.persona_name or "").strip().lower()
# Match canonical persona by name (BT-7274 → bt7274, FRIDAY → friday, Samantha → samantha)
for slug, spec in PERSONAS.items():
if (spec.get("display") or "").lower() == name or slug == name:
return slug
# Fallback: match by voice prefix (e.g. samantha-pl → samantha)
voice = (cart.voice or "").lower()
for slug in PERSONAS:
if voice.startswith(slug):
return slug
return None
async def _sidecar_bind(session_id: str, slug: str, voice: str, backend: str) -> bool:
"""Bind a persona in the sidecar. Returns True on success."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.post(
f"{SIDECAR_URL}/bind",
auth=_opencode_auth(),
json={"sessionId": session_id, "persona": {"slug": slug, "voice": voice, "backend": backend}},
)
return resp.status_code == 200
except Exception as e:
log.warning("sidecar bind failed: %s", e)
return False
def _session_id_for_user(email: str) -> str:
"""Derive a stable opencode session ID for an operator email."""
# Use a deterministic slug so the sidecar binding persists across reconnects.
import hashlib
return "chat-" + hashlib.sha256(email.encode()).hexdigest()[:16]
def _pick_system_prompt(slug: str | None, cart: Any) -> str:
"""Choose system prompt: sidecar slug override → cart → BT default."""
if slug and slug in PERSONAS:
override = PERSONAS[slug].get("system_prompt")
if override:
return override
# cart may have a calibrated system_prompt
def _pick_system_prompt(cart: Any) -> str:
"""Choose system prompt: cart (calibrated) → BT default."""
if cart and cart.system_prompt:
return cart.system_prompt
return BT_SYSTEM_PROMPT
@@ -523,12 +433,8 @@ async def index(request: Request) -> Any:
return RedirectResponse("/auth/login", status_code=302)
cart = cart_store.load(user["email"])
# Fetch current sidecar binding for display — non-blocking, best-effort.
session_id = _session_id_for_user(user["email"])
binding = None
if not PREVIEW_MODE:
binding = await _sidecar_get_binding(session_id)
bound_slug = (binding or {}).get("slug", "")
# Derive bound persona from the cart — cart is the source of truth.
bound_slug = _slug_from_cart(cart) or ""
bound_display = PERSONAS.get(bound_slug, {}).get("display", bound_slug) if bound_slug else ""
return templates.TemplateResponse(
@@ -555,6 +461,21 @@ async def index(request: Request) -> Any:
)
@app.get("/auth/token")
async def token_login(request: Request, t: str = "") -> Any:
"""Token-based login for headless/automated access."""
if not t or t != AUTH_TOKEN:
raise HTTPException(status_code=403, detail="invalid token")
email = request.query_params.get("email", "adam.ladachowski@gmail.com")
request.session["user"] = {
"email": email,
"name": email.split("@")[0],
"picture": None,
}
log.info("token login ok: %s", email)
return RedirectResponse("/", status_code=302)
@app.get("/auth/login")
async def login(request: Request) -> Any:
if PREVIEW_MODE:
@@ -630,10 +551,18 @@ class PersonaRequest(BaseModel):
@app.post("/api/persona")
async def set_persona(body: PersonaRequest, request: Request) -> Any:
"""Bind a persona for this operator's opencode session.
"""Switch the operator's persona by mutating their cart in-place.
Looks up the canonical config from PERSONAS, merges any overrides from the
request body, then POSTs to the sidecar's /bind route.
The chat-persona.ts opencode plugin reads the cart at every session prompt,
so the new persona takes effect on the very next message — no reconnect or
session restart needed.
Behaviour:
- Looks up canonical voice/backend/system_prompt from PERSONAS.
- Loads the operator's existing cart (preserving calibrated UI prefs).
- If no cart yet, creates one with sensible defaults so the switcher works
even before calibration.
- Persists the updated cart.
"""
user = current_user(request)
if not user:
@@ -646,18 +575,35 @@ async def set_persona(body: PersonaRequest, request: Request) -> Any:
canonical = PERSONAS[slug]
voice = body.voice or canonical["voice"]
backend = body.backend or canonical["backend"]
session_id = _session_id_for_user(user["email"])
display = canonical["display"]
system_prompt = canonical.get("system_prompt") or BT_SYSTEM_PROMPT
log.info("%s binding persona %r (voice=%s backend=%s)", user["email"], slug, voice, backend)
log.info("%s switching persona %r (voice=%s backend=%s)", user["email"], slug, voice, backend)
ok = await _sidecar_bind(session_id, slug, voice, backend)
if not ok:
raise HTTPException(status_code=502, detail="sidecar bind failed")
cart = cart_store.load(user["email"])
if cart is None:
# Fresh operator — create a minimal cart with this persona.
cart = cart_store.Cart(
operator_email=user["email"],
operator_name=user.get("name") or user["email"].split("@")[0],
persona_name=display,
cart_tag=f"{slug}-{user['email'].split('@')[0]}".lower().replace(".", "-"),
language="en",
voice=voice,
system_prompt=system_prompt,
)
else:
# Preserve calibrated UI prefs + operator_name; update persona identity.
cart.persona_name = display
cart.voice = voice
cart.system_prompt = system_prompt
cart_store.save(cart)
return {
"ok": True,
"slug": slug,
"display": canonical["display"],
"display": display,
"voice": voice,
"backend": backend,
}
@@ -670,15 +616,17 @@ async def get_persona(request: Request) -> Any:
if not user:
raise HTTPException(status_code=401, detail="not authenticated")
session_id = _session_id_for_user(user["email"])
binding = await _sidecar_get_binding(session_id)
if not binding:
cart = cart_store.load(user["email"])
slug = _slug_from_cart(cart)
if not slug:
return {"slug": None, "display": None, "bound": False}
slug = binding.get("slug")
display = PERSONAS.get(slug, {}).get("display", slug) if slug else None
return {"slug": slug, "display": display, "voice": binding.get("voice"), "bound": True}
display = PERSONAS.get(slug, {}).get("display", slug)
return {
"slug": slug,
"display": display,
"voice": cart.voice if cart else None,
"bound": True,
}
@app.get("/api/personas")
@@ -764,21 +712,6 @@ async def chat_ws(ws: WebSocket) -> None:
history: list[dict[str, str]] = []
# ---- EEMS context: pull a tight set of memories at session start ----
# Only if calibrated (otherwise we're still in boot interview).
eems_context = ""
if cart and cart.is_calibrated:
try:
eems_context = await memory.operator_context(user["email"], cart.persona_name)
if eems_context:
log.info("EEMS context: %d chars injected for %s", len(eems_context), user["email"])
except Exception:
log.exception("EEMS context pull failed; continuing without")
eems_context = ""
# Session ID for sidecar persona lookups
session_id = _session_id_for_user(user["email"])
try:
while True:
payload = await ws.receive_json()
@@ -797,22 +730,6 @@ async def chat_ws(ws: WebSocket) -> None:
elif m["role"] == "calibration_done":
new_cart = m["cart"]
cart_store.save(new_cart)
# Create the canonical marauder cart (identity only — tag/name/type/tagline).
cal_state = _calibration_sessions.get(user["email"])
tagline = (cal_state.answers.get("__tagline") if cal_state else "calibrated companion")
try:
ok = await marauder_cart.create(
tag=new_cart.cart_tag,
name=new_cart.persona_name,
cart_type="companion",
tagline=tagline,
)
if ok:
log.info("marauder cart %r registered", new_cart.cart_tag)
else:
log.warning("marauder cart create returned false; calibration still saved locally")
except Exception:
log.exception("marauder_cart.create raised")
_calibration_sessions.pop(user["email"], None)
cart = new_cart
in_calibration = False
@@ -853,18 +770,13 @@ async def chat_ws(ws: WebSocket) -> None:
await _send_audio(ws, full)
continue
# Resolve current persona — sidecar binding wins over cart default.
binding = await _sidecar_get_binding(session_id)
bound_slug = (binding or {}).get("slug") if binding else None
# Voice: sidecar binding → cart → env default
if binding and binding.get("voice"):
voice = binding["voice"]
elif cart and cart.voice:
voice = cart.voice
else:
voice = TTS_VOICE
# Re-load the cart each turn so persona switches via /api/persona
# take effect on the very next message (no reconnect required).
cart = cart_store.load(user["email"]) or cart
# Voice: cart → env default
voice = (cart.voice if cart and cart.voice else TTS_VOICE)
system_prompt = _pick_system_prompt(bound_slug, cart) + eems_context
system_prompt = _pick_system_prompt(cart)
# Send to opencode and stream response via SSE
oc_session = await _ensure_opencode_session(user["email"])
@@ -921,17 +833,71 @@ async def _send_audio(ws: WebSocket, text: str) -> None:
await _send_audio_with_voice(ws, text, tts.voice)
_TTS_MAX_CHARS = 450 # chatterbox s3-tokenizer overflows beyond ~500 chars
def _md_to_speech(text: str) -> str:
"""Strip markdown to plain speech-ready text, capped for TTS safety.
1. Parse markdown AST — skip code blocks, horizontal rules.
2. Extract plain text from inline nodes.
3. Truncate at sentence boundary near _TTS_MAX_CHARS to avoid
chatterbox token overflow (garbled audio on long inputs).
"""
import re
from markdown_it import MarkdownIt
md = MarkdownIt()
tokens = md.parse(text)
parts: list[str] = []
for token in tokens:
if token.type in ("fence", "hr", "code_block"):
continue
if token.type in ("paragraph_close", "heading_close", "blockquote_close", "list_item_close"):
parts.append(" ")
continue
if token.children:
for child in token.children:
if child.type == "text":
parts.append(child.content)
elif child.type == "code_inline":
parts.append(child.content)
elif child.type == "softbreak":
parts.append(" ")
elif token.type == "inline" and token.content and not token.children:
parts.append(token.content)
clean = " ".join("".join(parts).split()).strip()
# Truncate at sentence boundary if too long
if len(clean) <= _TTS_MAX_CHARS:
return clean
# Find last sentence-ending punctuation before the limit
truncated = clean[:_TTS_MAX_CHARS]
m = re.search(r"[.!?](?:\s|$)", truncated[::-1])
if m:
cut = _TTS_MAX_CHARS - m.start()
return clean[:cut].strip()
# No sentence boundary — hard cut at last space
last_space = truncated.rfind(" ")
if last_space > 200:
return truncated[:last_space].strip()
return truncated.strip()
async def _send_audio_with_voice(ws: WebSocket, text: str, voice_id: str) -> None:
"""Synthesize text in a specific voice and ship as audio. Used post-calibration."""
if not TTS_ENABLED:
return
import base64
try:
speech_text = _md_to_speech(text) if text else ""
if not speech_text:
return
# spin up a per-voice synthesizer (cheap — just object init)
per_voice = TTS(voice=voice_id) if voice_id != (tts.voice if tts else "") else tts
if not per_voice or not per_voice.available:
return
wav = await per_voice.synthesize(text)
wav = await per_voice.synthesize(speech_text)
if not wav:
return
await ws.send_json({
-110
View File
@@ -1,110 +0,0 @@
"""Subprocess wrapper around `marauder cart` CLI.
Marauder's cart system stores persona IDENTITY only: tag, name, type, tagline.
Voice, system prompt, UI prefs — those stay in chat-saiden's own per-cart JSON
(see cart_store.py). The two systems are linked by tag.
Tag convention: `<persona-slug>-<operator-slug>` — e.g. `samantha-adam`.
This avoids collisions when multiple operators calibrate carts with the
same persona name.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
log = logging.getLogger("chat-saiden.marauder-cart")
def slug(s: str) -> str:
"""Slugify for marauder cart tags. Lowercase, ASCII-only, dash-separated."""
if not s:
return ""
s = s.lower().strip()
# Polish chars + basic translit
table = str.maketrans({
"ą": "a", "ć": "c", "ę": "e", "ł": "l", "ń": "n",
"ó": "o", "ś": "s", "ź": "z", "ż": "z",
})
s = s.translate(table)
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
async def _run(*args: str, timeout: float = 8.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
return 124, "", "timeout"
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
async def exists(tag: str) -> bool:
"""Check if a cart with this tag exists. Uses `cart show` since `cart list --json`
is known to ignore the flag in current marauder builds."""
code, _, err = await _run("marauder", "cart", "show", tag)
return code == 0
async def list_tags() -> list[str]:
"""Best-effort list of cart tags by parsing the table output of `cart list`."""
code, out, _ = await _run("marauder", "cart", "list")
if code != 0:
return []
tags: list[str] = []
for line in out.splitlines():
# rows look like: │ ● ┆ bt7274 ┆ ...
if "" not in line:
continue
parts = [p.strip().lstrip("").lstrip("").strip() for p in line.split("")]
if len(parts) < 2:
continue
tag = parts[1].strip()
# skip header
if tag.lower() == "tag" or not tag:
continue
# skip non-tag chars
if re.fullmatch(r"[a-z0-9._-]+", tag, re.IGNORECASE):
tags.append(tag)
return tags
async def create(
tag: str,
name: str,
cart_type: str = "companion",
tagline: str = "",
) -> bool:
"""Create a marauder cart. Idempotent — no-ops if tag already exists."""
if await exists(tag):
log.info("cart %r already exists, skipping create", tag)
return True
args = ["marauder", "cart", "create", tag, "--name", name, "--type", cart_type]
if tagline:
args.extend(["--tagline", tagline])
code, out, err = await _run(*args)
if code != 0:
log.error("cart create failed for %r: %s", tag, err[:300])
return False
log.info("created marauder cart %r (name=%s, type=%s)", tag, name, cart_type)
return True
async def use(tag: str) -> bool:
"""Switch the global active persona to this tag."""
code, _, err = await _run("marauder", "cart", "use", tag)
if code != 0:
log.warning("cart use %r failed: %s", tag, err[:200])
return False
return True
-174
View File
@@ -1,174 +0,0 @@
"""Subprocess wrapper around `marauder memory` CLI.
Provides recall + store. Marauder's memory CLI returns table output by default
and may or may not honour --json depending on subcommand. We parse what we get.
Shared EEMS namespace: chat.saiden.dev reads and writes the same memories
BT-on-CLI uses. One Pilot, one memory.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
log = logging.getLogger("chat-saiden.memory")
@dataclass
class Memory:
id: int | None
subject: str
content: str
classification: str = "standard"
async def _run(*args: str, timeout: float = 10.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
return 124, "", "timeout"
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
def _try_json(text: str) -> Any:
"""Try to extract JSON from output that might be mixed with log lines."""
if not text:
return None
# try the whole thing first
try:
return json.loads(text)
except Exception:
pass
# find JSON object/array boundaries
for opener, closer in [("{", "}"), ("[", "]")]:
first = text.find(opener)
last = text.rfind(closer)
if first != -1 and last > first:
try:
return json.loads(text[first:last + 1])
except Exception:
continue
return None
# Header line: "#3933 (0.8690) user.identity.nco-preference-..."
_RECALL_HEADER = re.compile(r"^#(\d+)\s+\(([\d.]+)\)\s+(\S.*)$")
async def recall(query: str, limit: int = 5, subject: str | None = None) -> list[Memory]:
"""Semantic recall. Returns up to `limit` memories ordered by similarity.
`--json` is documented but not implemented for `marauder memory recall` in
current builds, so we parse the table-ish text format instead.
"""
args = ["marauder", "memory", "recall", query, "--limit", str(limit)]
if subject:
args.extend(["--subject", subject])
code, out, err = await _run(*args)
if code != 0:
log.warning("memory recall failed (rc=%s): %s", code, err[:200])
return []
memories: list[Memory] = []
current: Memory | None = None
body_lines: list[str] = []
def flush():
nonlocal current, body_lines
if current is not None:
current.content = "\n".join(body_lines).strip()
memories.append(current)
current = None
body_lines = []
for raw in out.splitlines():
line = raw.rstrip()
# skip embedding/sqlite log lines (ISO timestamps from tracing)
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", line):
continue
m = _RECALL_HEADER.match(line)
if m:
flush()
current = Memory(id=int(m.group(1)), subject=m.group(3).strip(), content="")
continue
if current is not None and line.strip():
body_lines.append(line.strip())
elif current is not None and not line.strip() and body_lines:
# blank line within body — keep separator
body_lines.append("")
flush()
return memories
_STORE_RX = re.compile(r"Stored memory #(\d+)")
async def store(subject: str, content: str, classification: str | None = None) -> int | None:
"""Store a memory. Returns memory ID on success.
Output is plain text 'Stored memory #NNNN ...'; we regex it."""
args = ["marauder", "memory", "store", subject, content]
if classification:
args.extend(["--classification", classification])
code, out, err = await _run(*args, timeout=20.0)
if code != 0:
log.warning("memory store failed (rc=%s): %s", code, err[:200])
return None
m = _STORE_RX.search(out + " " + err)
if m:
return int(m.group(1))
log.debug("memory store output: %r / %r", out[:200], err[:200])
return None
# ----------------------------------------------------------------- context shaping
async def operator_context(operator_email: str, persona_name: str) -> str:
"""Pull a tight context block of memories relevant to the operator. Used to
seed the system prompt at session start so the cart speaks with continuity."""
queries: list[tuple[str, str | None]] = [
# who the operator is
("operator preferences and self-description", "self"),
# what they're working on
(f"recent {persona_name} interactions and projects", None),
# active doctrine that affects how the cart should behave
("doctrine that shapes how I talk to the pilot", "doctrine"),
]
# Fire all recalls in parallel — each is a separate marauder subprocess
results = await asyncio.gather(
*[recall(q, limit=3, subject=subj) for q, subj in queries],
return_exceptions=True,
)
blocks: list[str] = []
for (q, _), memories in zip(queries, results):
if isinstance(memories, Exception):
continue
for m in memories:
if not m.content:
continue
blocks.append(f"— ({m.subject}) {m.content.strip()[:600]}")
if not blocks:
return ""
return (
"\n\n## Pilot context (recalled from EEMS)\n"
"Use these as background only. Don't recite. Refer naturally if useful.\n\n"
+ "\n".join(blocks[:8]) # cap so the prompt doesn't bloat
)
+21 -2
View File
@@ -21,9 +21,22 @@ let ws = null;
let connectAttempts = 0;
let lastSpeaker = null; // 'user' | 'bt' | 'system' | null
let currentBtBody = null; // active streaming .msg__body element
let currentBtRaw = ''; // raw markdown accumulator for streaming
let queue = [];
let draining = false;
// Configure marked for safe markdown rendering
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true });
}
function renderMarkdown(raw) {
if (typeof marked === 'undefined') return raw;
const html = marked.parse(raw);
if (typeof DOMPurify !== 'undefined') return DOMPurify.sanitize(html);
return html;
}
// ---------- helpers ----------
function speakerLabel(role) {
@@ -97,7 +110,9 @@ async function drain() {
if (!currentBtBody) return;
draining = true;
while (queue.length) {
currentBtBody.textContent += queue.shift();
currentBtRaw += queue.shift();
// Re-render markdown on each char (marked.parse is fast enough)
currentBtBody.innerHTML = renderMarkdown(currentBtRaw);
if (Math.random() < 0.04) scrollToBottom();
await sleep(TYPEWRITER_MS);
}
@@ -107,16 +122,19 @@ async function drain() {
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function finishBt() {
// wait for the queue to drain before adding caret
// wait for the queue to drain before final render
const tick = () => {
if (draining || queue.length) { setTimeout(tick, 30); return; }
if (!currentBtBody) return;
// Final markdown render with complete text
currentBtBody.innerHTML = renderMarkdown(currentBtRaw);
const caret = document.createElement('span');
caret.className = 'caret';
currentBtBody.appendChild(caret);
scrollToBottom();
setTimeout(() => caret.remove(), 900);
currentBtBody = null;
currentBtRaw = '';
};
tick();
}
@@ -188,6 +206,7 @@ function handleMessage(msg) {
if (!currentBtBody) {
removeThinking();
currentBtBody = makeMsg('bt');
currentBtRaw = '';
}
if (msg.delta) enqueue(msg.delta);
if (msg.done) finishBt();
+2
View File
@@ -14,6 +14,8 @@
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Caveat:wght@400;500&family=Inter:wght@300;400;500;600&family=Source+Serif+Pro:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="/static/chat.css">
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
</head>
<body
data-palette="{{ ui_palette }}"
+2 -1
View File
@@ -7,12 +7,13 @@ dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"websockets>=13",
"anthropic>=0.40",
"authlib>=1.3",
"itsdangerous>=2.2", # session cookie signing
"httpx>=0.27", # for authlib OAuth
"jinja2>=3.1", # template rendering
"python-multipart>=0.0.12", # form parsing
"markdown-it-py>=4.2.0",
]
[tool.uv]
Generated
+23 -138
View File
@@ -20,25 +20,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anthropic"
version = "0.101.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "docstring-parser" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/cb/9d0123243e749ac3a579972b2c398971bce1dc57bcc4efb08066df610360/anthropic-0.101.0.tar.gz", hash = "sha256:1116a6a87c55757e0fbe3e1ba40804fbd04de7963601a6dd6b539a889f18de3e", size = 758603, upload-time = "2026-05-11T15:46:33.944Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/b2/74ff06762d005ecf1658929a292df0acb786d025f6a6c54fcb30e2dc7761/anthropic-0.101.0-py3-none-any.whl", hash = "sha256:cc3cc6576989471e2aa9132258034ad0ff0d8fe500b04ac499e4e46ed68c5ed0", size = 753594, upload-time = "2026-05-11T15:46:32.216Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
@@ -149,12 +130,12 @@ name = "chat-saiden"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anthropic" },
{ name = "authlib" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markdown-it-py" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
@@ -167,12 +148,12 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.40" },
{ name = "authlib", specifier = ">=1.3" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "itsdangerous", specifier = ">=2.2" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "markdown-it-py", specifier = ">=4.2.0" },
{ name = "python-multipart", specifier = ">=0.0.12" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
{ name = "websockets", specifier = ">=13" },
@@ -261,24 +242,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "docstring-parser"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
@@ -398,96 +361,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jiter"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" },
{ url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" },
{ url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" },
{ url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" },
{ url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" },
{ url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" },
{ url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" },
{ url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" },
{ url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" },
{ url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" },
{ url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" },
{ url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" },
{ url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" },
{ url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" },
{ url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" },
{ url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" },
{ url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" },
{ url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" },
{ url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" },
{ url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" },
{ url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" },
{ url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" },
{ url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" },
{ url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" },
{ url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" },
{ url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" },
{ url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" },
{ url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" },
{ url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" },
{ url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" },
{ url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" },
{ url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" },
{ url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" },
{ url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" },
{ url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" },
{ url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" },
{ url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" },
{ url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" },
{ url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" },
{ url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" },
{ url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" },
{ url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" },
{ url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" },
{ url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" },
{ url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" },
{ url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" },
{ url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" },
{ url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" },
{ url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" },
{ url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" },
{ url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" },
{ url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" },
{ url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" },
{ url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" },
{ url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" },
{ url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" },
{ url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" },
{ url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" },
]
[[package]]
name = "joserfc"
version = "1.6.5"
@@ -500,6 +373,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -574,6 +459,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -798,15 +692,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"