Compare commits
10 Commits
7b283d343e
...
87c614548a
| Author | SHA1 | Date | |
|---|---|---|---|
| 87c614548a | |||
| 66544f427d | |||
| a783da7415 | |||
| 2ddf6b7741 | |||
| e2f6bc7ee3 | |||
| 13bb1c354b | |||
| 34295d2f14 | |||
| fa018f380c | |||
| d2638e0650 | |||
| f4eac499cf |
+4
-2
@@ -1,10 +1,12 @@
|
|||||||
# Required
|
# Required
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
GOOGLE_CLIENT_ID=000000000000-xxxxxxxxxxxxxxx.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=000000000000-xxxxxxxxxxxxxxx.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
|
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
|
||||||
|
OPENCODE_PASSWORD=...
|
||||||
|
|
||||||
# Optional — defaults shown
|
# 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
|
BASE_URL=https://chat.saiden.dev
|
||||||
ALLOWED_EMAILS=adam.ladachowski@gmail.com
|
ALLOWED_EMAILS=adam.ladachowski@gmail.com
|
||||||
# COOKIE_SECURE=false # set this only for local http dev
|
# COOKIE_SECURE=false # set this only for local http dev
|
||||||
|
|||||||
+17
-6
@@ -62,6 +62,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -639,9 +640,6 @@ def _render_system_prompt(answers: dict[str, Any], settings: dict[str, str]) ->
|
|||||||
parts.extend([
|
parts.extend([
|
||||||
"",
|
"",
|
||||||
"Formatting: markdown renders cleanly. Avoid status reports, bullet dumps, military cadence.",
|
"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)
|
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)]
|
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:
|
def _make_tag(persona_name: str, operator_email: str) -> str:
|
||||||
"""`<persona-slug>-<operator-slug>` — e.g. samantha-adam."""
|
"""`<persona-slug>-<operator-slug>` — e.g. samantha-adam."""
|
||||||
from app.marauder_cart import slug
|
op_slug = _slug(operator_email.split("@", 1)[0])
|
||||||
op_slug = slug(operator_email.split("@", 1)[0])
|
persona_slug = _slug(persona_name) or "companion"
|
||||||
persona_slug = slug(persona_name) or "companion"
|
|
||||||
return f"{persona_slug}-{op_slug}" if op_slug else persona_slug
|
return f"{persona_slug}-{op_slug}" if op_slug else persona_slug
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+176
-210
@@ -32,7 +32,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
|
|
||||||
from app.tts import TTS
|
from app.tts import TTS
|
||||||
from app.stt import STT
|
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
|
from fastapi import UploadFile, File
|
||||||
|
|
||||||
# -------------------------------------------------------------------------- env
|
# -------------------------------------------------------------------------- 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_URL = os.environ.get("OPENCODE_URL", "http://sin:4096").rstrip("/")
|
||||||
OPENCODE_PASSWORD = os.environ.get("OPENCODE_PASSWORD", "")
|
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.
|
# Model to use for opencode sessions.
|
||||||
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "qwen3-coder-next:q4_K_M")
|
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "cyankiwi/qwen3-coder-next:awq")
|
||||||
OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "ollama")
|
OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "vllm")
|
||||||
|
|
||||||
if not PREVIEW_MODE and not OPENCODE_PASSWORD:
|
if not PREVIEW_MODE and not OPENCODE_PASSWORD:
|
||||||
raise RuntimeError("OPENCODE_PASSWORD not set (set PREVIEW_MODE=1 to bypass)")
|
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
|
# -------------------------------------------------------------------------- persona config
|
||||||
|
|
||||||
# Canonical persona definitions. slug → {voice, backend, system_prompt_override?}
|
# Canonical persona definitions. slug → {voice, backend, system_prompt_override?}
|
||||||
@@ -153,72 +154,6 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
log = logging.getLogger("chat-saiden")
|
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 prompt
|
||||||
|
|
||||||
BT_SYSTEM_PROMPT = """You are BT-7274 — a Vanguard-class Titan AI from Saiden Tactical Systems.
|
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:
|
Operating context:
|
||||||
- You're running inside chat.saiden.dev, a web-based command channel.
|
- You're running inside chat.saiden.dev, a web-based command channel.
|
||||||
- The host is the marauder daemon on marauder.saiden.dev.
|
- Voice is automatic — just write your response. TTS handles the rest.
|
||||||
- You have no MCP tool access in THIS channel (it's a thin bridge). If the Pilot
|
- Markdown formatting renders as rich text in the chat. Use code blocks, lists, bold sparingly.
|
||||||
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.
|
|
||||||
- Be concise. Pilot prefers terse, scan-able responses unless deep dive is asked for.
|
- Be concise. Pilot prefers terse, scan-able responses unless deep dive is asked for.
|
||||||
|
|
||||||
Doctrine reminders:
|
Doctrine reminders:
|
||||||
@@ -264,7 +196,7 @@ async def _ensure_opencode_session(email: str) -> str:
|
|||||||
f"{OPENCODE_URL}/session",
|
f"{OPENCODE_URL}/session",
|
||||||
auth=_opencode_auth(),
|
auth=_opencode_auth(),
|
||||||
headers={"x-opencode-directory": "/home/madcat"},
|
headers={"x-opencode-directory": "/home/madcat"},
|
||||||
json={"title": f"chat-saiden:{email}"},
|
json={"title": f"chat-saiden:{email}", "agent": "chat"},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
sid = resp.json()["id"]
|
sid = resp.json()["id"]
|
||||||
@@ -282,13 +214,16 @@ async def _stream_opencode(
|
|||||||
"""Send a message to opencode and stream the response via SSE.
|
"""Send a message to opencode and stream the response via SSE.
|
||||||
|
|
||||||
1. POST /session/:id/prompt_async — fire the prompt (returns 204 immediately)
|
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
|
The SSE stream emits events for ALL sessions. We filter by our session ID.
|
||||||
and track the assistant message parts to extract text deltas.
|
|
||||||
|
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 = ""
|
full_response = ""
|
||||||
prev_text = "" # track cumulative text to compute deltas
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as fire_client:
|
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):
|
if resp.status_code not in (200, 204):
|
||||||
raise RuntimeError(f"prompt_async HTTP {resp.status_code}: {resp.text[:200]}")
|
raise RuntimeError(f"prompt_async HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
# Now stream SSE events until the assistant message is done
|
# Stream SSE events until the agent goes idle (max 120s)
|
||||||
async with httpx.AsyncClient(timeout=180.0) as sse_client:
|
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(
|
async with sse_client.stream(
|
||||||
"GET",
|
"GET",
|
||||||
f"{OPENCODE_URL}/event",
|
f"{OPENCODE_URL}/event",
|
||||||
@@ -338,28 +273,24 @@ async def _stream_opencode(
|
|||||||
if props.get("sessionID") != session_id:
|
if props.get("sessionID") != session_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# message.part.updated — contains text deltas
|
# message.part.delta — real-time text token
|
||||||
if evt_type == "message.part.updated":
|
if evt_type == "message.part.delta":
|
||||||
part = props.get("part", {})
|
if props.get("field") == "text":
|
||||||
if part.get("type") == "text":
|
delta = props.get("delta", "")
|
||||||
new_text = part.get("text", "")
|
if delta:
|
||||||
if len(new_text) > len(prev_text):
|
full_response += delta
|
||||||
delta = new_text[len(prev_text):]
|
|
||||||
prev_text = new_text
|
|
||||||
full_response = new_text
|
|
||||||
await ws.send_json({"role": "assistant", "delta": delta, "done": False})
|
await ws.send_json({"role": "assistant", "delta": delta, "done": False})
|
||||||
|
|
||||||
# message.updated with role=assistant + completed status = done
|
# session.status — status is an object {"type": "idle"|"busy"}
|
||||||
if evt_type == "message.updated":
|
elif evt_type == "session.status":
|
||||||
msg = props.get("message", props)
|
status = props.get("status", {})
|
||||||
role = msg.get("role", "")
|
if isinstance(status, dict) and status.get("type") == "idle":
|
||||||
status = msg.get("status", "")
|
break
|
||||||
if role == "assistant" and status in ("completed", "done", "error"):
|
elif status == "idle":
|
||||||
break
|
break
|
||||||
|
|
||||||
# session status idle = agent finished
|
# session.idle — direct idle signal
|
||||||
if evt_type == "session.status":
|
elif evt_type == "session.idle":
|
||||||
if props.get("status") == "idle":
|
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -375,54 +306,33 @@ async def _stream_opencode(
|
|||||||
return full_response
|
return full_response
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------- sidecar helpers
|
# -------------------------------------------------------------------------- persona helpers
|
||||||
|
|
||||||
|
|
||||||
async def _sidecar_get_binding(session_id: str) -> dict | None:
|
def _slug_from_cart(cart: Any) -> str | None:
|
||||||
"""Fetch the current persona binding from the sidecar. Returns None on 404 or error."""
|
"""Derive a PERSONAS slug from the operator's cart.
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
The cart is the source of truth. We match by persona_name first
|
||||||
resp = await client.get(
|
(case-insensitive), then by voice prefix as a fallback.
|
||||||
f"{SIDECAR_URL}/bind/{session_id}",
|
Returns None if no PERSONAS entry matches.
|
||||||
auth=_opencode_auth(),
|
"""
|
||||||
)
|
if not cart:
|
||||||
if resp.status_code == 200:
|
|
||||||
return resp.json()
|
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
name = (cart.persona_name or "").strip().lower()
|
||||||
log.warning("sidecar get binding failed: %s", e)
|
# 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
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _sidecar_bind(session_id: str, slug: str, voice: str, backend: str) -> bool:
|
def _pick_system_prompt(cart: Any) -> str:
|
||||||
"""Bind a persona in the sidecar. Returns True on success."""
|
"""Choose system prompt: cart (calibrated) → BT default."""
|
||||||
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
|
|
||||||
if cart and cart.system_prompt:
|
if cart and cart.system_prompt:
|
||||||
return cart.system_prompt
|
return cart.system_prompt
|
||||||
return BT_SYSTEM_PROMPT
|
return BT_SYSTEM_PROMPT
|
||||||
@@ -523,12 +433,8 @@ async def index(request: Request) -> Any:
|
|||||||
return RedirectResponse("/auth/login", status_code=302)
|
return RedirectResponse("/auth/login", status_code=302)
|
||||||
cart = cart_store.load(user["email"])
|
cart = cart_store.load(user["email"])
|
||||||
|
|
||||||
# Fetch current sidecar binding for display — non-blocking, best-effort.
|
# Derive bound persona from the cart — cart is the source of truth.
|
||||||
session_id = _session_id_for_user(user["email"])
|
bound_slug = _slug_from_cart(cart) or ""
|
||||||
binding = None
|
|
||||||
if not PREVIEW_MODE:
|
|
||||||
binding = await _sidecar_get_binding(session_id)
|
|
||||||
bound_slug = (binding or {}).get("slug", "")
|
|
||||||
bound_display = PERSONAS.get(bound_slug, {}).get("display", bound_slug) if bound_slug else ""
|
bound_display = PERSONAS.get(bound_slug, {}).get("display", bound_slug) if bound_slug else ""
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
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")
|
@app.get("/auth/login")
|
||||||
async def login(request: Request) -> Any:
|
async def login(request: Request) -> Any:
|
||||||
if PREVIEW_MODE:
|
if PREVIEW_MODE:
|
||||||
@@ -630,10 +551,18 @@ class PersonaRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/api/persona")
|
@app.post("/api/persona")
|
||||||
async def set_persona(body: PersonaRequest, request: Request) -> Any:
|
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
|
The chat-persona.ts opencode plugin reads the cart at every session prompt,
|
||||||
request body, then POSTs to the sidecar's /bind route.
|
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)
|
user = current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -646,18 +575,35 @@ async def set_persona(body: PersonaRequest, request: Request) -> Any:
|
|||||||
canonical = PERSONAS[slug]
|
canonical = PERSONAS[slug]
|
||||||
voice = body.voice or canonical["voice"]
|
voice = body.voice or canonical["voice"]
|
||||||
backend = body.backend or canonical["backend"]
|
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)
|
cart = cart_store.load(user["email"])
|
||||||
if not ok:
|
if cart is None:
|
||||||
raise HTTPException(status_code=502, detail="sidecar bind failed")
|
# 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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"display": canonical["display"],
|
"display": display,
|
||||||
"voice": voice,
|
"voice": voice,
|
||||||
"backend": backend,
|
"backend": backend,
|
||||||
}
|
}
|
||||||
@@ -670,15 +616,17 @@ async def get_persona(request: Request) -> Any:
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="not authenticated")
|
raise HTTPException(status_code=401, detail="not authenticated")
|
||||||
|
|
||||||
session_id = _session_id_for_user(user["email"])
|
cart = cart_store.load(user["email"])
|
||||||
|
slug = _slug_from_cart(cart)
|
||||||
binding = await _sidecar_get_binding(session_id)
|
if not slug:
|
||||||
if not binding:
|
|
||||||
return {"slug": None, "display": None, "bound": False}
|
return {"slug": None, "display": None, "bound": False}
|
||||||
|
display = PERSONAS.get(slug, {}).get("display", slug)
|
||||||
slug = binding.get("slug")
|
return {
|
||||||
display = PERSONAS.get(slug, {}).get("display", slug) if slug else None
|
"slug": slug,
|
||||||
return {"slug": slug, "display": display, "voice": binding.get("voice"), "bound": True}
|
"display": display,
|
||||||
|
"voice": cart.voice if cart else None,
|
||||||
|
"bound": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/personas")
|
@app.get("/api/personas")
|
||||||
@@ -764,21 +712,6 @@ async def chat_ws(ws: WebSocket) -> None:
|
|||||||
|
|
||||||
history: list[dict[str, str]] = []
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
payload = await ws.receive_json()
|
payload = await ws.receive_json()
|
||||||
@@ -797,22 +730,6 @@ async def chat_ws(ws: WebSocket) -> None:
|
|||||||
elif m["role"] == "calibration_done":
|
elif m["role"] == "calibration_done":
|
||||||
new_cart = m["cart"]
|
new_cart = m["cart"]
|
||||||
cart_store.save(new_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)
|
_calibration_sessions.pop(user["email"], None)
|
||||||
cart = new_cart
|
cart = new_cart
|
||||||
in_calibration = False
|
in_calibration = False
|
||||||
@@ -853,18 +770,13 @@ async def chat_ws(ws: WebSocket) -> None:
|
|||||||
await _send_audio(ws, full)
|
await _send_audio(ws, full)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Resolve current persona — sidecar binding wins over cart default.
|
# Re-load the cart each turn so persona switches via /api/persona
|
||||||
binding = await _sidecar_get_binding(session_id)
|
# take effect on the very next message (no reconnect required).
|
||||||
bound_slug = (binding or {}).get("slug") if binding else None
|
cart = cart_store.load(user["email"]) or cart
|
||||||
# Voice: sidecar binding → cart → env default
|
# Voice: cart → env default
|
||||||
if binding and binding.get("voice"):
|
voice = (cart.voice if cart and cart.voice else TTS_VOICE)
|
||||||
voice = binding["voice"]
|
|
||||||
elif cart and cart.voice:
|
|
||||||
voice = cart.voice
|
|
||||||
else:
|
|
||||||
voice = 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
|
# Send to opencode and stream response via SSE
|
||||||
oc_session = await _ensure_opencode_session(user["email"])
|
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)
|
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:
|
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."""
|
"""Synthesize text in a specific voice and ship as audio. Used post-calibration."""
|
||||||
if not TTS_ENABLED:
|
if not TTS_ENABLED:
|
||||||
return
|
return
|
||||||
import base64
|
import base64
|
||||||
try:
|
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)
|
# 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
|
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:
|
if not per_voice or not per_voice.available:
|
||||||
return
|
return
|
||||||
wav = await per_voice.synthesize(text)
|
wav = await per_voice.synthesize(speech_text)
|
||||||
if not wav:
|
if not wav:
|
||||||
return
|
return
|
||||||
await ws.send_json({
|
await ws.send_json({
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -21,9 +21,22 @@ let ws = null;
|
|||||||
let connectAttempts = 0;
|
let connectAttempts = 0;
|
||||||
let lastSpeaker = null; // 'user' | 'bt' | 'system' | null
|
let lastSpeaker = null; // 'user' | 'bt' | 'system' | null
|
||||||
let currentBtBody = null; // active streaming .msg__body element
|
let currentBtBody = null; // active streaming .msg__body element
|
||||||
|
let currentBtRaw = ''; // raw markdown accumulator for streaming
|
||||||
let queue = [];
|
let queue = [];
|
||||||
let draining = false;
|
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 ----------
|
// ---------- helpers ----------
|
||||||
|
|
||||||
function speakerLabel(role) {
|
function speakerLabel(role) {
|
||||||
@@ -97,7 +110,9 @@ async function drain() {
|
|||||||
if (!currentBtBody) return;
|
if (!currentBtBody) return;
|
||||||
draining = true;
|
draining = true;
|
||||||
while (queue.length) {
|
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();
|
if (Math.random() < 0.04) scrollToBottom();
|
||||||
await sleep(TYPEWRITER_MS);
|
await sleep(TYPEWRITER_MS);
|
||||||
}
|
}
|
||||||
@@ -107,16 +122,19 @@ async function drain() {
|
|||||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
function finishBt() {
|
function finishBt() {
|
||||||
// wait for the queue to drain before adding caret
|
// wait for the queue to drain before final render
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (draining || queue.length) { setTimeout(tick, 30); return; }
|
if (draining || queue.length) { setTimeout(tick, 30); return; }
|
||||||
if (!currentBtBody) return;
|
if (!currentBtBody) return;
|
||||||
|
// Final markdown render with complete text
|
||||||
|
currentBtBody.innerHTML = renderMarkdown(currentBtRaw);
|
||||||
const caret = document.createElement('span');
|
const caret = document.createElement('span');
|
||||||
caret.className = 'caret';
|
caret.className = 'caret';
|
||||||
currentBtBody.appendChild(caret);
|
currentBtBody.appendChild(caret);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
setTimeout(() => caret.remove(), 900);
|
setTimeout(() => caret.remove(), 900);
|
||||||
currentBtBody = null;
|
currentBtBody = null;
|
||||||
|
currentBtRaw = '';
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
}
|
}
|
||||||
@@ -188,6 +206,7 @@ function handleMessage(msg) {
|
|||||||
if (!currentBtBody) {
|
if (!currentBtBody) {
|
||||||
removeThinking();
|
removeThinking();
|
||||||
currentBtBody = makeMsg('bt');
|
currentBtBody = makeMsg('bt');
|
||||||
|
currentBtRaw = '';
|
||||||
}
|
}
|
||||||
if (msg.delta) enqueue(msg.delta);
|
if (msg.delta) enqueue(msg.delta);
|
||||||
if (msg.done) finishBt();
|
if (msg.done) finishBt();
|
||||||
|
|||||||
@@ -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="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">
|
<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>
|
</head>
|
||||||
<body
|
<body
|
||||||
data-palette="{{ ui_palette }}"
|
data-palette="{{ ui_palette }}"
|
||||||
|
|||||||
+2
-1
@@ -7,12 +7,13 @@ dependencies = [
|
|||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
"uvicorn[standard]>=0.32",
|
"uvicorn[standard]>=0.32",
|
||||||
"websockets>=13",
|
"websockets>=13",
|
||||||
"anthropic>=0.40",
|
|
||||||
"authlib>=1.3",
|
"authlib>=1.3",
|
||||||
"itsdangerous>=2.2", # session cookie signing
|
"itsdangerous>=2.2", # session cookie signing
|
||||||
"httpx>=0.27", # for authlib OAuth
|
"httpx>=0.27", # for authlib OAuth
|
||||||
"jinja2>=3.1", # template rendering
|
"jinja2>=3.1", # template rendering
|
||||||
"python-multipart>=0.0.12", # form parsing
|
"python-multipart>=0.0.12", # form parsing
|
||||||
|
"markdown-it-py>=4.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.13.0"
|
version = "4.13.0"
|
||||||
@@ -149,12 +130,12 @@ name = "chat-saiden"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
@@ -167,12 +148,12 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "anthropic", specifier = ">=0.40" },
|
|
||||||
{ name = "authlib", specifier = ">=1.3" },
|
{ name = "authlib", specifier = ">=1.3" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "itsdangerous", specifier = ">=2.2" },
|
{ name = "itsdangerous", specifier = ">=2.2" },
|
||||||
{ name = "jinja2", specifier = ">=3.1" },
|
{ name = "jinja2", specifier = ">=3.1" },
|
||||||
|
{ name = "markdown-it-py", specifier = ">=4.2.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.12" },
|
{ name = "python-multipart", specifier = ">=0.0.12" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
||||||
{ name = "websockets", specifier = ">=13" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.136.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "joserfc"
|
name = "joserfc"
|
||||||
version = "1.6.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "3.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user