fix(backend): 修复全面审阅发现的核心问题

修复索引首次失败回滚、Vault 扫描边界、Markdown 代码围栏和 UTF-16 Citation 偏移。

收紧 Plugin 权限与 JSON Schema 校验,补齐 Note Move、Task、Attachment 和 Transcript Tool。

接入 OpenAI SSE 与 Ollama JSONL 真流式输出,修正 Provider PATCH 语义并限制运行时内存保留。

新增对应回归测试,后端测试增至 62 项。
This commit is contained in:
2026-08-28 09:56:07 +08:00
parent 167ae24796
commit 36bc1022f1
26 changed files with 1531 additions and 99 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
from app.agent.permissions import PermissionManager, PermissionMode, PermissionPolicy
from app.agent.runtime import AgentRuntime, AgentRunNotFoundError
from app.agent.runtime import AgentCapacityError, AgentRuntime, AgentRunNotFoundError
from app.agent.tools import ToolRegistry
__all__ = [
"AgentRunNotFoundError",
"AgentCapacityError",
"AgentRuntime",
"PermissionManager",
"PermissionMode",
+121 -1
View File
@@ -1,9 +1,12 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.agent.tools import ToolExecutionContext, ToolRegistry
from app.contracts import SearchMode, SearchRequest, ToolDefinition
from app.contracts import SearchMode, SearchRequest, TaskStatus, ToolDefinition
from app.retrieval.engine import engine
from app.services import note_service
from app.services import attachment_service, task_service, transcription_service
class ToolArguments(BaseModel):
@@ -54,6 +57,42 @@ class NoteListArguments(ToolArguments):
tag: str | None = None
class NoteMoveArguments(ToolArguments):
note_id: str = Field(min_length=1)
folder: str
class TaskCreateArguments(ToolArguments):
title: str = Field(min_length=1)
description: str = ""
note_id: str | None = None
due_at: datetime | None = None
class TaskUpdateArguments(ToolArguments):
task_id: str = Field(min_length=1)
title: str | None = None
description: str | None = None
status: TaskStatus | None = None
note_id: str | None = None
due_at: datetime | None = None
class TaskListArguments(ToolArguments):
limit: int = Field(default=50, ge=1, le=100)
offset: int = Field(default=0, ge=0)
class AttachmentReadArguments(ToolArguments):
attachment_id: str = Field(min_length=1)
max_chars: int = Field(default=100_000, ge=1, le=1_000_000)
class AudioTranscribeArguments(ToolArguments):
attachment_id: str = Field(min_length=1)
language: str | None = None
async def echo(arguments: EchoArguments, _: ToolExecutionContext) -> dict[str, str]:
return {"text": arguments.text}
@@ -94,6 +133,39 @@ def list_notes(arguments: NoteListArguments, _: ToolExecutionContext) -> dict:
}
async def move_note(arguments: NoteMoveArguments, _: ToolExecutionContext) -> dict:
note = await note_service.move_note(arguments.note_id, folder=arguments.folder)
return note.model_dump(mode="json")
def create_task(arguments: TaskCreateArguments, _: ToolExecutionContext) -> dict:
return task_service.create_task(**arguments.model_dump()).model_dump(mode="json")
def update_task(arguments: TaskUpdateArguments, _: ToolExecutionContext) -> dict:
values = arguments.model_dump(exclude_unset=True)
task_id = values.pop("task_id")
return task_service.update_task(task_id, values).model_dump(mode="json")
def list_tasks(arguments: TaskListArguments, _: ToolExecutionContext) -> dict:
items, total = task_service.list_tasks(**arguments.model_dump())
return {
"items": [item.model_dump(mode="json") for item in items],
"page": {"total": total, "limit": arguments.limit, "offset": arguments.offset},
}
def read_attachment(arguments: AttachmentReadArguments, _: ToolExecutionContext) -> dict:
return attachment_service.read_attachment(**arguments.model_dump())
def transcribe_audio(arguments: AudioTranscribeArguments, _: ToolExecutionContext) -> dict:
return transcription_service.create_transcription(
arguments.attachment_id, arguments.language
).model_dump(mode="json")
def _register(
registry: ToolRegistry,
*,
@@ -178,3 +250,51 @@ def register_builtin_tools(registry: ToolRegistry) -> None:
executor=list_notes,
permission="notes.read",
)
_register(
registry,
name="notes.move",
description="Move a note to another folder while preserving note_id.",
arguments_model=NoteMoveArguments,
executor=move_note,
permission="notes.write",
)
_register(
registry,
name="tasks.create",
description="Create a persistent task.",
arguments_model=TaskCreateArguments,
executor=create_task,
permission="tasks.write",
)
_register(
registry,
name="tasks.update",
description="Update a persistent task.",
arguments_model=TaskUpdateArguments,
executor=update_task,
permission="tasks.write",
)
_register(
registry,
name="tasks.list",
description="List persistent tasks.",
arguments_model=TaskListArguments,
executor=list_tasks,
permission="tasks.read",
)
_register(
registry,
name="attachments.read",
description="Read a UTF-8 attachment from host-managed attachment storage.",
arguments_model=AttachmentReadArguments,
executor=read_attachment,
permission="attachments.read",
)
_register(
registry,
name="audio.transcribe",
description="Read a host-generated transcript for an audio attachment.",
arguments_model=AudioTranscribeArguments,
executor=transcribe_audio,
permission="attachments.read",
)
+27 -1
View File
@@ -10,13 +10,39 @@ class PermissionMode(str, Enum):
deny = "deny"
KNOWN_PERMISSIONS = frozenset(
{
"notes.read",
"notes.search",
"notes.write",
"notes.delete",
"tasks.read",
"tasks.write",
"attachments.read",
"network.request",
"secrets.use",
"ui.command",
"ui.settings",
"ui.sidebar",
}
)
class PermissionPolicy:
def __init__(self) -> None:
self._rules: dict[str, PermissionMode] = {
"notes.read": PermissionMode.allow,
"notes.search": PermissionMode.allow,
"notes.delete": PermissionMode.confirm,
"notes.write": PermissionMode.confirm,
"tasks.read": PermissionMode.allow,
"tasks.write": PermissionMode.confirm,
"attachments.read": PermissionMode.allow,
"network.request": PermissionMode.confirm,
"secrets.use": PermissionMode.confirm,
"ui.command": PermissionMode.allow,
"ui.settings": PermissionMode.allow,
"ui.sidebar": PermissionMode.allow,
}
def set_rule(self, permission: str, mode: PermissionMode) -> None:
@@ -25,7 +51,7 @@ class PermissionPolicy:
def mode_for(self, permission: str | None) -> PermissionMode:
if permission is None:
return PermissionMode.allow
return self._rules.get(permission, PermissionMode.allow)
return self._rules.get(permission, PermissionMode.deny)
@dataclass(slots=True)
+34
View File
@@ -34,11 +34,18 @@ class AgentRunNotFoundError(LookupError):
pass
class AgentCapacityError(RuntimeError):
pass
TERMINAL_STATUSES = {
AgentRunStatus.completed,
AgentRunStatus.failed,
AgentRunStatus.cancelled,
}
MAX_RUN_RECORDS = 200
MAX_EVENTS_PER_RUN = 2_000
MAX_TOOL_CALLS_PER_TURN = 50
@dataclass(slots=True)
@@ -67,6 +74,7 @@ class AgentRuntime:
self._records: dict[str, RunRecord] = {}
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
self._prune_records()
provider = self.providers.get(request.provider_id)
skill_config = None
if request.skill_id:
@@ -217,6 +225,13 @@ class AgentRuntime:
return
if turn.tool_calls:
if len(turn.tool_calls) > MAX_TOOL_CALLS_PER_TURN:
self._fail(
record,
"TOO_MANY_TOOL_CALLS",
f"Provider requested more than {MAX_TOOL_CALLS_PER_TURN} tools in one turn.",
)
return
calls = [
ToolCall(
tool_call_id=item.tool_call_id,
@@ -393,6 +408,8 @@ class AgentRuntime:
timestamp=datetime.now(timezone.utc),
)
record.events.append(event)
if len(record.events) > MAX_EVENTS_PER_RUN:
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
for queue in record.subscribers:
queue.put_nowait(event)
@@ -429,3 +446,20 @@ class AgentRuntime:
return self._records[run_id]
except KeyError as exc:
raise AgentRunNotFoundError(run_id) from exc
def _prune_records(self) -> None:
overflow = len(self._records) - MAX_RUN_RECORDS + 1
if overflow <= 0:
return
terminal = sorted(
(
record
for record in self._records.values()
if record.run.status in TERMINAL_STATUSES
),
key=lambda record: record.run.updated_at,
)
for record in terminal[:overflow]:
self._records.pop(record.run.run_id, None)
if len(self._records) >= MAX_RUN_RECORDS:
raise AgentCapacityError("Too many active Agent runs.")
+4 -1
View File
@@ -4,6 +4,8 @@ from time import perf_counter
from typing import Any, Awaitable, Callable
from pydantic import BaseModel, ValidationError
from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
from app.contracts import ToolCall, ToolDefinition, ToolResult
@@ -78,8 +80,9 @@ class ToolRegistry:
)
try:
Draft202012Validator(registered.definition.parameters).validate(call.arguments)
arguments = registered.arguments_model.model_validate(call.arguments)
except ValidationError as exc:
except (ValidationError, JsonSchemaValidationError) as exc:
return ToolResult(
tool_call_id=call.tool_call_id,
name=call.name,
+4
View File
@@ -23,6 +23,7 @@ class Settings:
data_dir: Path
db_path: Path
vault_path: Path
attachments_path: Path
@lru_cache
@@ -37,4 +38,7 @@ def get_settings() -> Settings:
data_dir=data_dir,
db_path=Path(os.getenv("APP_DB_PATH", str(data_dir / "app.db"))),
vault_path=Path(os.getenv("APP_VAULT_PATH", str(data_dir / "vault"))),
attachments_path=Path(
os.getenv("APP_ATTACHMENTS_PATH", str(data_dir / "attachments"))
),
)
+8
View File
@@ -384,6 +384,7 @@ class Plugin(Contract):
manifest: PluginManifest
status: PluginStatus
enabled: bool = False
granted_permissions: list[str] = Field(default_factory=list)
error_message: str | None = None
@@ -391,6 +392,10 @@ class PluginListResponse(Contract):
items: list[Plugin] = Field(default_factory=list)
class PluginPermissionGrantRequest(Contract):
permissions: list[str] = Field(default_factory=list)
# Providers
class ProviderType(str, Enum):
mock = "mock"
@@ -506,6 +511,9 @@ class TranscriptionJob(Contract):
job_id: str
attachment_id: str
status: Literal["queued", "processing", "completed", "failed"]
text: str | None = None
error_code: str | None = None
error_message: str | None = None
created_at: datetime
+14
View File
@@ -55,6 +55,20 @@ MIGRATIONS: list[str] = [
value TEXT NOT NULL
);
""",
# v2: 第一阶段 Task Core;正文仍归 Note/Vault,任务状态持久化到 SQLite。
"""
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo',
note_id TEXT REFERENCES notes(note_id) ON DELETE SET NULL,
due_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_status_due ON tasks(status, due_at);
""",
]
+1
View File
@@ -17,6 +17,7 @@ class ApiError(Exception):
message: str,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.code = code
self.message = message
+76 -1
View File
@@ -6,9 +6,12 @@ from pathlib import Path
from typing import Any, Literal
import yaml
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
from app.agent.tools import ToolExecutionContext, ToolRegistry
from app.agent.permissions import KNOWN_PERMISSIONS
from app.contracts import (
ModelCapability,
Plugin,
@@ -73,6 +76,7 @@ class SkillRuntime:
except ValidationError as exc:
raise _manifest_error("skill", exc) from exc
_validate_id("skill", manifest.skill_id)
_validate_permissions("skill", manifest.permissions)
if manifest.skill_id in self._records:
raise ExtensionError(
"SKILL_ALREADY_INSTALLED",
@@ -257,6 +261,7 @@ class PluginRuntime:
except ValidationError as exc:
raise _manifest_error("plugin", exc) from exc
_validate_id("plugin", manifest.plugin_id)
_validate_permissions("plugin", manifest.permissions)
if manifest.plugin_id in self._records:
raise ExtensionError(
"PLUGIN_ALREADY_INSTALLED",
@@ -275,6 +280,7 @@ class PluginRuntime:
)
for spec in specs:
_validate_id("tool", spec.name)
_validate_tool_schema(spec)
if spec.permission and spec.permission not in manifest.permissions:
raise ExtensionError(
"PLUGIN_PERMISSION_UNDECLARED",
@@ -283,7 +289,14 @@ class PluginRuntime:
)
record = _PluginRecord(
plugin=Plugin(manifest=manifest, status=PluginStatus.installed),
plugin=Plugin(
manifest=manifest,
status=(
PluginStatus.permission_required
if manifest.permissions
else PluginStatus.installed
),
),
tools=specs,
package_path=root,
registered_tools=[],
@@ -309,6 +322,17 @@ class PluginRuntime:
status_code=501,
details={"plugin_id": plugin_id, "backend": "mcp"},
)
missing_grants = sorted(
set(record.plugin.manifest.permissions) - set(record.plugin.granted_permissions)
)
if missing_grants:
record.plugin.status = PluginStatus.permission_required
raise ExtensionError(
"PLUGIN_PERMISSION_REQUIRED",
"Plugin permissions must be granted before it can be enabled.",
status_code=409,
details={"plugin_id": plugin_id, "permissions": missing_grants},
)
conflicts = [spec.name for spec in record.tools if self.registry.contains(spec.name)]
if conflicts:
raise ExtensionError(
@@ -353,6 +377,27 @@ class PluginRuntime:
record.plugin.error_message = None
return record.plugin.model_copy(deep=True)
def set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin:
record = self._record(plugin_id)
requested = set(permissions)
declared = set(record.plugin.manifest.permissions)
undeclared = sorted(requested - declared)
if undeclared:
raise ExtensionError(
"PLUGIN_PERMISSION_UNDECLARED",
"Cannot grant permissions that are not declared by the Plugin.",
details={"plugin_id": plugin_id, "permissions": undeclared},
)
record.plugin.granted_permissions = sorted(requested)
missing = declared - requested
if missing and record.plugin.enabled:
self.disable(plugin_id)
if missing:
record.plugin.status = PluginStatus.permission_required
elif not record.plugin.enabled:
record.plugin.status = PluginStatus.installed
return record.plugin.model_copy(deep=True)
def disable(self, plugin_id: str) -> Plugin:
record = self._record(plugin_id)
for name in record.registered_tools:
@@ -429,6 +474,16 @@ def _validate_id(kind: str, value: str) -> None:
)
def _validate_permissions(kind: str, permissions: list[str]) -> None:
unknown = sorted(set(permissions) - KNOWN_PERMISSIONS)
if unknown:
raise ExtensionError(
"EXTENSION_PERMISSION_INVALID",
f"Invalid {kind} permissions: {', '.join(unknown)}",
details={"kind": kind, "permissions": unknown},
)
def _manifest_error(kind: str, exc: ValidationError) -> ExtensionError:
return ExtensionError(
"EXTENSION_MANIFEST_INVALID",
@@ -457,3 +512,23 @@ def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
fields[name] = (annotation, ... if name in required else None)
model_name = "PluginArgs_" + re.sub(r"\W+", "_", spec.name)
return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields)
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
schema = spec.parameters or {"type": "object", "properties": {}}
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
raise ExtensionError(
"PLUGIN_TOOL_SCHEMA_INVALID",
f"Invalid JSON Schema for tool {spec.name}: {exc.message}",
details={"tool": spec.name},
) from exc
if schema.get("type", "object") != "object" or not isinstance(
schema.get("properties", {}), dict
):
raise ExtensionError(
"PLUGIN_TOOL_SCHEMA_INVALID",
"Tool parameters must be an object schema with object properties.",
details={"tool": spec.name},
)
+27 -6
View File
@@ -18,6 +18,7 @@ from app.textutils import count_tokens
_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)\s*$")
_FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$")
_FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(?:[^`]*)$")
@dataclass
@@ -48,9 +49,10 @@ def parse_note(
tags: list[str] | None = None,
created_at: datetime,
updated_at: datetime,
note_id: str | None = None,
) -> ParsedNote:
"""解析一篇 Markdown,生成 ParsedNote(元数据 + Block 列表)。"""
note_id = note_id_for_path(file_path)
note_id = note_id or note_id_for_path(file_path)
frontmatter = _extract_frontmatter(markdown)
fallback_title = Path(file_path).stem
@@ -79,13 +81,14 @@ def parse_blocks(markdown: str, note_id: str) -> list[NoteBlock]:
heading_stack: list[str] = []
body: list[tuple[str, int]] = []
id_counters: dict[str, int] = {}
fence_marker: str | None = None
def make_block(path: list[str], chunk: list[tuple[str, int]]) -> None:
if not chunk:
return
content = "\n".join(line for line, _ in chunk)
start = chunk[0][1]
end = chunk[-1][1] + len(chunk[-1][0])
end = chunk[-1][1] + _utf16_len(chunk[-1][0])
block_id = _stable_block_id(note_id, path, content, id_counters)
blocks.append(
NoteBlock(
@@ -109,6 +112,20 @@ def parse_blocks(markdown: str, note_id: str) -> list[NoteBlock]:
if offset < content_start:
continue # 跳过 frontmatter 区域,但保留 offset 准确性
fence = _FENCE_RE.match(line)
if fence_marker is not None:
body.append((line, offset))
marker = fence.group(1) if fence else ""
if marker.startswith(fence_marker[0]) and len(marker) >= len(fence_marker):
fence_marker = None
flush_body()
continue
if fence:
flush_body()
fence_marker = fence.group(1)
body.append((line, offset))
continue
heading = _HEADING_RE.match(line)
if heading:
flush_body()
@@ -138,7 +155,7 @@ def _stable_block_id(note_id: str, path: list[str], content: str, counters: dict
def _split_lines(text: str) -> list[tuple[str, int]]:
"""按行拆分并记录每行在原文中的起始字符偏移"""
"""按行拆分并记录 UTF-16 code unit 偏移,直接兼容浏览器编辑器"""
result: list[tuple[str, int]] = []
start = 0
for raw in text.splitlines(keepends=True):
@@ -148,19 +165,23 @@ def _split_lines(text: str) -> list[tuple[str, int]]:
elif line.endswith("\n") or line.endswith("\r"):
line = line[:-1]
result.append((line, start))
start += len(raw)
start += _utf16_len(raw)
return result
def _content_start(markdown: str) -> int:
"""返回正文起始偏移:有 frontmatter 时跳过 --- 分隔块,否则为 0"""
"""返回正文起始 UTF-16 偏移:有 frontmatter 时跳过 --- 分隔块。"""
if markdown.startswith("---"):
end = markdown.find("\n---", 3)
if end != -1:
return end + 4
return _utf16_len(markdown[: end + 4])
return 0
def _utf16_len(text: str) -> int:
return len(text.encode("utf-16-le")) // 2
def _extract_frontmatter(markdown: str) -> dict[str, str]:
"""极简 frontmatter 解析,只提取 key: value 行。"""
if not markdown.startswith("---"):
+113 -1
View File
@@ -1,8 +1,11 @@
from uuid import uuid4
import json
from collections.abc import AsyncIterator
from datetime import datetime, timezone
import httpx
from app.contracts import ModelCapability, ModelInfo, ModelRequest
from app.contracts import ModelCapability, ModelEvent, ModelEventType, ModelInfo, ModelRequest
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
@@ -86,6 +89,115 @@ class OllamaProvider(TurnStreamingMixin):
if isinstance(item, dict) and item.get("name")
]
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
payload = self._chat_payload(request, stream=True)
sequence = 0
def event(kind: ModelEventType, data: dict | None = None) -> ModelEvent:
nonlocal sequence
item = ModelEvent(
event=kind, sequence=sequence, data=data or {},
timestamp=datetime.now(timezone.utc),
)
sequence += 1
return item
try:
async for data in self._stream_json(payload):
message = data.get("message") or {}
if message.get("thinking"):
yield event(ModelEventType.thinking_delta, {"text": message["thinking"]})
if message.get("content"):
yield event(ModelEventType.text_delta, {"text": message["content"]})
for raw_call in message.get("tool_calls") or []:
function = raw_call.get("function") or {}
call_id = raw_call.get("id") or f"call_{uuid4().hex}"
yield event(
ModelEventType.tool_call_start,
{"tool_call_id": call_id, "name": function.get("name") or ""},
)
yield event(
ModelEventType.tool_call_delta,
{
"tool_call_id": call_id,
"arguments_delta": json.dumps(
function.get("arguments") or {}, ensure_ascii=False
),
},
)
yield event(ModelEventType.tool_call_end, {"tool_call_id": call_id})
if data.get("done"):
yield event(
ModelEventType.usage,
{
"input_tokens": int(data.get("prompt_eval_count") or 0),
"output_tokens": int(data.get("eval_count") or 0),
},
)
yield event(ModelEventType.done)
except ProviderError as exc:
yield event(ModelEventType.error, {"code": exc.code, "message": exc.message})
yield event(ModelEventType.done)
def _chat_payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
messages = []
if request.system:
messages.append({"role": "system", "content": request.system})
for message in request.messages:
item: dict[str, object] = {"role": message.role.value, "content": message.content}
if message.tool_calls:
item["tool_calls"] = [
{"function": {"name": call.name, "arguments": call.arguments}}
for call in message.tool_calls
]
messages.append(item)
payload: dict[str, object] = {
"model": request.model, "messages": messages, "stream": stream
}
if request.tools:
payload["tools"] = [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
for tool in request.tools
]
return payload
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
try:
async with httpx.AsyncClient(
timeout=self.timeout_seconds, transport=self.transport
) as client:
async with client.stream(
"POST", f"{self.base_url}/api/chat", json=payload
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.strip():
continue
try:
data = json.loads(line)
except json.JSONDecodeError as exc:
raise ProviderError(
"PROVIDER_INVALID_RESPONSE", "Ollama returned invalid JSONL."
) from exc
if isinstance(data, dict):
yield data
except httpx.TimeoutException as exc:
raise ProviderError("PROVIDER_TIMEOUT", "Ollama request timed out.") from exc
except httpx.HTTPStatusError as exc:
raise ProviderError(
"MODEL_NOT_FOUND" if exc.response.status_code == 404 else "PROVIDER_UNAVAILABLE",
f"Ollama returned HTTP {exc.response.status_code}.",
) from exc
except httpx.HTTPError as exc:
raise ProviderError("PROVIDER_UNAVAILABLE", "Ollama is unavailable.") from exc
async def test_connection(self, model: str | None = None) -> tuple[bool, str]:
try:
models = await self.list_models()
+156 -34
View File
@@ -1,9 +1,18 @@
import json
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from uuid import uuid4
import httpx
from app.contracts import MessageRole, ModelCapability, ModelInfo, ModelRequest
from app.contracts import (
MessageRole,
ModelCapability,
ModelEvent,
ModelEventType,
ModelInfo,
ModelRequest,
)
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
from app.providers.credentials import CredentialResolver
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
@@ -25,29 +34,7 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
self.transport = transport
async def complete(self, request: ModelRequest) -> ProviderTurn:
payload: dict[str, object] = {
"model": request.model,
"messages": self._messages(request),
"stream": False,
}
if request.tools:
payload["tools"] = [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
for tool in request.tools
]
if request.temperature is not None:
payload["temperature"] = request.temperature
if request.max_tokens is not None:
payload["max_tokens"] = request.max_tokens
if request.response_format is not None:
payload["response_format"] = request.response_format
payload = self._payload(request, stream=False)
data = await self._request("POST", "/chat/completions", json=payload)
try:
@@ -73,6 +60,133 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
output_tokens=int(usage.get("completion_tokens") or 0),
)
def _payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
payload: dict[str, object] = {
"model": request.model,
"messages": self._messages(request),
"stream": stream,
}
if request.tools:
payload["tools"] = [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
for tool in request.tools
]
if request.temperature is not None:
payload["temperature"] = request.temperature
if request.max_tokens is not None:
payload["max_tokens"] = request.max_tokens
if request.response_format is not None:
payload["response_format"] = request.response_format
return payload
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
sequence = 0
open_calls: dict[int, str] = {}
def event(kind: ModelEventType, data: dict | None = None) -> ModelEvent:
nonlocal sequence
item = ModelEvent(
event=kind,
sequence=sequence,
data=data or {},
timestamp=datetime.now(timezone.utc),
)
sequence += 1
return item
try:
async for data in self._stream_json(self._payload(request, stream=True)):
usage = data.get("usage") or {}
if usage:
yield event(
ModelEventType.usage,
{
"input_tokens": int(usage.get("prompt_tokens") or 0),
"output_tokens": int(usage.get("completion_tokens") or 0),
},
)
choices = data.get("choices") or []
if not choices:
continue
choice = choices[0]
delta = choice.get("delta") or {}
if delta.get("reasoning_content"):
yield event(
ModelEventType.thinking_delta,
{"text": delta["reasoning_content"]},
)
if delta.get("content"):
yield event(ModelEventType.text_delta, {"text": delta["content"]})
for raw_call in delta.get("tool_calls") or []:
index = int(raw_call.get("index") or 0)
function = raw_call.get("function") or {}
call_id = raw_call.get("id") or open_calls.get(index) or f"call_{uuid4().hex}"
if index not in open_calls:
open_calls[index] = call_id
yield event(
ModelEventType.tool_call_start,
{"tool_call_id": call_id, "name": function.get("name") or ""},
)
if function.get("arguments"):
yield event(
ModelEventType.tool_call_delta,
{
"tool_call_id": open_calls[index],
"arguments_delta": function["arguments"],
},
)
if choice.get("finish_reason") == "tool_calls":
for call_id in open_calls.values():
yield event(
ModelEventType.tool_call_end, {"tool_call_id": call_id}
)
open_calls.clear()
for call_id in open_calls.values():
yield event(ModelEventType.tool_call_end, {"tool_call_id": call_id})
yield event(ModelEventType.done)
except ProviderError as exc:
yield event(ModelEventType.error, {"code": exc.code, "message": exc.message})
yield event(ModelEventType.done)
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
headers = self._headers()
try:
async with httpx.AsyncClient(
timeout=self.timeout_seconds, transport=self.transport
) as client:
async with client.stream(
"POST", f"{self.base_url}/chat/completions", headers=headers, json=payload
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
value = line[5:].strip()
if not value or value == "[DONE]":
continue
try:
data = json.loads(value)
except json.JSONDecodeError as exc:
raise ProviderError(
"PROVIDER_INVALID_RESPONSE", "Provider returned invalid SSE JSON."
) from exc
if isinstance(data, dict):
yield data
except httpx.TimeoutException as exc:
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
except httpx.HTTPStatusError as exc:
raise self._status_error(exc) from exc
except httpx.HTTPError as exc:
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
async def list_models(self) -> list[ModelInfo]:
data = await self._request("GET", "/models")
return [
@@ -127,10 +241,7 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
return result
async def _request(self, method: str, path: str, **kwargs) -> dict:
headers = {"Content-Type": "application/json"}
api_key = self.credentials.resolve(self.credential_id)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers = self._headers()
try:
async with httpx.AsyncClient(
timeout=self.timeout_seconds, transport=self.transport
@@ -143,14 +254,25 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
except httpx.TimeoutException as exc:
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
except httpx.HTTPStatusError as exc:
code = {
401: "PROVIDER_AUTH_FAILED",
404: "MODEL_NOT_FOUND",
429: "PROVIDER_RATE_LIMITED",
}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
raise ProviderError(code, f"Provider returned HTTP {exc.response.status_code}.") from exc
raise self._status_error(exc) from exc
except (httpx.HTTPError, ValueError) as exc:
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
if not isinstance(data, dict):
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Provider returned non-object JSON.")
return data
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
api_key = self.credentials.resolve(self.credential_id)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
@staticmethod
def _status_error(exc: httpx.HTTPStatusError) -> ProviderError:
code = {
401: "PROVIDER_AUTH_FAILED",
404: "MODEL_NOT_FOUND",
429: "PROVIDER_RATE_LIMITED",
}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
return ProviderError(code, f"Provider returned HTTP {exc.response.status_code}.")
+63 -39
View File
@@ -10,7 +10,6 @@ from app.contracts import (
AgentRunCreateRequest,
AgentRunListResponse,
ChatRequest,
ErrorResponse,
ExtensionInstallRequest,
IndexJob,
IndexRebuildRequest,
@@ -27,6 +26,7 @@ from app.contracts import (
PermissionDecisionRequest,
Plugin,
PluginListResponse,
PluginPermissionGrantRequest,
ProviderConfig,
ProviderCreateRequest,
ProviderListResponse,
@@ -46,17 +46,16 @@ from app.contracts import (
TranscriptionJob,
TranscriptionRequest,
)
from app.agent import AgentRunNotFoundError
from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.container import container
from app.errors import ApiError, not_implemented
from app.errors import ApiError
from app.extensions import ExtensionError
from app.providers.registry import ProviderNotFoundError
from app.providers.factory import UnsupportedProviderError
from app.retrieval.engine import engine
from app.services import index_service, note_service
from app.services import index_service, note_service, task_service, transcription_service
router = APIRouter(prefix="/api")
not_implemented_response = {501: {"model": ErrorResponse, "description": "业务服务尚未实现"}}
def utc_now() -> datetime:
@@ -151,11 +150,9 @@ async def delete_note(note_id: str) -> OperationResponse:
return OperationResponse(status="completed", resource_id=note_id, message="deleted")
@router.post(
"/notes/{note_id}/move", response_model=Note, responses=not_implemented_response, tags=["Notes"]
)
async def move_note(note_id: str, _: NoteMoveRequest) -> Note:
not_implemented(f"notes.move:{note_id}")
@router.post("/notes/{note_id}/move", response_model=Note, tags=["Notes"])
async def move_note(note_id: str, request: NoteMoveRequest) -> Note:
return await note_service.move_note(note_id, folder=request.folder)
# Retrieval and chat
@@ -219,6 +216,8 @@ async def create_agent_run(request: AgentRunCreateRequest) -> AgentRun:
return await container.agent.create_run(request)
except ExtensionError as exc:
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
except AgentCapacityError as exc:
raise ApiError(429, "AGENT_CAPACITY_EXCEEDED", str(exc)) from exc
@router.get(
@@ -386,6 +385,19 @@ async def disable_plugin(plugin_id: str) -> Plugin:
return extension_call(lambda: container.plugins.disable(plugin_id))
@router.put(
"/plugins/{plugin_id}/permissions",
response_model=Plugin,
tags=["Plugins"],
)
async def set_plugin_permissions(
plugin_id: str, request: PluginPermissionGrantRequest
) -> Plugin:
return extension_call(
lambda: container.plugins.set_permissions(plugin_id, request.permissions)
)
@router.delete(
"/plugins/{plugin_id}",
response_model=OperationResponse,
@@ -407,7 +419,6 @@ async def list_providers() -> ProviderListResponse:
@router.get(
"/providers/{provider_id}",
response_model=ProviderConfig,
responses=not_implemented_response,
tags=["Providers"],
)
async def get_provider(provider_id: str) -> ProviderConfig:
@@ -417,7 +428,6 @@ async def get_provider(provider_id: str) -> ProviderConfig:
@router.post(
"/providers",
response_model=ProviderConfig,
responses=not_implemented_response,
tags=["Providers"],
)
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
@@ -446,7 +456,6 @@ async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
@router.patch(
"/providers/{provider_id}",
response_model=ProviderConfig,
responses=not_implemented_response,
tags=["Providers"],
)
async def update_provider(
@@ -455,7 +464,19 @@ async def update_provider(
current = configurable_provider_or_404(provider_id).config
if provider_id == "mock":
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified.")
config = current.model_copy(update=request.model_dump(exclude_none=True))
fields = request.model_fields_set
if ("name" in fields and request.name is None) or (
"enabled" in fields and request.enabled is None
):
raise ApiError(
422,
"VALIDATION_ERROR",
"name and enabled cannot be null when explicitly provided.",
)
updates = {name: getattr(request, name) for name in fields}
config = ProviderConfig.model_validate(
{**current.model_dump(mode="python"), **updates}
)
adapter = container.provider_factory.build(config)
container.providers.replace(config, adapter)
return config
@@ -464,7 +485,6 @@ async def update_provider(
@router.delete(
"/providers/{provider_id}",
response_model=OperationResponse,
responses=not_implemented_response,
tags=["Providers"],
)
async def delete_provider(provider_id: str) -> OperationResponse:
@@ -478,7 +498,6 @@ async def delete_provider(provider_id: str) -> OperationResponse:
@router.get(
"/providers/{provider_id}/models",
response_model=ProviderModelsResponse,
responses=not_implemented_response,
tags=["Providers"],
)
async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
@@ -492,7 +511,6 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
@router.post(
"/providers/test",
response_model=ProviderTestResponse,
responses=not_implemented_response,
tags=["Providers"],
)
async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
@@ -517,38 +535,39 @@ async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
async def list_tasks(
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
) -> TaskListResponse:
return TaskListResponse(page=PageMeta(limit=limit, offset=offset))
items, total = task_service.list_tasks(limit=limit, offset=offset)
return TaskListResponse(
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
)
@router.post(
"/tasks", response_model=Task, responses=not_implemented_response, tags=["Tasks"]
)
async def create_task(_: TaskCreateRequest) -> Task:
not_implemented("tasks.create")
@router.post("/tasks", response_model=Task, tags=["Tasks"])
async def create_task(request: TaskCreateRequest) -> Task:
return task_service.create_task(**request.model_dump())
@router.get(
"/tasks/{task_id}", response_model=Task, responses=not_implemented_response, tags=["Tasks"]
)
@router.get("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
async def get_task(task_id: str) -> Task:
not_implemented(f"tasks.read:{task_id}")
task = task_service.get_task(task_id)
if task is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
return task
@router.patch(
"/tasks/{task_id}", response_model=Task, responses=not_implemented_response, tags=["Tasks"]
)
async def update_task(task_id: str, _: TaskUpdateRequest) -> Task:
not_implemented(f"tasks.update:{task_id}")
@router.patch("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
return task_service.update_task(task_id, request.model_dump(exclude_unset=True))
@router.delete(
"/tasks/{task_id}",
response_model=OperationResponse,
responses=not_implemented_response,
tags=["Tasks"],
)
async def delete_task(task_id: str) -> OperationResponse:
not_implemented(f"tasks.delete:{task_id}")
if not task_service.delete_task(task_id):
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
return OperationResponse(status="completed", resource_id=task_id, message="deleted")
# Media and index
@@ -556,21 +575,26 @@ async def delete_task(task_id: str) -> OperationResponse:
"/media/transcriptions",
response_model=TranscriptionJob,
status_code=202,
responses=not_implemented_response,
tags=["Media"],
)
async def create_transcription(_: TranscriptionRequest) -> TranscriptionJob:
not_implemented("media.transcriptions.create")
async def create_transcription(request: TranscriptionRequest) -> TranscriptionJob:
return transcription_service.create_transcription(
request.attachment_id, request.language
)
@router.get(
"/media/transcriptions/{job_id}",
response_model=TranscriptionJob,
responses=not_implemented_response,
tags=["Media"],
)
async def get_transcription(job_id: str) -> TranscriptionJob:
not_implemented(f"media.transcriptions.read:{job_id}")
job = transcription_service.get_transcription(job_id)
if job is None:
raise ApiError(
404, "RESOURCE_NOT_FOUND", "transcription job not found", {"job_id": job_id}
)
return job
@router.get("/index/status", response_model=IndexStatus, tags=["Index"])
@@ -0,0 +1,46 @@
from __future__ import annotations
import re
from pathlib import Path
from app.config import get_settings
from app.errors import ApiError
_ATTACHMENT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$")
MAX_ATTACHMENT_BYTES = 1024 * 1024
def attachment_path(attachment_id: str) -> Path:
if not _ATTACHMENT_ID.fullmatch(attachment_id):
raise ApiError(
400, "INVALID_ATTACHMENT_ID", "attachment_id is invalid",
{"attachment_id": attachment_id},
)
root = get_settings().attachments_path.resolve()
candidate = (root / attachment_id).resolve()
if not candidate.is_relative_to(root):
raise ApiError(400, "INVALID_ATTACHMENT_ID", "attachment path escapes storage")
return candidate
def read_attachment(attachment_id: str, *, max_chars: int = 100_000) -> dict[str, object]:
path = attachment_path(attachment_id)
if not path.is_file():
raise ApiError(
404, "ATTACHMENT_NOT_FOUND", "attachment not found",
{"attachment_id": attachment_id},
)
size = path.stat().st_size
if size > MAX_ATTACHMENT_BYTES:
raise ApiError(
413, "ATTACHMENT_TOO_LARGE", "attachment exceeds the Tool read limit",
{"attachment_id": attachment_id, "size": size},
)
text = path.read_text(encoding="utf-8")
truncated = len(text) > max_chars
return {
"attachment_id": attachment_id,
"content": text[:max_chars],
"size": size,
"truncated": truncated,
}
+15
View File
@@ -0,0 +1,15 @@
import asyncio
from functools import wraps
_vault_mutation_lock = asyncio.Lock()
def serialized_vault_mutation(operation):
"""串行化 Vault 文件与可重建索引的写入,避免 rebuild 与 Note 写操作交错。"""
@wraps(operation)
async def wrapped(*args, **kwargs):
async with _vault_mutation_lock:
return await operation(*args, **kwargs)
return wrapped
+52 -10
View File
@@ -17,11 +17,24 @@ from app.contracts import IndexJob, IndexRebuildRequest, IndexStatus
from app.errors import ApiError
from app.knowledge.parser import parse_note
from app.services.note_service import index_note
from app.services import task_service
from app.services.coordination import serialized_vault_mutation
from app.retrieval.vectorstore import SqliteVecStore
vector_store = SqliteVecStore()
_jobs: dict[str, IndexJob] = {}
_active_job_id: str | None = None
_last_completed_at: datetime | None = None
_last_error: str | None = None
MAX_JOBS = 100
def _remember_job(job: IndexJob) -> None:
_jobs[job.job_id] = job
while len(_jobs) > MAX_JOBS:
oldest = next(iter(_jobs))
_jobs.pop(oldest, None)
def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
@@ -29,23 +42,28 @@ def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
先读入内存若文件读取失败rebuild 尚未清空旧索引不会造成数据损失
"""
vault = get_settings().vault_path
vault = get_settings().vault_path.resolve()
result: list[tuple[str, str, str, datetime, datetime]] = []
if not vault.exists():
return result
for path in sorted(vault.rglob("*.md")):
resolved = path.resolve()
if not resolved.is_relative_to(vault):
continue
rel = path.relative_to(vault).as_posix()
folder = path.relative_to(vault).parent.as_posix()
if folder == ".":
folder = ""
stat = path.stat()
stat = resolved.stat()
created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
updated = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
result.append((rel, folder, path.read_text(encoding="utf-8"), created, updated))
result.append((rel, folder, resolved.read_text(encoding="utf-8"), created, updated))
return result
@serialized_vault_mutation
async def rebuild(request: IndexRebuildRequest) -> IndexJob:
global _active_job_id, _last_completed_at, _last_error
job_id = "job_" + uuid4().hex[:12]
# 增量重建(scope != all 或指定 note_ids)尚未实现,明确拒绝而非静默全量重建
if request.scope != "all" or request.note_ids:
@@ -59,10 +77,22 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
# 先扫描到内存(失败不会清旧索引),再快照旧库用于失败回滚
docs = _scan_vault()
settings = get_settings()
backup_path = settings.db_path.with_suffix(".db.bak") if settings.db_path.exists() else None
database_existed = settings.db_path.exists()
task_note_links = task_service.note_links() if database_existed else {}
backup_path = (
settings.db_path.with_name(f"{settings.db_path.name}.{job_id}.bak")
if database_existed
else None
)
if backup_path is not None:
shutil.copy2(settings.db_path, backup_path)
_active_job_id = job_id
_last_error = None
_remember_job(IndexJob(
job_id=job_id, status="running", scope=request.scope,
created_at=datetime.now(timezone.utc),
))
try:
repository.clear_all()
await vector_store.clear()
@@ -72,27 +102,39 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
created_at=created, updated_at=updated,
)
await index_note(parsed)
except BaseException:
task_service.restore_note_links(task_note_links)
except BaseException as exc:
# 重建失败:恢复旧索引,避免留下半成品;记录 failed 任务后向上抛
if backup_path is not None and backup_path.exists():
shutil.copy2(backup_path, settings.db_path)
_jobs[job_id] = IndexJob(
elif not database_existed:
settings.db_path.unlink(missing_ok=True)
_remember_job(IndexJob(
job_id=job_id, status="failed", scope=request.scope,
created_at=datetime.now(timezone.utc),
)
))
_last_error = str(exc)
raise
finally:
_active_job_id = None
if backup_path is not None:
backup_path.unlink(missing_ok=True)
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
_jobs[job_id] = job
_remember_job(job)
_last_completed_at = job.created_at
return job
def get_status() -> IndexStatus:
# 同步重建、无排队任务,因此状态恒为 idle;实际索引规模可由 GET /api/notes 与搜索反映
return IndexStatus(status="idle", pending_jobs=0)
if _active_job_id is not None:
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id)
return IndexStatus(
status="failed" if _last_error else "idle",
pending_jobs=0,
last_completed_at=_last_completed_at,
error_message=_last_error,
)
def get_job(job_id: str) -> IndexJob | None:
+55
View File
@@ -19,6 +19,7 @@ from app.errors import ApiError
from app.knowledge.parser import ParsedNote, parse_note
from app.retrieval.embedding import HashEmbeddingProvider
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
from app.services.coordination import serialized_vault_mutation
# 轻量实现实例(无状态,可直接复用);接入真实模型后替换为对应 Provider
embedding = HashEmbeddingProvider()
@@ -148,6 +149,7 @@ async def index_note(parsed: ParsedNote) -> None:
conn.close()
@serialized_vault_mutation
async def create_note(*, title: str, markdown: str, folder: str | None, tags: list[str]) -> Note:
rel_path, clean_folder = _rel_path(folder, title)
now = datetime.now(timezone.utc)
@@ -183,6 +185,7 @@ async def get_note(note_id: str) -> Note | None:
record.created_at, record.updated_at, record.blocks, markdown)
@serialized_vault_mutation
async def update_note(
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None
) -> Note:
@@ -213,6 +216,58 @@ async def update_note(
parsed.created_at, parsed.updated_at, parsed.blocks, new_md)
@serialized_vault_mutation
async def move_note(note_id: str, *, folder: str) -> Note:
record = repository.get_note_record(note_id)
if record is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
clean_folder = _normalize_folder(folder)
filename = Path(record.file_path).name
new_rel_path = f"{clean_folder}/{filename}" if clean_folder else filename
if new_rel_path == record.file_path:
note = await get_note(note_id)
assert note is not None
return note
source = _abs_path(record.file_path)
target = _abs_path(new_rel_path)
if not source.is_file():
raise ApiError(
409, "NOTE_FILE_MISSING", "note file is missing from the Vault",
{"note_id": note_id, "file_path": record.file_path},
)
if target.exists():
raise ApiError(
409, "RESOURCE_CONFLICT", "a note already exists at the target path",
{"note_id": note_id, "file_path": new_rel_path},
)
markdown = source.read_text(encoding="utf-8")
target.parent.mkdir(parents=True, exist_ok=True)
source.replace(target)
try:
parsed = parse_note(
markdown=markdown,
file_path=new_rel_path,
folder=clean_folder,
tags=record.tags,
created_at=record.created_at,
updated_at=datetime.now(timezone.utc),
note_id=record.note_id,
)
parsed.title = record.title
await index_note(parsed)
except BaseException:
target.replace(source)
raise
return _build_note(
parsed.note_id, parsed.title, parsed.file_path, parsed.tags,
parsed.created_at, parsed.updated_at, parsed.blocks, markdown,
)
@serialized_vault_mutation
async def delete_note(note_id: str) -> bool:
record = repository.get_note_record(note_id)
if record is None:
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
from app import repository
from app.contracts import Task, TaskStatus
from app.database.db import connect, transaction
from app.errors import ApiError
def _now() -> datetime:
return datetime.now(timezone.utc)
def _task_from_row(row) -> Task:
return Task(
task_id=row["task_id"],
title=row["title"],
description=row["description"],
status=TaskStatus(row["status"]),
note_id=row["note_id"],
due_at=datetime.fromisoformat(row["due_at"]) if row["due_at"] else None,
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
def create_task(
*, title: str, description: str = "", note_id: str | None = None,
due_at: datetime | None = None,
) -> Task:
if note_id and repository.get_note_record(note_id) is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
task_id = f"task_{uuid4().hex}"
now = _now()
conn = connect()
try:
with transaction(conn):
conn.execute(
"""
INSERT INTO tasks
(task_id, title, description, status, note_id, due_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id, title, description, TaskStatus.todo.value, note_id,
due_at.isoformat() if due_at else None, now.isoformat(), now.isoformat(),
),
)
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
return _task_from_row(row)
finally:
conn.close()
def get_task(task_id: str) -> Task | None:
conn = connect()
try:
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
return _task_from_row(row) if row else None
finally:
conn.close()
def list_tasks(*, limit: int, offset: int) -> tuple[list[Task], int]:
conn = connect()
try:
total = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
rows = conn.execute(
"SELECT * FROM tasks ORDER BY updated_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [_task_from_row(row) for row in rows], total
finally:
conn.close()
def update_task(task_id: str, values: dict[str, object]) -> Task:
current = get_task(task_id)
if current is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
if "note_id" in values and values["note_id"]:
note_id = str(values["note_id"])
if repository.get_note_record(note_id) is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
if values.get("title") is None:
values.pop("title", None)
if values.get("description") is None:
values.pop("description", None)
if values.get("status") is None:
values.pop("status", None)
columns: list[str] = []
params: list[object] = []
for name, value in values.items():
columns.append(f"{name} = ?")
if isinstance(value, datetime):
value = value.isoformat()
elif isinstance(value, TaskStatus):
value = value.value
params.append(value)
columns.append("updated_at = ?")
params.append(_now().isoformat())
params.append(task_id)
conn = connect()
try:
with transaction(conn):
conn.execute(
f"UPDATE tasks SET {', '.join(columns)} WHERE task_id = ?",
params,
)
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
return _task_from_row(row)
finally:
conn.close()
def delete_task(task_id: str) -> bool:
conn = connect()
try:
with transaction(conn):
cursor = conn.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,))
return cursor.rowcount > 0
finally:
conn.close()
def note_links() -> dict[str, str]:
"""重建可再生 Note 索引前,暂存不可再生 Task 到 Note 的业务关联。"""
conn = connect()
try:
return {
row["task_id"]: row["note_id"]
for row in conn.execute(
"SELECT task_id, note_id FROM tasks WHERE note_id IS NOT NULL"
)
}
finally:
conn.close()
def restore_note_links(links: dict[str, str]) -> None:
if not links:
return
conn = connect()
try:
with transaction(conn):
for task_id, note_id in links.items():
exists = conn.execute(
"SELECT 1 FROM notes WHERE note_id = ?", (note_id,)
).fetchone()
if exists:
conn.execute(
"UPDATE tasks SET note_id = ? WHERE task_id = ?",
(note_id, task_id),
)
finally:
conn.close()
@@ -0,0 +1,40 @@
from __future__ import annotations
from collections import OrderedDict
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from app.contracts import TranscriptionJob
from app.services.attachment_service import attachment_path
_jobs: OrderedDict[str, TranscriptionJob] = OrderedDict()
MAX_JOBS = 100
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
del language # 预生成 transcript 暂不需要语言识别。
source = attachment_path(attachment_id)
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
job = TranscriptionJob(
job_id=f"transcription_{uuid4().hex}",
attachment_id=attachment_id,
status="completed" if transcript.is_file() else "failed",
text=transcript.read_text(encoding="utf-8") if transcript.is_file() else None,
error_code=None if transcript.is_file() else "TRANSCRIPTION_BACKEND_UNAVAILABLE",
error_message=(
None
if transcript.is_file()
else "No host-generated transcript is available; local speech models are phase two."
),
created_at=datetime.now(timezone.utc),
)
_jobs[job.job_id] = job
while len(_jobs) > MAX_JOBS:
_jobs.popitem(last=False)
return job.model_copy(deep=True)
def get_transcription(job_id: str) -> TranscriptionJob | None:
job = _jobs.get(job_id)
return job.model_copy(deep=True) if job else None
+1
View File
@@ -7,6 +7,7 @@ requires-python = ">=3.11"
dependencies = [
"fastapi>=0.116,<1.0",
"httpx>=0.28,<1.0",
"jsonschema>=4.25,<5.0",
"pyyaml>=6.0,<7.0",
"sqlite-vec>=0.1.9",
"uvicorn[standard]>=0.35,<1.0",
+62 -2
View File
@@ -2,8 +2,25 @@ import asyncio
from app.main import health, service_status
from app.routes import get_index_status, list_notes, list_plugins, list_providers, list_skills
from app.routes import create_provider, delete_provider, get_provider, update_provider
from app.contracts import ProviderCreateRequest, ProviderType, ProviderUpdateRequest
from app.routes import (
create_provider,
create_task,
delete_provider,
delete_task,
get_provider,
get_task,
list_tasks,
update_provider,
update_task,
)
from app.contracts import (
ProviderCreateRequest,
ProviderType,
ProviderUpdateRequest,
TaskCreateRequest,
TaskStatus,
TaskUpdateRequest,
)
def test_health() -> None:
@@ -79,3 +96,46 @@ def test_provider_configuration_lifecycle() -> None:
assert fetched.provider_type == ProviderType.ollama
assert disabled.enabled is False
assert deleted.resource_id == created.provider_id
def test_provider_patch_can_clear_nullable_fields() -> None:
created = asyncio.run(
create_provider(
ProviderCreateRequest(
provider_type=ProviderType.ollama,
name="Clearable",
base_url="http://127.0.0.1:11434",
default_model="qwen",
credential_id="unused",
)
)
)
try:
cleared = asyncio.run(
update_provider(
created.provider_id,
ProviderUpdateRequest(
base_url=None, default_model=None, credential_id=None
),
)
)
assert cleared.base_url is None
assert cleared.default_model is None
assert cleared.credential_id is None
finally:
asyncio.run(delete_provider(created.provider_id))
def test_task_lifecycle_is_persistent() -> None:
created = asyncio.run(create_task(TaskCreateRequest(title="审阅修复")))
fetched = asyncio.run(get_task(created.task_id))
updated = asyncio.run(
update_task(created.task_id, TaskUpdateRequest(status=TaskStatus.done))
)
listed = asyncio.run(list_tasks(limit=20, offset=0))
deleted = asyncio.run(delete_task(created.task_id))
assert fetched.title == "审阅修复"
assert updated.status == TaskStatus.done
assert any(item.task_id == created.task_id for item in listed.items)
assert deleted.resource_id == created.task_id
+125
View File
@@ -13,6 +13,7 @@ from app.contracts import (
)
from app.extensions import ExtensionError
from app.services import note_service
from app.config import get_settings
def run(coroutine):
@@ -172,3 +173,127 @@ tools: [plugin.not-installed]
with pytest.raises(ExtensionError) as exc:
container.skills.enable("missing-tool")
assert exc.value.code == "SKILL_DEPENDENCY_MISSING"
def test_plugin_permissions_must_be_known_and_granted(tmp_path) -> None:
package = tmp_path / "write-plugin"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: write-plugin
name: Write Plugin
version: 1.0.0
permissions: [notes.write]
contributes:
tools: [plugin.write]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "tools.yaml").write_text(
"""
tools:
- name: plugin.write
description: permission test
permission: notes.write
handler: echo
parameters:
type: object
properties: {text: {type: string}}
required: [text]
""".strip(),
encoding="utf-8",
)
container = build_container()
installed = container.plugins.install(package)
assert installed.status == "permission_required"
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("write-plugin")
assert exc.value.code == "PLUGIN_PERMISSION_REQUIRED"
granted = container.plugins.set_permissions("write-plugin", ["notes.write"])
enabled = container.plugins.enable("write-plugin")
assert granted.granted_permissions == ["notes.write"]
assert enabled.status == "ready"
def test_plugin_rejects_unknown_permissions_and_invalid_schema(tmp_path) -> None:
unknown = tmp_path / "unknown-permission"
unknown.mkdir()
(unknown / "plugin.yaml").write_text(
"""
id: unknown-permission
name: Unknown
version: 1.0.0
permissions: [notes.wirte]
""".strip(),
encoding="utf-8",
)
container = build_container()
with pytest.raises(ExtensionError) as exc:
container.plugins.install(unknown)
assert exc.value.code == "EXTENSION_PERMISSION_INVALID"
malformed = tmp_path / "malformed-schema"
malformed.mkdir()
(malformed / "plugin.yaml").write_text(
"""
id: malformed-schema
name: Malformed
version: 1.0.0
contributes:
tools: [bad.schema]
""".strip(),
encoding="utf-8",
)
(malformed / "tools.yaml").write_text(
"""
tools:
- name: bad.schema
description: invalid schema
handler: echo
parameters:
type: object
properties: []
""".strip(),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as exc:
container.plugins.install(malformed)
assert exc.value.code == "PLUGIN_TOOL_SCHEMA_INVALID"
def test_attachment_and_transcription_tools_use_host_storage() -> None:
async def scenario() -> None:
root = get_settings().attachments_path
root.mkdir(parents=True, exist_ok=True)
(root / "meeting.txt").write_text("会议转写内容", encoding="utf-8")
container = build_container()
attachment = await container.tools.execute(
ToolCall(
tool_call_id="call_attachment",
name="attachments.read",
arguments={"attachment_id": "meeting.txt"},
),
ToolExecutionContext(run_id="run_attachment"),
)
transcription = await container.tools.execute(
ToolCall(
tool_call_id="call_transcription",
name="audio.transcribe",
arguments={"attachment_id": "meeting.txt"},
),
ToolExecutionContext(run_id="run_transcription"),
)
assert attachment.success is True
assert attachment.output["content"] == "会议转写内容"
assert transcription.success is True
assert transcription.output["status"] == "completed"
assert transcription.output["text"] == "会议转写内容"
run(scenario())
+74 -1
View File
@@ -3,7 +3,14 @@ import json
import httpx
from app.contracts import Message, MessageRole, ModelRequest, ToolCall, ToolDefinition
from app.contracts import (
Message,
MessageRole,
ModelEventType,
ModelRequest,
ToolCall,
ToolDefinition,
)
from app.providers.ollama import OllamaProvider
from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -17,6 +24,10 @@ def run(coroutine):
return asyncio.run(coroutine)
async def collect(stream):
return [event async for event in stream]
def test_openai_compatible_maps_tool_call_and_credentials() -> None:
captured: dict = {}
@@ -152,3 +163,65 @@ def test_ollama_maps_models_and_completion() -> None:
assert turn.text == "local answer"
assert turn.input_tokens == 5
assert turn.output_tokens == 2
def test_openai_compatible_streams_incremental_sse() -> None:
def handler(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content)
assert payload["stream"] is True
body = "\n".join(
[
'data: {"choices":[{"delta":{"content":"hel"}}]}',
'data: {"choices":[{"delta":{"content":"lo"},"finish_reason":"stop"}]}',
'data: {"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1}}',
"data: [DONE]",
"",
]
)
return httpx.Response(200, text=body)
provider = OpenAICompatibleProvider(
base_url="https://provider.test/v1",
credential_id=None,
credentials=StaticCredentials(),
transport=httpx.MockTransport(handler),
)
request = ModelRequest(
provider_id="test", model="model",
messages=[Message(role=MessageRole.user, content="hello")],
)
events = run(collect(provider.stream(request)))
assert [item.data["text"] for item in events if item.event == ModelEventType.text_delta] == [
"hel", "lo"
]
assert events[-1].event == ModelEventType.done
assert [item.sequence for item in events] == list(range(len(events)))
def test_ollama_streams_incremental_jsonl() -> None:
def handler(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content)
assert payload["stream"] is True
body = "\n".join(
[
'{"message":{"content":""},"done":false}',
'{"message":{"content":""},"done":false}',
'{"message":{"content":""},"done":true,"prompt_eval_count":3,"eval_count":2}',
"",
]
)
return httpx.Response(200, text=body)
provider = OllamaProvider(transport=httpx.MockTransport(handler))
request = ModelRequest(
provider_id="ollama", model="qwen",
messages=[Message(role=MessageRole.user, content="hello")],
)
events = run(collect(provider.stream(request)))
assert [item.data["text"] for item in events if item.event == ModelEventType.text_delta] == [
"", ""
]
assert events[-2].event == ModelEventType.usage
assert events[-1].event == ModelEventType.done
+77 -1
View File
@@ -77,6 +77,28 @@ def test_block_ids_are_stable() -> None:
assert all(b.block_id.startswith("blk_") for b in p1.blocks)
def test_code_fence_does_not_create_fake_headings() -> None:
markdown = '# Real Heading\n\n```python\n# code comment\nprint("x")\n```\n'
parsed = parse_note(
markdown=markdown, file_path="code.md", folder="",
tags=None, created_at=_dt(), updated_at=_dt(),
)
assert all(block.heading_path == ["Real Heading"] for block in parsed.blocks)
assert any("# code comment" in block.content for block in parsed.blocks)
def test_offsets_use_browser_compatible_utf16_units() -> None:
markdown = "# \U0001f600\n\nbody"
parsed = parse_note(
markdown=markdown, file_path="emoji.md", folder="",
tags=None, created_at=_dt(), updated_at=_dt(),
)
body = next(block for block in parsed.blocks if block.content == "body")
assert body.start_offset == len(markdown[: markdown.index("body")].encode("utf-16-le")) // 2
def test_tokens_split_cjk_bigrams_and_match_query() -> None:
toks = tokens("向量检索")
assert "" in toks and "" in toks
@@ -454,7 +476,6 @@ def test_patch_partial_content_no_orphan_vectors(vault) -> None:
)
)
assert ids("vec_blocks") == ids("blocks")
asyncio.run(
note_service.update_note(note.note_id, markdown="# 标题\n\n段落一改了。\n\n新增段落。")
)
@@ -462,6 +483,22 @@ def test_patch_partial_content_no_orphan_vectors(vault) -> None:
assert ids("vec_blocks") == ids("blocks")
def test_move_note_preserves_id_and_updates_indexed_path(vault) -> None:
from app.services import note_service
created = asyncio.run(
note_service.create_note(
title="可移动", markdown="# 标题\n\n正文", folder="原目录", tags=[]
)
)
moved = asyncio.run(note_service.move_note(created.note_id, folder="新目录"))
assert moved.note_id == created.note_id
assert moved.file_path == "新目录/可移动.md"
assert not (vault / "原目录" / "可移动.md").exists()
assert (vault / "新目录" / "可移动.md").exists()
assert asyncio.run(note_service.get_note(created.note_id)).file_path == moved.file_path
def test_fts_metadata_filter_recalls_beyond_candidate_pool(vault) -> None:
"""metadata 过滤不能受候选池截断影响:目标块排在 50 名之外也应被召回(审阅 #4)。"""
from app.retrieval.engine import engine
@@ -524,3 +561,42 @@ def test_rebuild_failure_restores_old_index(vault, monkeypatch) -> None:
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
assert repository.stats() == before # 旧索引已恢复,无半成品
def test_first_rebuild_failure_removes_partial_database(vault, monkeypatch) -> None:
"""首次启动没有旧库时,失败也不能留下已经写入的部分索引。"""
from app.services import index_service
_write_vault(
vault,
{"a.md": "# A\n\nfirst", "b.md": "# B\n\nsecond"},
)
real_index = index_service.index_note
calls = {"count": 0}
async def fail_on_second(parsed):
calls["count"] += 1
if calls["count"] == 2:
raise RuntimeError("injected first-rebuild failure")
await real_index(parsed)
monkeypatch.setattr(index_service, "index_note", fail_on_second)
with pytest.raises(RuntimeError):
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
assert not get_settings().db_path.exists()
def test_rebuild_preserves_task_note_links(vault) -> None:
from app.services import index_service, note_service, task_service
note = asyncio.run(
note_service.create_note(
title="任务关联", markdown="# 任务关联", folder="", tags=[]
)
)
task = task_service.create_task(title="跟进", note_id=note.note_id)
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
assert task_service.get_task(task.task_id).note_id == note.note_id
+175
View File
@@ -33,6 +33,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
@@ -174,6 +183,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "notes-agent-backend"
version = "0.1.0"
@@ -181,6 +217,7 @@ source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "pyyaml" },
{ name = "sqlite-vec" },
{ name = "uvicorn", extra = ["standard"] },
@@ -195,6 +232,7 @@ dev = [
requires-dist = [
{ name = "fastapi", specifier = ">=0.116,<1.0" },
{ name = "httpx", specifier = ">=0.28,<1.0" },
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "sqlite-vec", specifier = ">=0.1.9" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
@@ -427,6 +465,143 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" },
{ url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" },
{ url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" },
{ url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" },
{ url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" },
{ url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" },
{ url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" },
{ url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" },
{ url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" },
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
{ url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" },
{ url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" },
{ url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" },
{ url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" },
{ url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" },
]
[[package]]
name = "sqlite-vec"
version = "0.1.9"