diff --git a/README.md b/README.md index ea60cd0..100e7e7 100644 --- a/README.md +++ b/README.md @@ -24,42 +24,57 @@ suggested, never cited (§2): `config.yaml`, its own configuration, and the instance goes **inert**: nothing is injected, no tool is offered, no timer runs, and every writer refuses in one line (§11). -## Install +## Standalone install + +The portable suggestion job does not require `amplifier-app-cli`. It declares +Core >=2.0.1, Foundation and its setup installer as dependencies. Use the public +index and require the published Core wheel: ```bash -# 1. Session plane (load + save + /remember and /memory), composed into all sessions. -# Point --app at the behavior file, not at the root bundle: the root bundle includes -# this same behavior, so an --app install of it is a self-include the loader skips -# ("Circular Include Skipped"), leaving a session with no hook and no memory tool. -amplifier bundle add 'git+https://github.com/microsoft/amplifier-bundle-memory@main#subdirectory=behaviors/memory-session.yaml' --app - -# 2. The CLI (`amplifier-memory`, a thin click wrapper over the `amplifier_memory` -# library: init · status · review · why · format_why · doctor · update_check · -# update_plan · service_status · run_suggest · pending/accept/decline/skip — -# cli.py adds only parsing, printing and exit codes): -uv tool install git+https://github.com/microsoft/amplifier-bundle-memory@main - -# 3. Create the store (a git repo at ~/.amplifier-memory) and install the daily -# suggestion timer. This is the only setup step; `--no-timer` skips the timer. -amplifier-memory init - -# 4. Verify: +uv tool install --default-index https://pypi.org/simple --no-build-package amplifier-core \ + git+https://github.com/microsoft/amplifier-bundle-memory@main +amplifier-memory init --no-timer +amplifier-memory setup --workspace /path/to/your/workspace +amplifier-memory service install amplifier-memory doctor ``` -Step 3 asks **one** question — *What kinds of things should I remember for you?* -— offering a default answer you can accept with Enter, and saves your answer as -`m-001`. It is your own words, verbatim: nothing is invented for you, and with no -terminal attached it takes the default and says so. It then prints what it -installed, how to turn it off (`amplifier-memory service uninstall --home -`) and where to steer what it costs (`config.yaml` inside the instance -— [which model the judge uses](#which-model-the-judge-uses)). Running it again -reports `store exists · timer installed` and changes nothing; once you have -uninstalled the timer, `init` leaves it uninstalled. - -If you already had a store at the older `~/.amplifier/memory`, step 3 **offers** -to move it to the default and prints what it did. It never moves it silently, and -a no leaves it exactly where it is (with the `mv` you would run yourself). +`init` asks the existing memory-seeding question and preserves an existing store. +`setup` reads shared and workspace provider settings, prepares the selected source +modules under this memory instance, and installs their dependencies in the standalone +tool environment. It makes no model call. Setup is explicit: suggestion runs never +install or refresh anything. Timer installation/start/restart refuses an unprepared +or changed runtime. After changing provider sources/settings or upgrading runtime +packages, run `setup` again before re-enabling the timer. + +Setup uses Foundation's settings overlay in this order: shared +`$AMPLIFIER_HOME/settings.yaml` (otherwise `~/.amplifier/settings.yaml`), workspace +`.amplifier/settings.yaml`, then `.amplifier/settings.local.yaml`. The chosen +workspace is recorded for the timer. Named accounts and their source/configuration +are preserved; source overrides come from `sources.modules`. Custom providers need +an explicit source. Only providers, the optional configured `hooks-routing`, and +basic Core lifecycle modules are mounted. User tools, other hooks, workspace +instructions and recursive memory jobs are not loaded. An explicitly selected +arbitrary bundle or unsupported module override requires host-resolved inference +rather than silently selecting a different account. + +The default `fast` role requires an explicit `config.hooks` routing declaration; +routing supplied only through bundle includes is not composed and fails visibly. +Use the user's intended routing settings, explicitly choose provider/model, or +use host-resolved inference. See [configuration and model provenance](docs/SESSIONLESS_INFERENCE.md). + +Generated state stays under the existing resolved memory home: +`runtime/generations/` contains prepared module copies and source receipts; +`runtime/jobs//` contains internal job metadata, safe usage events and the +private transcript. These files are ignored by Git and not read as memories or +new source conversations. Shared source histories and `sessions.jsonl` are never +rewritten. Provider-owned OAuth refresh stores remain under each provider's +configured ownership; the job does not relocate, clone or reset credentials. + +Hosts such as Unified keep their existing mounted-provider inference path and +manage their own component installations. Installing the session memory behavior +in another host is a separate, optional host operation. Existing memory homes, +including the legacy fallback, keep their current location; no migration is added. ### `--home`: more than one instance @@ -86,21 +101,13 @@ amplifier-memory service uninstall --home ~/work-memory To see the session plane itself working, start a session and say a standing preference: it is saved in that turn and announced with its id and its undo. -To remove all of it: +To stop the daily pass without deleting memories: ```bash -amplifier bundle remove 'git+https://github.com/microsoft/amplifier-bundle-memory@main#subdirectory=behaviors/memory-session.yaml' --app -uv tool uninstall amplifier-memory -rm -rf ~/.amplifier-memory # deletes your memories +amplifier-memory service uninstall ``` -The daily suggestion inbox (Phase 2, below) needs nothing further: step 3 -installed its timer. To turn it off, or to put it back afterwards: - -```bash -amplifier-memory service uninstall # no more daily pass; the store is untouched -amplifier-memory service install # what `init` already did — run it to undo an uninstall -``` +After setup, `amplifier-memory service install` enables it again. Both act on the instance `--home` resolves to. `amplifier-memory service status` lists **every** installed instance timer, not only that one, so a timer you set up @@ -147,18 +154,11 @@ first) · `amplifier-memory review` (pending suggestions) · `amplifier-memory init` · `amplifier-memory update` (alias `upgrade`) · `amplifier-memory suggest` and `service` (Phase 2, below). -One `amplifier-memory update` is enough: it refreshes all three copies of this -bundle a device runs, and prints each as ` → `: the `amplifier-memory` -uv tool (the shell verb), the bundle cache clone(s) under `~/.amplifier/cache/` and -`~/.amplifier/cache/skills/` (what a session loads the modules and skills -from), and the `amplifier_memory` library inside the amplifier CLI's own venv -(what those modules import). `doctor`'s `update` row compares all three -against `git ls-remote` and names which one is behind; a cache or a venv it -cannot find is INFO, never a failure. When the uv-tool step upgrades the CLI, -`update` re-runs itself from the freshly installed binary so the remaining -refreshes happen with the new code — that hand-off is why the second half of -the report shows step 1 skipped. Sessions started before an update keep the -old module code until they restart — nothing is hot-reloaded. +`amplifier-memory update` upgrades the standalone tool, checks inference readiness, +restarts an installed timer only when ready, and runs `doctor`. Other hosts manage +their own runtime updates. Legacy CLI/cache maintenance remains an explicitly +requested library interoperability operation; ordinary update never discovers or +invokes `amplifier`. Existing sessions retain their loaded code until they end. Reading memory leaves no commit behind: loads and citations are appended to `~/.amplifier-memory/usage.jsonl`, which git does not track. Every commit in @@ -172,8 +172,8 @@ yesterday's recorded sessions, asks the model one question per session, checks in code that every quote it gets back was really said by you, and *proposes* the survivors. It never writes to `MEMORY.md`. -`amplifier-memory init` installed that timer (a systemd `--user` timer, launchd on -macOS) — there is no second setup step. The rest of the verbs: +After explicit setup and service installation, the timer is a systemd user timer +on Linux or a launchd agent on macOS. The remaining verbs: ``` amplifier-memory suggest # run the pass once, now @@ -248,38 +248,32 @@ billed — a provider id, `role:`, or `inherited`. ### Which model the judge uses -Three answers, in this order: - -1. the `provider` / `model` / `bundle` you set in `config.yaml` (below); -2. else the **role** — `fast` as shipped — resolved by the amplifier CLI, when - your CLI has `amplifier run --model-role` (the job reads its `--help` to find - out; today's CLI does not, so this step is skipped); -3. else the CLI's own default, **inherited** — whatever `amplifier provider` has - starred. +Selection precedence: -The shipped default is a role, never a provider id: a provider id names one -machine's account. To pin one for this job alone, write the `llm:` block of the -store's own `config.yaml`, which `init` already wrote: +1. The explicit `provider` / `model` / host-resolved `bundle` in this instance's `config.yaml`. +2. Otherwise the configured routing module resolves the requested role (`fast` by default). +3. Without a routing resolver, the unique configured default account wins. Ambiguous or + unavailable selections fail visibly; there is no fallback to another account. ```yaml -enabled: true # store.v3 §11 - false makes this instance inert +enabled: true llm: judge: - role: "fast" # recorded and logged; resolved when the host can - provider: "luna" # an amplifier provider id -> `amplifier run -p` - model: "" # optional -> `-m` - bundle: "" # optional -> `-B` + role: fast + provider: "" # optional configured named account + model: "" # optional exact model + bundle: "" # requires host-resolved inference ``` -It lives *inside* the store it configures, because a store is an instance and there may -be more than one (store.v3 §1–§2): move the instance and its configuration moves with -it. `config.yaml` is plumbing, not memory — never injected, never suggested, never -cited. Only the keys you set become flags; with no file, or an empty one, the job runs -exactly the command it always ran. Every run's log line names which provider it used, -and `amplifier-memory doctor`'s `llm judge` row names the judge before the night rather -than after it — the provider you pinned, the role it resolved through, or `inherited` -with what that default was **measured** to cost per call, and so what a full 30-call -night can bill you. +Provider configuration retains explicit credential placeholders such as `${OPENAI_API_KEY}`; +process environment takes precedence over shared `keys.env` when resolving placeholders. +The dedicated `amplifier-memory suggest` process also loads missing shared keys into +its own environment for providers using implicit environment credentials. Embedding +hosts use `run_suggest`/`complete_once`; those APIs never change process environment. +No credential values are written to runtime receipts or usage logs. Requests have no tools, +no automatic retry, a 4096 output-token cap, and no fixed healthy-call completion deadline. +The job records actual provider/model and measured usage; missing cost remains unknown. +Historical evaluation costs below are observations, not current account estimates. What the measurements say (7 model variants, 210 real calls, `evaluations/model-class/`): diff --git a/conformance/cli/run.py b/conformance/cli/run.py index 521b557..7a8bda1 100755 --- a/conformance/cli/run.py +++ b/conformance/cli/run.py @@ -31,6 +31,7 @@ from contextlib import contextmanager, redirect_stdout from datetime import UTC, datetime, timedelta from pathlib import Path +from unittest.mock import patch if __package__ in (None, ""): # allow `python conformance/cli/run.py` sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -55,7 +56,7 @@ #: steward's device on 2026-09-07 while only the pre-v3 pair existed (item `…-5wc`). UNIT_NAME = re.compile(r"amplifier-memory-suggest[A-Za-z0-9._-]*\.(?:timer|service)") -CONTRACT_VERBS = ["init", "status", "why", "review", "doctor", "service", "update", "suggest"] +CONTRACT_VERBS = ["init", "status", "why", "review", "doctor", "service", "update", "suggest", "setup"] HUMAN_IDENTITY = ("Test Human", "human@example.invalid") SHA_A, SHA_B = "a" * 40, "b" * 40 @@ -419,18 +420,17 @@ def _judge_rows(home: Path) -> dict[str, str]: --help` and the wording does not depend on what this host happens to document. """ from amplifier_memory import llm_config - from amplifier_memory.doctor import APP_DEFAULT, INHERITED, llm_row + from amplifier_memory.doctor import llm_row config = home / llm_config.CONFIG_NAME shipped = config.read_text(encoding="utf-8") rows: dict[str, str] = {} - # 1. INHERITED - the shipped config names a role and no provider, and this host - # documents no `--model-role`, so the pass runs on the app's own default. + # 1. The shipped config requests a role, never silently a provider default. inherited = llm_row(llm_config.load(home), home=home, help_text="") assert inherited.level == "OK", inherited.render() - assert INHERITED in inherited.detail, inherited.detail - assert APP_DEFAULT in inherited.detail, "the inherited default is not named" + assert "role fast requested" in inherited.detail, inherited.detail + assert "unavailable resolver fails visibly" in inherited.detail.lower() assert llm_config.CONFIG_NAME in inherited.detail, "the file is not named" assert "role fast" in inherited.detail, "the recorded role is not named" assert "no run yet, so no measured cost" in inherited.detail, "the measured cost is missing" @@ -444,7 +444,7 @@ def _judge_rows(home: Path) -> dict[str, str]: assert configured.level == "OK", configured.render() assert "provider luna" in configured.detail and "model gpt-5.6-luna" in configured.detail assert llm_config.CONFIG_NAME in configured.detail, configured.detail - assert INHERITED not in configured.detail, "a named judge inherits nothing" + assert "role fast requested" not in configured.detail, "a named judge inherits nothing" rows["configured"] = configured.detail # 3. UNUSABLE - WARN, never FAIL: the run still happens and still inherits, but the @@ -453,7 +453,7 @@ def _judge_rows(home: Path) -> dict[str, str]: unusable = llm_row(llm_config.load(home), home=home, help_text="") assert unusable.level == "WARN", "a bad config file is not a broken store" assert "not valid YAML" in unusable.detail, "the reason is not named" - assert INHERITED in unusable.detail and "remedy" in unusable.detail + assert "role fast requested" in unusable.detail and "remedy" in unusable.detail rows["unusable"] = unusable.detail # 4. The row and the daily job are ONE sentence, not two renderings of one fact @@ -493,6 +493,7 @@ def probe_core_5() -> Verdict: "suggest timer", "substrate", "llm judge", + "inference runtime", "update", ], names assert report.exit_code == 0 @@ -569,6 +570,8 @@ def probe_core_5() -> Verdict: ) +@patch("amplifier_memory.runtime.readiness", lambda home=None: {}) +@patch("amplifier_memory.service.which_platform", lambda platform=None: platform or "systemd") def probe_core_6() -> Verdict: """service: render units, daemon-reload -> enable --now, and roll back on a failed step. @@ -752,6 +755,8 @@ def lay_pre_v3() -> None: ) +@patch("amplifier_memory.runtime.readiness", lambda home=None: {}) +@patch("amplifier_memory.service.which_platform", lambda platform=None: platform or "systemd") def probe_core_7() -> Verdict: """update: upgrade the tool, refresh **all three** installed things, end in doctor. @@ -969,6 +974,8 @@ def probe_core_7() -> Verdict: ) +@patch("amplifier_memory.runtime.readiness", lambda home=None: {}) +@patch("amplifier_memory.service.which_platform", lambda platform=None: platform or "systemd") def probe_core_8() -> Verdict: """init: the store, the timer, and the three arms that install no timer. diff --git a/conformance/suggestions/run.py b/conformance/suggestions/run.py index 1fe5dc8..e806cb6 100755 --- a/conformance/suggestions/run.py +++ b/conformance/suggestions/run.py @@ -41,6 +41,7 @@ from contextlib import contextmanager from datetime import UTC, datetime, timedelta from pathlib import Path +from unittest.mock import patch if __package__ in (None, ""): # allow `python conformance/suggestions/run.py` sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -219,6 +220,7 @@ def __call__(self, argv: Sequence[str]) -> tuple[int, str]: # --------------------------------------------------------------------------- the probes +@patch("amplifier_memory.runtime.readiness", lambda home=None: {}) def probe_core_1() -> Verdict: """A timer, not a service: units render, install rolls back, nothing is resident.""" with fixture() as (_home, _base), tempfile.TemporaryDirectory(prefix="units-") as units: @@ -430,12 +432,11 @@ def probe_core_3() -> Verdict: "suggest.PROMPT is byte-identical to the ratified addendum's revised question " "(lifted from contracts/suggestions.v3.v6-candidate.md, not retyped); the prompt " "actually sent carries and complete bounded decline packets, and " - "exactly one call was made for one session. Which model answers it follows §3's " - "order over the INSTANCE's config.yaml (store.v3 §2): provider/model set -> " - "`-p luna -m gpt-5.6-luna`; unset on a host whose `amplifier run --help` documents " - f"{suggest.MODEL_ROLE_FLAG} -> `{suggest.MODEL_ROLE_FLAG} fast`, the shipped ROLE; " - "unset on a host without it -> inherited, adding no flag, so the argv is byte for " - "byte what it always was. The shipped default carries no provider id, because a " + "exactly one injected call was made for one session. The legacy flag serializer " + "still preserves explicit provider/model, role and inherited choices. This " + "fixture does not execute those flags. The installed direct-provider selection " + "and default suggestion callback are covered by tests/test_inference.py and " + "tests/test_suggest.py. The shipped default carries no provider id, because a " "provider id names one machine's account" ) @@ -577,6 +578,8 @@ def probe_core_7() -> Verdict: ) +@patch("amplifier_memory.runtime.readiness", lambda home=None: {}) +@patch("amplifier_memory.service.which_platform", lambda platform=None: "systemd") def probe_core_8() -> Verdict: """Bounded cost, visible: <=30 calls, and doctor shows what the run did.""" with fixture() as (home, base), tempfile.TemporaryDirectory(prefix="units-") as units: @@ -657,12 +660,11 @@ def capture_was_touched(*_args: object, **_kwargs: object) -> Path: assert "last run" in row.detail and "last outcome" in row.detail, row.render() assert rows["inbox"].detail.startswith("1 pending"), rows["inbox"].render() assert report.exit_code == 0, report.render() - # The judge, named, in all three states - including what an inherited night costs. - assert suggest.INHERITED in named_inherited, named_inherited - assert f"${suggest.INHERITED_COST_USD:.3f}/call" in named_inherited, named_inherited - assert suggest.INHERITED_COST_SOURCE in named_inherited, named_inherited - assert f"${suggest.INHERITED_COST_USD * suggest.MAX_CALLS:.2f}" in named_inherited - assert suggest.MODEL_ROLE_FLAG in named_role and "role fast" in named_role, named_role + # The requested judge is named without guessing a price or a resolved model. + assert "role fast requested" in named_inherited, named_inherited + assert "Selected provider, model provenance and usage" in named_inherited, named_inherited + assert "fails visibly" in named_inherited and "private memory session" in named_inherited + assert "model-role resolver" in named_role and "role fast" in named_role, named_role assert "provider luna" in named_provider, named_provider assert disabled_call.prompts == [], "a disabled instance spent a model call" assert disabled.status == f"disabled:instance={home} (enabled: false)", disabled.log_line @@ -678,11 +680,8 @@ def capture_was_touched(*_args: object, **_kwargs: object) -> Path: "can fail the check. The judge is NAMED in all three states by one library " "function, `suggest.judge_detail`, which doctor's `llm judge` row is a thin adapter " f"over: a configured provider ({named_provider!r}); the role this host resolved " - f"({named_role!r}); or inherited, with the app's default AND its measured per-call " - f"cost - ${suggest.INHERITED_COST_USD:.3f}/call from " - f"{suggest.INHERITED_COST_SOURCE}, up to " - f"${suggest.INHERITED_COST_USD * suggest.MAX_CALLS:.2f} for a full night - so an " - "unattended night's bill is read before the night, never after it. store.v3 §11's " + f"({named_role!r}); or the host role policy requested with usage reported " + "at completion and unknown costs left unknown. store.v3 §11's " f"discriminating pair measured enabled calls={len(call.prompts)} versus disabled " f"calls={len(disabled_call.prompts)}; the disabled pass did not discover the capture and " f"added one deliberate outcome line ({disabled.log_line!r}), never `degraded:`" diff --git a/contracts/suggestions.v3.v7-candidate.md b/contracts/suggestions.v3.v7-candidate.md new file mode 100644 index 0000000..6d825ef --- /dev/null +++ b/contracts/suggestions.v3.v7-candidate.md @@ -0,0 +1,46 @@ +target: contracts/suggestions.v3.md + +# suggestions.v3.v7 candidate — private portable inference (DRAFT) + +The owner requires portable memory inference without amplifier-app-cli. Unified's +mounted-provider consolidation remains direct; this change replaces the separate +portable suggestion runner's amplifier-run subprocess. + +Core/Foundation are declared standalone runtime dependencies. Explicit `setup` +prepares configured provider/routing sources in a memory-owned runtime subtree and +installs dependencies in the standalone tool environment. Normal inference cannot +install, refresh sources or invoke another application. Offline readiness checks +precede timer enablement and reject changed settings/packages/source contents. + +Each suggestion request uses an AmplifierSession for public module mounting and +cleanup, then makes one tool-free typed provider call without an agent loop. +Shared configuration is read with Foundation's scoped settings API. Account, +model, role, explicit source and credential configuration are preserved within +the documented supported settings shape. Unsupported bundle/module composition +requires host-resolved inference; no account is silently substituted. + +Generated session metadata, transcript and usage events live below +`store_home()/runtime/jobs`. Prepared caches live below the same runtime root. +They are internal memory jobs, excluded from suggestion source ingestion and Git. +Existing memory homes, histories, source sessions and provenance files are preserved. +Provider-owned credential refresh writes retain their configured ownership. + +Healthy provider requests have no fixed elapsed completion deadline. Existing +per-pass call/input budgets remain and output is capped at4096tokens. Cancellation +propagates; owned provider mounts clean up on success, error and cancellation. +Host-supplied providers are never closed by complete_once. Usage records identify +the selected provider; model provenance distinguishes response-reported from +request-selected values. SDK defaults not present in the request or response stay +unknown, as do unknown usage and cost. An inherited role without a prepared +resolver fails visibly instead of silently selecting a provider default. + +The existing human eligibility, fenced prompt, quote verification, inbox review, +disabled-instance and source-history preservation rules remain. There is no +historical archive/delete/backfill. CLI doctor/update have no automatic app-cli +prerequisite; optional legacy interop remains explicit. + +Tests cover Core2 private sessions, default suggest callback, readiness, cancellation, +source/config drift and private persistence with synthetic providers. Fresh uv-tool +acceptance additionally runs real Foundation source preparation and routing. +No real provider quality, real credential refresh or operating-system timer run is +claimed by these isolated checks. diff --git a/docs/PRIVATE_INFERENCE_VALIDATION.md b/docs/PRIVATE_INFERENCE_VALIDATION.md new file mode 100644 index 0000000..541bf0c --- /dev/null +++ b/docs/PRIVATE_INFERENCE_VALIDATION.md @@ -0,0 +1,64 @@ +# Private inference validation + +The portable runner is independent of amplifier-app-cli. Unified's existing +mounted-provider consolidation path is unchanged. + +Validated on 2026-09-23 with Python 3.13, published Core 2.0.1 and Foundation +2a63c56ef11e5e9b49a1268c4845e3046d397230: + +- A fresh, non-editable uv-tool install included its declared runtime dependencies + and no amplifier-app-cli distribution/executable. Core came from the public + PyPI wheel; no Core build or downgrade occurred. +- The actual `setup` command prepared sources in the memory instance, copied local + modules before installation, and used its own declared uv binary. The actual + console `suggest` command then used the default callback, real Core mounting, + real Foundation scoped settings and the real hooks-routing module. +- A synthetic provider received one tool-free request for the named account and + routed model. It returned one verified proposal and measured usage. Inference + could not connect to the network, run a non-Git subprocess, or write Python + files outside the private test root. Source transcripts/settings stayed exact. +- Service installation was refused before setup and rendered after offline + readiness, without starting any operating-system service. +- `tests/test_runtime.py` covers failure/cancellation/cleanup, account and role + precedence, setup failure preserving the prior receipt, disabled instances, + changed sources, library environment preservation, standalone credential + loading, and exclusion of private jobs from Git and source ingestion. + +The full library suite passed 480 tests. Ten host-tool documentation checks failed +on this Mac and reproduced unchanged on base b224eea: Linux `systemctl` is absent, +and Git manual overstrike formatting breaks literal argument searches. After the +last setup safeguards, the focused runtime suite passed all 18 tests. Ruff and +`uv lock --check` passed. Synthetic timer fixtures explicitly model systemd; +they do not prove launchd/systemd activation on a real host. + +The fresh-tool proof above applies to implementation commit `65a6128`. Subsequent +review corrections add pytest-asyncio to the declared development environment, +reject missing role resolvers (including bundle-only routing), record response vs +request model provenance, and reject dangling runtime ignore-file symlinks. The +ordinary focused suite on those corrections passed 146 tests, with Core's pytest +plugin enabled. Ruff, formatting and lock checks passed. + +The separate module suites reported 206 passed, one skipped and one failure: +the Mac resolves `/home/...` under `/System/Volumes/Data`, violating a Linux-style +slug assertion. That failure reproduced unchanged on base b224eea. The suggestion +and CLI conformance kits run within the focused tests. The separate injection kit +reported no Broken clauses; the tool kit's scoped-review-ID wording failure and +the budget kit's phrase-check failure both reproduced on the same baseline. +The measured context budget was 422 of 500 tokens. These residuals are not a green +conformance gate; the implementation stays draft. + +Run the focused acceptance locally: + +```bash +uv sync +uv run pytest tests/test_runtime.py tests/test_inference.py tests/test_suggest.py +``` + +The fresh-tool exercise additionally used controlled local module sources and +real routing, never a provider account or a model. It does not prove provider SDK +compatibility for every configured source, real OAuth refresh, model quality, or +live service operation. Arbitrary host bundle composition remains an explicit +host-resolved-inference requirement. No saved history was migrated or deleted. +The AGENTS.md real-host smoke (one session saves a memory, another loads it) and +model-behavior conformance remain unfulfilled; synthetic tool calls do not replace +them. No live run or OS-service activation is included in this validation. diff --git a/docs/SESSIONLESS_INFERENCE.md b/docs/SESSIONLESS_INFERENCE.md new file mode 100644 index 0000000..0872c59 --- /dev/null +++ b/docs/SESSIONLESS_INFERENCE.md @@ -0,0 +1,90 @@ +# Memory suggestion inference + +A portable `run_suggest` pass makes one tool-free provider request per eligible +source session, within its call/input budgets. Its private Core session mounts and +cleans up prepared modules; it never executes an agent loop or registers a normal +chat. Session metadata, transcripts and usage events stay under the resolved +memory home's `runtime/jobs`. Existing source histories and quote verification +are unchanged. This filename is retained for existing links. + +Unified's consolidation uses its own mounted provider directly. Neither path +requires an amplifier-app-cli package, executable or import. + +## Host-owned providers + +An async host can use `complete_once` from `amplifier_memory.inference` with its +mounted `providers`, `default_provider` and public `model_role_resolver`. +`CallConfig` carries the memory instance's explicit provider/model/bundle or role. +A synchronous `run_suggest(model_call=...)` callback can return `Completion` or a +plain string. The host retains provider ownership: the adapter never closes shared +clients or changes environment. Cancellation propagates and no retry/tool loop is +added. Safe lifecycle observations exclude prompts and raw error bodies. + +## Prepared standalone runtime + +The package declares Core ≥2.0.1, Foundation and uv dependencies. Storage imports +remain lightweight. Run `amplifier-memory setup --workspace /path/to/project` +explicitly to resolve configured sources and install their dependencies in the +tool environment. Private source copies, caches and preparation receipts live +under `store_home()/runtime`. Inference and readiness never install or activate +modules. Timer installation/start/restart requires offline readiness. + +Foundation merges shared, workspace and workspace-local settings. The supported +shape is `config.providers` plus an explicit `config.hooks` entry for +`hooks-routing`. Provider account `id`/`instance_id`, configuration and priority +are retained. `sources.modules` overrides take precedence over module-row sources. +Known provider, loop-basic, context-simple and routing source defaults track their +Microsoft repositories' `main`; custom providers require explicit sources. +Preparation receipts bind exact installed contents and configuration. They do not +prove every upstream default SDK works or perform an inference-time freshness check. + +For example, a host that intends the balanced routing policy can declare: + +```yaml +config: + providers: + - id: work-openai + module: provider-openai + config: + api_key: ${OPENAI_API_KEY} + hooks: + - module: hooks-routing + config: + default_matrix: balanced +``` + +Keep the user's actual routing configuration; this example is not a policy to +substitute for a configured bundle. Arbitrary bundle includes are not composed. +If a requested role (normally `fast`) has no prepared resolver, setup/inference +fails visibly. Configure the routing hook explicitly, choose a provider/model +explicitly, or use host-resolved inference. A role never silently falls through +to a provider default. An embedding caller that explicitly supplies no role can +use an unambiguous default provider. Explicit selections are never rerouted. + +`${NAME}` credential references read shared `keys.env`, with process environment +taking precedence. The dedicated console process also supplies missing environment +keys for SDK-owned credential loading; embedding APIs never modify environment. +Provider-owned OAuth refresh writes retain the provider's configured ownership. +No credentials are copied into job receipts. + +Only providers, explicit routing and the minimal loop/context lifecycle are +mounted. General user hooks, tools, recursive memory jobs and workspace instructions +are excluded. Owned sessions clean up on success, error and cancellation. Output +is capped at 4,096 tokens; healthy requests have no fixed completion deadline. + +## Receipts and limits + +`SuggestReport.inference` and private job receipts carry provider identity and +measured usage. Model provenance distinguishes a response-reported model +(`modelSource: response`) from the explicit/routed request (`request`). If neither +is available, the model remains unknown; an SDK's declared default is not claimed +as a confirmed response model. Unknown token/cost values remain unknown. + +Explicit bundle selection and unsupported module composition require host-resolved +inference. Routing options outside supported typed request knobs fail visibly. +Readiness verifies environment, settings and source contents without mounting a +provider; it does not prove live authentication or provider SDK compatibility. +Legacy pure argv/flag helpers remain import-compatible but no runner executes them. + +See [Private inference validation](PRIVATE_INFERENCE_VALIDATION.md) for the exact +synthetic fresh-install acceptance and remaining real-host gates. diff --git a/pyproject.toml b/pyproject.toml index a9f16cb..839a553 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,18 @@ version = "0.1.0" description = "The one home for memory-store behaviour: writer, init, provenance. Every surface is a thin adapter over this." readme = "README.md" requires-python = ">=3.11" -dependencies = ["click>=8.1", "pyyaml>=6"] +dependencies = [ + "click>=8.1", "pyyaml>=6", "uv>=0.8", + "amplifier-core>=2.0.1", + "amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@main", +] + +[tool.uv] +no-build-package = ["amplifier-core"] + +[[tool.uv.index]] +url = "https://pypi.org/simple" +default = true [project.scripts] amplifier-memory = "amplifier_memory.cli:main" @@ -20,7 +31,7 @@ allow-direct-references = true packages = ["src/amplifier_memory"] [dependency-groups] -dev = ["pytest>=8", "ruff>=0.6"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.6"] [tool.pytest.ini_options] testpaths = ["tests", "ledger/checks"] diff --git a/src/amplifier_memory/__init__.py b/src/amplifier_memory/__init__.py index eba8d2a..d0bff14 100644 --- a/src/amplifier_memory/__init__.py +++ b/src/amplifier_memory/__init__.py @@ -3,11 +3,9 @@ Import this and you have the whole store: `init`, `save`, `edit`, `forget`, `list_memories`, `log_usage`, `record_citation`, `why`, `store_home`, the reports `status`, `review`, -`doctor` and `update_check`, and the exceptions a refusal raises. It depends on no -`amplifier_*` package and on no CLI framework — PyYAML, which reads the instance's -`config.yaml` (store.v3 §2), is its one third-party dependency — so the CLI, the memory tool, -the inject hook and the Phase 2 job are all thin adapters over the same code -(cli.v2 Core 9). +`doctor` and `update_check`, and the exceptions a refusal raises. Core/Foundation are declared runtime dependencies for portable inference. Store +imports stay lightweight: runtime modules are loaded only for setup or inference. +The CLI, memory tool, inject hook and job remain adapters over this library. Every `amplifier-memory` verb is one of these functions plus printing: @@ -299,3 +297,19 @@ "GitFailed", "PageOutOfRange", ] + + +def prepare_inference(home=None, *, workspace=None): + """Explicit standalone runtime setup; no provider completion.""" + import asyncio + + from .runtime import prepare + + return asyncio.run(prepare(home, workspace=workspace)) + + +def run_standalone_suggest(home=None): + """Standalone CLI only: owns its credential environment for provider mounting.""" + from .runtime import standalone_suggest + + return standalone_suggest(home) diff --git a/src/amplifier_memory/cli.py b/src/amplifier_memory/cli.py index e1303d0..fe64d5f 100644 --- a/src/amplifier_memory/cli.py +++ b/src/amplifier_memory/cli.py @@ -83,11 +83,30 @@ def main(ctx: click.Context, home: str | None) -> None: def init(home: str | None, no_timer: bool) -> None: """Create the instance (a git repo) and install its daily suggest timer. - This is the only setup step. A second run changes nothing. + A second run preserves existing data. Run setup before enabling the daily timer. """ click.echo(amplifier_memory.build_instance(home, timer=not no_timer).render()) +@main.command() +@click.option( + "--workspace", + type=click.Path(file_okay=False), + default=None, + help="Read provider settings from this workspace (remembered for the timer).", +) +@click.pass_obj +def setup(home: str | None, workspace: str | None) -> None: + """Prepare this instance's providers and private inference runtime; no model call.""" + try: + result = amplifier_memory.prepare_inference(home, workspace=workspace) + except RuntimeError as exc: + _die(exc) + click.echo( + f"Prepared {len(result['modulePaths'])} modules. Run amplifier-memory service install." + ) + + @main.command() @click.pass_obj def status(home: str | None) -> None: @@ -185,7 +204,7 @@ def service(home: str | None, verb: str) -> None: ) @click.pass_obj def update(home: str | None, after_upgrade: bool) -> None: - """Refresh all three installed copies (tool, bundle cache, venv library), then doctor.""" + """Update this standalone tool, check inference readiness, then doctor.""" result = amplifier_memory.run_update(after_upgrade=after_upgrade, home=home) click.echo(result.render()) raise SystemExit(result.exit_code) @@ -199,7 +218,7 @@ def suggest(home: str | None, last: bool) -> None: if last: click.echo(amplifier_memory.suggest_status(home)) return - click.echo(amplifier_memory.run_suggest(home).log_line) + click.echo(amplifier_memory.run_standalone_suggest(home).log_line) # cli.v3 Core 1: `upgrade` is an alias of `update`, and hidden so `--help` lists the diff --git a/src/amplifier_memory/doctor.py b/src/amplifier_memory/doctor.py index 21c5894..8ab9bb1 100644 --- a/src/amplifier_memory/doctor.py +++ b/src/amplifier_memory/doctor.py @@ -525,6 +525,12 @@ def last_cost(home: str | os.PathLike[str] | None = None) -> str: fields = parse_log_line(line) provider = fields.get("provider", "?") model = f" model={fields.get('model')}" if fields.get("model") else "" + if "cost_usd_known" in fields: + return ( + f"last run {fields.get('ts', '?')}: {fields.get('calls', '?')} model call(s) " + f"on provider={provider}{model}; reported cost ${fields['cost_usd_known']}, " + f"cost unknown for {fields.get('cost_unknown', '?')} call(s)" + ) return ( f"last run {fields.get('ts', '?')} cost {fields.get('calls', '?')} model call(s) " f"on provider={provider}{model}" @@ -546,15 +552,10 @@ def llm_row( same day. This row adds the **last run's measured cost**, which the clause asks for and the job's own sentence does not carry, and the remedy for an unusable file. - Three states, and the middle one is the point of the clause: - - * **configured** — `config.yaml` names a provider (and maybe a model): print them. - * **role** — nothing is named, but this host resolves roles: the recorded role and - the flag it resolves through. While `amplifier run` documents no `--model-role`, - this arm cannot fire on this device and the sentence says so in the next one. - * **inherited** — the pass runs on the app's default. The row says `inherits the - app's default`, names that default, and carries the measured cost, so a bill - nobody chose is visible instead of silent. + Explicit provider/model/bundle choices are displayed as configured. Otherwise + the row names the requested role and host default, not an assumed model or price. + Actual completion receipts supply the last run's account, model and known usage. + Legacy help inputs remain display-only compatibility; no CLI is probed. An unusable `config.yaml` is WARN, never FAIL: the run still happens and still inherits (suggestions.v2 Core 10), so nothing is broken — but the user believes they @@ -638,7 +639,17 @@ def doctor( rows.append(timer_row(home=path, runner=service_runner, config_dir=config_dir)) rows.append(substrate_row(base_path)) rows.append(llm_row(llm, home=path)) - installed = installed_commits() if installed_sha is _UNSET else installed_sha + from .inference import InferenceError + from .runtime import readiness + + try: + readiness(path) + rows.append( + DoctorRow("inference runtime", OK, "prepared; check is offline and makes no model call") + ) + except InferenceError as exc: + rows.append(DoctorRow("inference runtime", WARN, str(exc))) + installed = installed_commit() if installed_sha is _UNSET else installed_sha remote = remote_commit() if remote_sha is _UNSET else remote_sha rows.append( update_check( @@ -675,21 +686,10 @@ def doctor( ) UPDATE_STEPS = ( - f"uv tool upgrade amplifier-memory (the CLI, from {REPO_URL}@{PINNED_REF})", - ( - f"git fetch origin && git reset --hard origin/{PINNED_REF} in every bundle cache " - "clone (~/.amplifier/cache/ and cache/skills/) - what sessions load modules and " - f"skills from; a clone that is not a git checkout falls back to `amplifier bundle " - f"remove {_APP_URI} --app` then `add`" - ), - ( - "uv pip install --python --refresh " - f"--reinstall-package amplifier-memory 'amplifier-memory @ git+{REPO_URL}@{PINNED_REF}' " - " (the library those modules import; skipped with a warning when no amplifier " - "venv is found)" - ), - "restart the suggest timer, if one is installed (Phase 2 only)", - "run `amplifier-memory doctor`", + f"uv tool upgrade amplifier-memory (standalone tool, from {REPO_URL}@{PINNED_REF})", + "check private inference readiness; run `amplifier-memory setup` if sources or packages changed", + "restart the suggest timer only when installed and inference is ready", + "run `amplifier-memory doctor`; other host environments are managed by their own updater", ) diff --git a/src/amplifier_memory/inference.py b/src/amplifier_memory/inference.py new file mode 100644 index 0000000..50d69fd --- /dev/null +++ b/src/amplifier_memory/inference.py @@ -0,0 +1,218 @@ +"""One tool-free provider request using host-owned providers. + +The portable entrypoint delegates to the prepared Core/Foundation private-session +runtime. Hosts can call complete_once with providers they already own. +""" + +from __future__ import annotations + +import asyncio +import inspect +import os +import re +import shlex +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +from .llm_config import CallConfig + +MAX_OUTPUT_TOKENS = 4096 + + +class InferenceError(RuntimeError): + """Safe diagnostic; provider exception bodies may contain credentials/input.""" + + +@dataclass(frozen=True) +class Completion: + text: str + provider: str + model: str | None + usage: dict[str, Any] | None + model_source: str | None = None + + +async def complete_once( + prompt: str, + *, + providers: Mapping[str, Any], + call: CallConfig | None = None, + role_resolver: Any = None, + default_provider: str | None = None, + observe: Callable[[dict], Any] | None = None, + resolved_bundle: str | None = None, +) -> Completion: + """Use the host's existing provider/auth/routing, without a tool loop. + + Provider ownership stays with the host. This function propagates cancellation + but never closes a shared provider, mutates its configuration, or starts a + session. ``observe`` receives safe lifecycle facts, never prompt/error bodies. + """ + from amplifier_core.message_models import ChatRequest, ChatResponse, Message + + choice = call or CallConfig() + if choice.bundle and choice.bundle != resolved_bundle: + raise InferenceError("Configured bundle requires host-resolved inference; no call made") + if not isinstance(prompt, str) or not prompt.strip(): + raise InferenceError("Memory inference requires a nonempty prompt") + selected = choice.provider or default_provider + options: dict[str, Any] = {} + if choice.inherits and choice.role: + if role_resolver is None: + raise InferenceError( + "Requested model role has no prepared resolver. Configure routing, " + "explicitly choose provider/model, or use host-resolved inference" + ) + try: + preferences = await role_resolver.resolve(choice.role) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - sanitize provider-backed resolver failures + raise InferenceError(f"Model-role resolution failed ({type(exc).__name__})") from None + if not preferences: + raise InferenceError("Configured model role has no available provider; no call made") + preference = preferences[0] + selected = preference.provider + options = dict(preference.config or {}) + options["model"] = preference.model + if selected is None: + if len(providers) != 1: + raise InferenceError("No unambiguous default provider; supply host-resolved inference") + selected = next(iter(providers)) + if selected not in providers: + raise InferenceError("Configured provider is unavailable; no call made") + provider = providers[selected] + if choice.model: + options["model"] = choice.model + # Routing may supply inference knobs, not messages, tools, credentials, + # endpoints or a second request. Unknown knobs fail rather than disappear. + allowed = {"model", "temperature", "max_output_tokens", "top_p", "stop", "reasoning_effort"} + if set(options) - allowed: + raise InferenceError("Routing returned unsupported inference options; no call made") + cap = options.get("max_output_tokens") + if cap is not None and (isinstance(cap, bool) or not isinstance(cap, int) or cap < 1): + raise InferenceError("Invalid routing output budget; no call made") + options["max_output_tokens"] = min(cap or MAX_OUTPUT_TOKENS, MAX_OUTPUT_TOKENS) + request = ChatRequest( + messages=[Message(role="user", content=prompt)], + tools=[], + tool_choice="none", + timeout=None, + metadata={"purpose": "memory.suggestion"}, + **options, + ) + + async def event(status: str, **facts): + if observe is not None: + value = observe( + { + "purpose": "memory.suggestion", + "status": status, + "provider": selected, + "model": options.get("model"), + **facts, + } + ) + if inspect.isawaitable(value): + await value + + await event("started") + try: + # No retry/fallback loop and no elapsed completion deadline here. + response = ChatResponse.model_validate(await provider.complete(request)) + if response.tool_calls or any( + block.type in {"tool_call", "tool_use"} for block in response.content + ): + raise InferenceError("Provider returned a tool call to a tool-free request") + text = "".join(block.text for block in response.content if block.type == "text") + if not text.strip(): + raise InferenceError("Provider returned no assistant text") + if response.finish_reason not in {None, "stop", "end_turn", "completed", "stop_sequence"}: + raise InferenceError("Provider did not complete the memory response") + usage = ( + response.usage.model_dump( + mode="json", + include={ + "input_tokens", + "output_tokens", + "total_tokens", + "reasoning_tokens", + "cache_read_tokens", + "cache_write_tokens", + "cost_usd", + }, + ) + if response.usage is not None + else None + ) + reported_model = getattr(response, "model", None) + requested_model = options.get("model") + result = Completion( + text, + selected, + reported_model or requested_model, + usage, + "response" if reported_model else "request" if requested_model else None, + ) + await event( + "completed", usage=usage, resolvedModel=result.model, modelSource=result.model_source + ) + return result + except asyncio.CancelledError: + await event("cancelled") + raise + except Exception as exc: + await event("failed", errorType=type(exc).__name__) + if isinstance(exc, InferenceError): + raise + raise InferenceError(f"Memory provider request failed ({type(exc).__name__})") from None + + +def _expand(value, environment): + """Materialize explicit environment placeholders without guessing accounts.""" + if isinstance(value, str): + + def substitute(match): + name = match.group(1) + if name not in environment: + raise InferenceError("A configured provider environment value is unavailable") + return environment[name] + + return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", substitute, value) + if isinstance(value, dict): + return {key: _expand(item, environment) for key, item in value.items()} + if isinstance(value, list): + return [_expand(item, environment) for item in value] + return value + + +def _credential_environment(home): + """Read referenced shared keys without changing the caller's environment.""" + keys = {} + path = home / "keys.env" + if path.is_file(): + for raw in path.read_text().splitlines(): + line = raw.strip().removeprefix("export ") + if not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name.strip()): + keys[name.strip()] = " ".join(shlex.split(value, comments=True)) + return {**keys, **os.environ} + + +async def complete_installed( + prompt: str, *, call: CallConfig | None = None, home=None +) -> Completion: + """Use the explicitly prepared private Core/Foundation session runtime.""" + from .runtime import complete + + return await complete(prompt, home=home, call=call) + + +def default_completion(prompt: str, *, call: CallConfig | None = None, home=None) -> Completion: + """Synchronous timer/library entrypoint; async hosts use complete_once.""" + if os.environ.get("PYTEST_CURRENT_TEST"): + raise InferenceError("Tests must inject model_call; real inference is disabled") + return asyncio.run(complete_installed(prompt, call=call, home=home)) diff --git a/src/amplifier_memory/llm_config.py b/src/amplifier_memory/llm_config.py index ca6cc5d..7d9983a 100644 --- a/src/amplifier_memory/llm_config.py +++ b/src/amplifier_memory/llm_config.py @@ -8,11 +8,8 @@ every writer in this library refuses with one line. * ``llm:`` (suggestions.v1 §8) — which model each of the job's LLM calls uses. The job makes exactly one kind of call today: the suggestions.v1 §3 judge, one call per - session in the daily pass. Until this knob existed it ran ``amplifier run - --output-format json`` with no ``-p/-m/-B`` and inherited whatever the amplifier - CLI's starred provider happened to be — on the steward's device the most expensive - variant measured (`evaluations/model-class/RESULTS-2026-09-06-pilot.md`: opus, - $0.276/call) while a measured-clean alternative cost $0.02. + session in the daily pass. The host resolves the requested provider, model or role + and the run records actual measured usage when the provider supplies it. The file -------- @@ -22,9 +19,9 @@ llm: judge: role: fast # recorded and logged; resolved only once the host can - provider: "" # an amplifier provider id -> `amplifier run -p` - model: "" # optional -> `-m` - bundle: "" # optional -> `-B` + provider: "" # a configured host provider instance + model: "" # optional exact model + bundle: "" # optional host-resolved bundle **Inside the instance, because the instance is the unit.** store.v3 §1 makes a store an *instance* — there may be more than one, each its own git repository — and §2 gives @@ -33,9 +30,10 @@ disagree about the judge, or about `enabled`, without either knowing about the other. There is nothing left for a device-wide config file to configure, and none exists. -`role` is recorded and logged but not resolved: `amplifier run` has no `--model-role` -today (the ask to app-cli is tracked in the upstream workspace, not in this repo). When -it grows one, this key is already here and no schema changes. +`role` is resolved through a host's public model-role resolver. An explicit +provider/model/bundle takes precedence; an explicit bundle requires host-resolved +inference. The private standalone runtime prepares sources only during explicit setup. +Inference never installs modules or composes an arbitrary user agent bundle. Whole-file semantics -------------------- @@ -73,7 +71,7 @@ CALL_TYPES: tuple[str, ...] = (JUDGE,) #: The keys a call table may carry, in the order `flags()` emits them. KEYS: tuple[str, ...] = ("provider", "model", "bundle", "role") -#: The role recorded when the user names none. Semantic, host-resolved one day. +#: The role requested from the host resolver when the user names none. DEFAULT_ROLE = "fast" #: The keys a `config.yaml` may carry at the top level. @@ -84,8 +82,9 @@ class CallConfig: """One LLM call type's choice: an amplifier provider id, and optionally a model/bundle. - Empty strings mean "say nothing and inherit the CLI default" — the behaviour the job - had before this file existed, and still its behaviour with no file present. + Empty provider/model/bundle fields request the configured role. A missing + resolver fails visibly. An embedding caller may explicitly pass an empty role + to use an unambiguous default provider. Explicit selections are never substituted. """ provider: str = "" @@ -95,16 +94,11 @@ class CallConfig: @property def inherits(self) -> bool: - """True when this call adds no flag at all, so `amplifier run`'s own default wins.""" + """True when role/default selection is allowed instead of an explicit choice.""" return not (self.provider or self.model or self.bundle) def flags(self) -> list[str]: - """The `amplifier run` flags this choice adds — only the ones actually set. - - `-p/-m/-B` are the CLI's own short forms, verified against `amplifier run --help` - by `tests/test_suggest.py::test_the_default_argv_matches_amplifier_run_help`, - which prints the help it relied on (AGENTS.md rule 5). - """ + """Serialize legacy CLI choices; inference does not execute these flags.""" out: list[str] = [] if self.provider: out += ["-p", self.provider] @@ -117,7 +111,7 @@ def flags(self) -> list[str]: def render(self) -> str: """What `doctor` prints for this choice — the resolved flags, or the inheritance.""" if self.inherits: - return "inherits the CLI default" + return "requests the host default" named = ( ("provider", self.provider), ("model", self.model), @@ -148,7 +142,7 @@ def usable(self) -> bool: return self.reason is None def call(self, name: str = JUDGE) -> CallConfig: - """One call type's choice; an unconfigured type inherits the CLI default.""" + """One call type's choice; an unconfigured type requests the host default.""" return self.calls.get(name, CallConfig()) def source(self) -> str: @@ -189,9 +183,9 @@ def default_body(*, enabled: bool = DEFAULT_ENABLED) -> str: f"{TABLE}:\n" f" {JUDGE}: # suggestions.v1 \u00a73's judge, one call per session\n" f' role: "{DEFAULT_ROLE}" # recorded and logged; resolved when the host can\n' - f' provider: "" # an amplifier provider id -> `amplifier run -p`\n' - f' model: "" # optional -> `-m`\n' - f' bundle: "" # optional -> `-B`\n' + f' provider: "" # a configured host provider instance\n' + f' model: "" # optional exact model\n' + f' bundle: "" # requires host-resolved inference\n' ) diff --git a/src/amplifier_memory/runtime.py b/src/amplifier_memory/runtime.py new file mode 100644 index 0000000..70c66d3 --- /dev/null +++ b/src/amplifier_memory/runtime.py @@ -0,0 +1,442 @@ +"""Explicit setup and private sessions for portable memory inference. + +Foundation owns settings merging and module preparation. Core owns mount/cleanup. +Only setup activates sources; readiness and ordinary jobs cannot install anything. +""" + +from __future__ import annotations + +import asyncio +import copy +import hashlib +import importlib +import importlib.metadata +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +from .inference import InferenceError, _credential_environment, _expand, complete_once +from .llm_config import CallConfig, load +from .store import store_home + +PROVIDER_SOURCES = { + f"provider-{name}": f"git+https://github.com/microsoft/amplifier-module-provider-{name}@main" + for name in ("openai", "anthropic", "azure-openai", "gemini", "ollama") +} + +BASE_SOURCES = { + "loop-basic": "git+https://github.com/microsoft/amplifier-module-loop-basic@main", + "context-simple": "git+https://github.com/microsoft/amplifier-module-context-simple@main", +} + + +def runtime_home(home=None): + return store_home(home).resolve() / "runtime" + + +def _json(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _hash(value): + return hashlib.sha256(value.encode()).hexdigest() + + +def _write(path, value): + from amplifier_foundation.settings import atomic_write + + atomic_write(path, json.dumps(value, indent=2) + "\n", private=True) + + +def _private_root(home): + if not load(home).enabled: + raise InferenceError("Memory instance is disabled; no runtime written") + root = runtime_home(home) + if root.is_symlink(): + raise InferenceError("Memory runtime cannot be a symlink") + root.mkdir(parents=True, exist_ok=True, mode=0o700) + ignore = root / ".gitignore" + if ignore.is_symlink(): + raise InferenceError("Memory runtime ignore file cannot be a symlink") + if not ignore.exists(): + ignore.write_text("# Generated private job state; never memory or committed content.\n*\n") + elif "*" not in ignore.read_text().splitlines(): + raise InferenceError("Memory runtime has an incompatible ignore file; no job started") + return root + + +def _versions(): + version = importlib.metadata.version("amplifier-core") + if tuple(int(part) for part in version.split(".")[:3]) < (2, 0, 1): + raise InferenceError("Memory runtime requires published amplifier-core >=2.0.1") + packages = {} + for distribution in importlib.metadata.distributions(): + name = distribution.metadata["Name"].lower().replace("_", "-") + direct = distribution.read_text("direct_url.json") or "" + packages[name] = {"version": distribution.version, "sourceHash": _hash(direct)} + return {"packages": packages, "memoryCode": _tree(Path(__file__).parent)} + + +def _plan(paths, choice): + from amplifier_foundation.settings import read_settings + + if choice.bundle: + raise InferenceError( + "Explicit bundle requires host-resolved inference; no account substituted" + ) + settings = read_settings(paths) + config = settings.get("config", {}) + if settings.get("modules") or settings.get("overrides"): + raise InferenceError("These module overrides require host-resolved inference") + overrides = settings.get("sources", {}).get("modules", {}) + if not isinstance(overrides, dict): + raise InferenceError("Module sources must be a mapping") + providers = [] + for original in config.get("providers", []): + if not original.get("enabled", True): + continue + row = copy.deepcopy(original) + row.pop("enabled", None) + module = row.get("module", "") + if not isinstance(module, str) or not re.fullmatch(r"provider-[a-z0-9][a-z0-9-]*", module): + raise InferenceError("Invalid configured provider module") + row["instance_id"] = row.pop("id", None) or row.get("instance_id") or module[9:] + row["source"] = overrides.get(module) or row.get("source") + if not row["source"]: + row["source"] = PROVIDER_SOURCES.get(module) + if not row["source"]: + raise InferenceError("Custom provider needs an explicit source before setup") + priority = row.get("config", {}).get("priority", 100) + if isinstance(priority, bool) or not isinstance(priority, (int, float)): + raise InferenceError("Provider priority must be numeric") + providers.append(row) + if not providers: + raise InferenceError("No configured providers; configure a shared provider before setup") + names = [row["instance_id"] for row in providers] + if len(names) != len(set(names)): + raise InferenceError("Duplicate configured provider account") + if choice.provider and choice.provider not in names: + raise InferenceError("Configured provider account is unavailable") + hooks = [ + copy.deepcopy(row) + for row in config.get("hooks", []) + if row.get("module") == "hooks-routing" and row.get("enabled", True) + ] + if choice.inherits and choice.role and not hooks: + raise InferenceError( + "Requested model role has no prepared resolver. Configure config.hooks routing, " + "explicitly choose provider/model, or use host-resolved inference; " + "bundle includes are not composed by memory setup" + ) + for hook in hooks: + hook.pop("enabled", None) + hook["source"] = ( + overrides.get("hooks-routing") + or hook.get("source") + or ( + "git+https://github.com/microsoft/amplifier-bundle-routing-matrix@main" + "#subdirectory=modules/hooks-routing" + ) + ) + session = { + key: {"module": module, "source": overrides.get(module) or source} + for key, (module, source) in zip(("orchestrator", "context"), BASE_SOURCES.items()) + } + session["metadata"] = {"visibility": "internal", "purpose": "memory.suggestion"} + plan = {"session": session, "providers": providers, "hooks": hooks, "tools": [], "agents": {}} + sources = {} + for row in [*providers, *hooks, session["orchestrator"], session["context"]]: + module, source = row["module"], row["source"] + if not isinstance(source, str) or not source: + raise InferenceError("Configured module source must be explicit") + if module in sources and sources[module] != source: + raise InferenceError( + "One module cannot resolve to two different sources in one runtime" + ) + sources[module] = source + return plan, sources + + +def _owned(root, path): + if not path.resolve().is_relative_to(root.resolve()): + raise InferenceError("Memory runtime path escapes its private root") + return path + + +def _tree(path): + path = Path(path) + return { + str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(path.rglob("*")) + if p.is_file() + and not any( + part in {".git", ".venv", "__pycache__", ".pytest_cache"} or part.endswith(".egg-info") + for part in p.relative_to(path).parts + ) + and p.suffix in {".py", ".toml", ".yaml", ".yml", ".json", ".md"} + } + + +async def prepare(home=None, *, workspace=None, shared_home=None): + """Explicit setup; sanitize activation/install errors that may contain secrets.""" + try: + return await _prepare(home, workspace=workspace, shared_home=shared_home) + except (InferenceError, asyncio.CancelledError): + raise + except Exception as exc: # noqa: BLE001 - source URLs may contain credentials + raise InferenceError( + f"Memory setup failed ({type(exc).__name__}); no runtime activated" + ) from None + + +async def _prepare(home=None, *, workspace=None, shared_home=None): + """Prepare only providers, routing, basic loop and context.""" + from amplifier_foundation.bundle import Bundle + from amplifier_foundation.paths.resolution import get_amplifier_home + + root = _private_root(home) + _versions() + shared = Path(shared_home or get_amplifier_home()).expanduser().resolve() + workspace = Path(workspace or Path.cwd()).resolve() + paths = [ + shared / "settings.yaml", + workspace / ".amplifier/settings.yaml", + workspace / ".amplifier/settings.local.yaml", + ] + choice = load(home).call() + plan, sources = _plan(paths, choice) + generation = _owned(root, root / "generations" / uuid4().hex) + generation.mkdir(parents=True, mode=0o700) + # Prevent a provider's Git dependency from rebuilding or downgrading the kernel. + overrides = generation / "overrides.txt" + overrides.write_text(f"amplifier-core=={importlib.metadata.version('amplifier-core')}\n") + bundle = Bundle( + name="memory-inference", + version="1", + session=plan["session"], + providers=plan["providers"], + hooks=plan["hooks"], + ) + prepared = await bundle.prepare(cache_dir=generation / "cache", strict=True, install_deps=False) + module_paths = {} + for name in sources: + selected = prepared.resolver.resolve(name).resolve() + target = _owned(root, generation / "modules" / name) + if any(p.is_symlink() for p in selected.rglob("*") if ".git" not in p.parts): + raise InferenceError("Module source contains symlinks; use a self-contained source") + # Local source policies are read-only inputs, too. Install only private copies. + shutil.copytree( + selected, + target, + ignore=shutil.ignore_patterns( + ".git", ".venv", "node_modules", "__pycache__", "*.egg-info", ".pytest_cache" + ), + ) + module_paths[name] = str(target) + # Installation is confined to this explicit setup surface, never inference. + from uv import find_uv_bin + + argv = [ + find_uv_bin(), + "pip", + "install", + "--python", + sys.executable, + "--no-sources", + "--only-binary", + "amplifier-core", + "--default-index", + "https://pypi.org/simple", + "--cache-dir", + str(generation / "uv-cache"), + "--overrides", + str(overrides), + ] + for path in sorted(set(module_paths.values())): + argv.extend(["-e", path]) + installed = await asyncio.to_thread(subprocess.run, argv, capture_output=True, text=True) + if installed.returncode: + raise InferenceError("Provider setup dependency installation failed; no runtime activated") + importlib.invalidate_caches() + receipt = { + "schema": 1, + "python": sys.executable, + "versions": _versions(), + "settingsPaths": list(map(str, paths)), + "sharedHome": str(shared), + "configurationHash": _hash(_json(plan)), + "sources": sources, + "modulePaths": module_paths, + "moduleHashes": {name: _tree(path) for name, path in module_paths.items()}, + } + _write(generation / "prepared.json", receipt) + _write(root / "prepared.json", receipt) + return readiness(home) + + +def readiness(home=None): + """Read-only and offline. Does not mount providers, import them or log in.""" + try: + root = runtime_home(home) + if root.is_symlink(): + raise InferenceError("Memory runtime cannot be a symlink") + receipt = json.loads(_owned(root, root / "prepared.json").read_text()) + if Path(receipt["python"]).absolute() != Path(sys.executable).absolute(): + raise InferenceError( + "Prepared runtime belongs to another Python environment; run setup" + ) + if receipt["schema"] != 1 or receipt["versions"] != _versions(): + raise InferenceError("Runtime packages changed; run amplifier-memory setup") + plan, sources = _plan(receipt["settingsPaths"], load(home).call()) + if _hash(_json(plan)) != receipt["configurationHash"] or sources != receipt["sources"]: + raise InferenceError("Provider configuration changed; run amplifier-memory setup") + for name, path in receipt["modulePaths"].items(): + _owned(root, Path(path)) + if not Path(path).is_dir() or _tree(path) != receipt["moduleHashes"][name]: + raise InferenceError( + "Prepared module changed or disappeared; run amplifier-memory setup" + ) + if set(sources) != set(receipt["modulePaths"]): + raise InferenceError("Incomplete prepared runtime; run amplifier-memory setup") + return receipt + except InferenceError: + raise + except (OSError, ValueError, KeyError, importlib.metadata.PackageNotFoundError): + raise InferenceError( + "Memory inference is not prepared; run amplifier-memory setup" + ) from None + + +async def complete(prompt, *, home=None, call: CallConfig | None = None): + """One private session, one tool-free request, cleanup even on cancellation.""" + from amplifier_foundation.bundle import Bundle, BundleModuleResolver, PreparedBundle + + receipt = readiness(home) + choice = call or load(home).call() + plan, sources = _plan(receipt["settingsPaths"], choice) + if sources != receipt["sources"]: + raise InferenceError("Unprepared module source; run amplifier-memory setup") + environment = _credential_environment(Path(receipt["sharedHome"])) + plan = _expand(plan, environment) + root = _private_root(home) + job = _owned(root, root / "jobs" / uuid4().hex) + job.mkdir(parents=True, mode=0o700) + manifest = { + "session_id": "memory-" + job.name, + "visibility": "internal", + "purpose": "memory.suggestion", + "origin": "internal", + "status": "starting", + } + _write(job / "metadata.json", manifest) + session = None + transcript = [{"role": "user", "content": prompt}] + + def observe(event): + from amplifier_foundation.settings import atomic_write + + path = job / "events.jsonl" + old = path.read_text() if path.exists() else "" + atomic_write(path, old + _json(event) + "\n", private=True) + + try: + prepared = PreparedBundle( + mount_plan=plan, + resolver=BundleModuleResolver({k: Path(v) for k, v in receipt["modulePaths"].items()}), + bundle=Bundle(name="memory-inference", version="1"), + ) + # Keep ownership before initialize: cleanup must also cover partial mount failures. + from amplifier_core import AmplifierSession + + session = AmplifierSession( + copy.deepcopy(prepared.mount_plan), session_id=manifest["session_id"] + ) + await session.coordinator.mount("module-source-resolver", prepared.resolver) + session.coordinator.register_capability("session.working_dir", str(job)) + await session.initialize() + providers = session.coordinator.get("providers") or {} + if set(providers) != {row["instance_id"] for row in plan["providers"]}: + raise InferenceError( + "A configured provider did not mount; no fallback account selected" + ) + resolver = session.coordinator.get_capability("model_role_resolver") + if plan["hooks"] and resolver is None: + raise InferenceError("Configured routing did not mount") + priorities = [ + (row.get("config", {}).get("priority", 100), row["instance_id"]) + for row in plan["providers"] + ] + best = min(priority for priority, _ in priorities) + defaults = [name for priority, name in priorities if priority == best] + result = await complete_once( + prompt, + providers=providers, + call=choice, + role_resolver=resolver, + default_provider=defaults[0] if len(defaults) == 1 else None, + observe=observe, + ) + transcript.append({"role": "assistant", "content": result.text}) + manifest.update( + status="completed", + provider=result.provider, + model=result.model, + modelSource=result.model_source, + usage=result.usage, + ) + return result + except asyncio.CancelledError: + manifest["status"] = "cancelled" + raise + except Exception as exc: + manifest.update(status="failed", errorType=type(exc).__name__) + if isinstance(exc, InferenceError): + raise + raise InferenceError(f"Memory session failed ({type(exc).__name__})") from None + finally: + try: + if session is not None: + await session.cleanup() + except Exception as exc: # noqa: BLE001 - sanitize cleanup errors + manifest.update(cleanupErrorType=type(exc).__name__) + if manifest["status"] == "completed": + manifest["status"] = "cleanup_failed" + raise InferenceError( + f"Memory session cleanup failed ({type(exc).__name__})" + ) from None + finally: + from amplifier_foundation.settings import atomic_write + + atomic_write( + job / "transcript.jsonl", + "".join(_json(row) + "\n" for row in transcript), + private=True, + ) + _write(job / "metadata.json", manifest) + + +def standalone_suggest(home=None): + """Dedicated CLI-process boundary, never used by an embedding host. + + Providers may read implicit credential environment variables when mounting. + Populate missing values in THIS standalone process from shared keys.env; + caller-supplied environment wins. Async library/host entrypoints never do this. + """ + import os + + from .suggest import run_suggest + + if load(home).enabled: + try: + receipt = readiness(home) + except InferenceError: + # Preserve run_suggest's bounded/degraded report when setup is missing. + pass + else: + for name, value in _credential_environment(Path(receipt["sharedHome"])).items(): + os.environ.setdefault(name, value) + return run_suggest(home) diff --git a/src/amplifier_memory/service.py b/src/amplifier_memory/service.py index 10dad15..bfa882b 100644 --- a/src/amplifier_memory/service.py +++ b/src/amplifier_memory/service.py @@ -733,6 +733,15 @@ def install( result.steps.append(Step("systemd user bus", None, USER_BUS_UNAVAILABLE, str(exc))) return result + from .inference import InferenceError + from .runtime import readiness + + try: + readiness(home) + except InferenceError as exc: + result.steps.append(Step("inference readiness", None, 1, str(exc))) + return result + targets = _targets(kind, config_dir, home) bodies = _bodies(kind, exe, home) #: Only files THIS call creates are rolled back; an existing unit is left alone. @@ -1067,6 +1076,14 @@ def run_verb( f"`service {verb}` is a systemd verb; on {kind} the timer is a launchd agent \u2014 " f"use `launchctl` against {agent_dir(config_dir) / plist_name(home)}." ) + if verb in {"start", "restart"}: + from .inference import InferenceError + from .runtime import readiness + + try: + readiness(home) + except InferenceError as exc: + return Step("inference readiness", None, 1, str(exc)).render() argv = ( ["journalctl", "--user", "-u", service_unit(home), "-n", "50", "--no-pager"] if verb == "logs" diff --git a/src/amplifier_memory/suggest.py b/src/amplifier_memory/suggest.py index bcbbf54..bb471d7 100644 --- a/src/amplifier_memory/suggest.py +++ b/src/amplifier_memory/suggest.py @@ -48,26 +48,15 @@ The model call -------------- -``amplifier run --output-format json [-p …] [-m …] [-B …] ""``. The flag is -``--output-format``, not ``--output``: verified against ``amplifier run --help`` on this -device 2026-09-06, whose output -`tests/test_suggest.py::test_the_default_argv_matches_amplifier_run_help` prints -(AGENTS.md rule 5 — `amplifier run --once` did not exist and shipped anyway). The JSON it -prints on success is ``{"status": "success", "response": "", -"session_id": …, "bundle": …, "model": …, "timestamp": …}`` (amplifier_app_cli/main.py -~:4440), so the assistant's text is ``json.loads(stdout)["response"]``. - -**Which model answers §3's question** is Core 3's own order, resolved once per run by -`resolve_judge` and named everywhere afterwards (`Judge.render`, the Core 9 log line, -`doctor` through `judge_detail`): the instance's `config.yaml` `llm: judge:` -provider/model/bundle when set; else the **role** — `fast` as shipped — through the -host's routing when this host has it (`amplifier run --model-role`, probed against the -CLI's own `--help`); else the app's own default, **inherited and said out loud**. With -no config the argv is byte-identical to what it always was. - -It is injectable, and **no test in this repository ever calls it**: `model_call` is a -parameter, every test passes a fake, and `tests/conftest.py` replaces the process runner -for the whole suite. +One tool-free request goes through the host's configured provider. Hosts can inject +`model_call`; the standalone default uses installed Core/Foundation/provider modules, +in a private session below the memory home, without a CLI executable, tool loop, +or automatic installation. +Explicit judge selections are honored or refused; a requested bundle requires +host-resolved inference. Routing uses the host's model-role resolver when available. +Provider usage and safe outcomes are retained separately from the source history. + +Tests inject the model boundary and use temporary stores; no live calls occur. This module imports only the standard library and this package: no `click`. """ @@ -77,14 +66,15 @@ import json import os import re -import subprocess from collections.abc import Callable, Iterator, Mapping, Sequence from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta +from decimal import Decimal from pathlib import Path from typing import Literal from . import inbox, llm_config +from .inference import Completion, default_completion from .store import ( DEFAULT_ORIGIN, _read_text, @@ -156,10 +146,10 @@ #: What the reply must be: a JSON list of `{text, quote}` (Core 3, "Output is structured"). REPLY_SHAPE = 'a JSON list of {"text": "…", "quote": "…"} objects' -#: The one argv the default model call runs. See the module docstring for the verification. +#: Legacy argv serialization only. No inference path executes this command. RUN_ARGV: tuple[str, ...] = ("amplifier", "run", "--output-format", "json") -ModelCall = Callable[[str], str] +ModelCall = Callable[[str], str | Completion] # --------------------------------------------------------------------------- the judge @@ -171,17 +161,10 @@ #: this, so the phrase lives here, in the one place the sentence is composed, and #: `doctor.INHERITED` is this constant — not a second copy that could drift from it. INHERITS_DEFAULT = "inherits the app's default" -#: What "the app's default" *is*, named rather than left as a shrug. There is no way to -#: ask for it: `amplifier run` has no `--model-role`, so a recorded role cannot be -#: resolved on this host, and what actually runs is whatever `amplifier run` picks for -#: itself with no `-p`/`-m`/`-B`. Naming it is the honest half; the measured cost below is -#: the other half, and together they are why an inherited price is visible, not silent. -APP_DEFAULT = "whatever `amplifier run` selects with no -p/-m/-B" - -#: Core 3's second resort: the host's own routing, asked for by role rather than by a -#: provider id. `amplifier run --help` on this device (2026-09-07) documents `-B/-p/-m` -#: and no `--model-role`, so today every unconfigured run lands on `INHERITED` — and says -#: so. When the CLI grows the flag, `host_help` sees it and nothing else changes. +#: The configured default is resolved at request time; never guess its price or account. +APP_DEFAULT = "the configured host provider, resolved at call time" + +#: Compatibility for callers displaying historical CLI configuration only. MODEL_ROLE_FLAG = "--model-role" HELP_ARGV: tuple[str, ...] = ("amplifier", "run", "--help") @@ -194,27 +177,8 @@ def host_help(runner: Callable[[], str] | None = None) -> str: - """`amplifier run --help`, as text — the evidence for what this host can resolve. - - AGENTS.md rule 5 in the one place it can be enforced at runtime: the job never claims - a flag exists, it reads the CLI's own help and looks. `runner` is the injection point - (every test and probe passes one). - - Under pytest with no `runner` this returns `""` — "this host documents nothing" — - rather than shelling out. The sibling guards in `default_model_call` and - `llm_config.load` refuse outright for the same reason; here refusing would mean - raising out of a code path every test exercises, so the honest inert answer is the - empty one, and a test that wants the resolved arm passes its own help text. - """ - if runner is not None: - return runner() - if os.environ.get("PYTEST_CURRENT_TEST"): - return "" - try: - proc = subprocess.run(HELP_ARGV, capture_output=True, text=True, check=False, timeout=30.0) - except (OSError, ValueError, subprocess.TimeoutExpired): - return "" - return proc.stdout if proc.returncode == 0 else "" + """Legacy diagnostic injection only; never invoke an external host command.""" + return runner() if runner is not None else "" def host_resolves_roles(help_text: str) -> bool: @@ -237,7 +201,7 @@ class Judge: """ call: llm_config.CallConfig - #: Whether this host documents `amplifier run --model-role` (evidence: its own --help). + #: Legacy display hint; live inference uses the actual model-role resolver. role_resolved: bool = False #: Where the answer came from — `config.yaml`, or the file's absence. origin: str = "" @@ -268,13 +232,7 @@ def model(self) -> str: return self.call.model def flags(self) -> list[str]: - """The `amplifier run` flags this judge adds — Core 3's order, nothing else. - - A configured judge contributes `-p/-m/-B` (`llm_config.CallConfig.flags`). An - unconfigured one on a host that resolves roles contributes `--model-role `. - An unconfigured one anywhere else contributes **nothing**, so the argv is byte - for byte what it always was and the app's default answers. - """ + """Serialize historical CLI choices; never used to execute inference.""" if self.source == "config": return self.call.flags() if self.source == "role": @@ -286,26 +244,20 @@ def render(self) -> str: This is the one place the wording lives. `doctor`'s `llm judge` row (cli.v3 §5) and this module's own reporting both read it, so the CLI and the log can never - disagree about which model is about to be billed. It therefore carries cli.v3 - §5's own words too (`INHERITS_DEFAULT`, `APP_DEFAULT`): that row is this sentence - plus the last run's measured cost, and nothing composed a second time. + disagree about the requested role or explicit selection. That row is this + sentence plus the last run's measured cost, not an invented resolved model. """ where = f" ({self.origin})" if self.origin else "" if self.source == "config": return f"{self.call.render()}{where}" if self.source == "role": - return ( - f"role {self.call.role}, resolved by this host " - f"(`amplifier run {MODEL_ROLE_FLAG} {self.call.role}`){where}" - ) - nightly = INHERITED_COST_USD * MAX_CALLS + return f"role {self.call.role}, resolved by this host (model-role resolver){where}" return ( - f"{INHERITED} — the pass {INHERITS_DEFAULT} ({APP_DEFAULT}){where} — this " - f"host's `amplifier run --help` documents no " - f"{MODEL_ROLE_FLAG}, so role {self.call.role or llm_config.DEFAULT_ROLE} " - f"cannot be resolved yet. Measured cost of that default: " - f"${INHERITED_COST_USD:.3f}/call ({INHERITED_COST_SOURCE}), so up to " - f"${nightly:.2f} for a full {MAX_CALLS}-call night" + f"role {self.call.role or llm_config.DEFAULT_ROLE} requested{where}; " + "the host resolver selects the model. An unavailable resolver fails visibly " + "before a provider request. " + "Selected provider, model provenance and usage are recorded when known; " + "no CLI is started; standalone inference uses a private memory session." ) @@ -316,11 +268,11 @@ def resolve_judge( help_text: str | None = None, help_runner: Callable[[], str] | None = None, ) -> Judge: - """Core 3's judge, resolved from the instance's `config.yaml` and this host's CLI. + """Read the instance choice; actual provider/routing resolution happens at request time. `config` is the already-read `llm_config.LlmConfig` (read from `home` when None). - `help_text` short-circuits the host probe for callers that already have the help in - hand; `help_runner` injects the probe itself. An unusable `config.yaml` is not this + `help_text`/`help_runner` remain optional legacy display inputs; no host command + is probed. An unusable `config.yaml` is not this function's to report — `LlmConfig.reason` carries that, and both `run_suggest` and `doctor` say it in their own voice — but it *is* honoured: an unusable file yields the built-in defaults, so the judge lands on role-or-inherited, never on a @@ -383,6 +335,7 @@ class SuggestReport: provider: str = "" #: The model, when the config named one. Absent from the line when it did not. model: str = "" + inference: list[dict] = field(default_factory=list) proposals: list[inbox.Suggestion] = field(default_factory=list) dropped: list[inbox.Suggestion] = field(default_factory=list) @@ -405,13 +358,24 @@ def log_line(self) -> str: `provider=` still parses, it simply lacks those keys. """ model = f" model={self.model}" if self.model else "" + accounting = "" + if self.inference: + usage = [row["usage"] for row in self.inference if row.get("usage") is not None] + costs = [Decimal(row["cost_usd"]) for row in usage if row.get("cost_usd") is not None] + accounting = ( + f" usage_known={len(usage)} usage_unknown={self.calls - len(usage)}" + f" input_tokens={sum(row.get('input_tokens', 0) for row in usage)}" + f" output_tokens={sum(row.get('output_tokens', 0) for row in usage)}" + f" cost_usd_known={sum(costs, Decimal(0))}" + f" cost_unknown={self.calls - len(costs)}" + ) return ( f"{self.when.isoformat(timespec='seconds')} " f"sessions={self.sessions} origin_excluded={self.origin_excluded} " f"proposed={self.proposed} rejected={self.rejected} " f"dropped_stale={self.dropped_stale} calls={self.calls} " f"invalid_reasons={self.invalid_reasons} " - f"provider={self.provider or INHERITED}{model} status={self.status}" + f"provider={self.provider or INHERITED}{model}{accounting} status={self.status}" ) def render(self) -> str: @@ -578,13 +542,19 @@ def _recent_human_context( return _recent_typed_turns(session, cutoff=cutoff, now=now), () events = [event for event in session.events if cutoff <= event.when <= now] human_positions = [ - index for index, event in enumerate(events) if event.role == "user" and is_typed_text(event.text) + index + for index, event in enumerate(events) + if event.role == "user" and is_typed_text(event.text) ] turns = tuple(events[index].text for index in human_positions) contexts: list[AssistantContext] = [] for human_index, position in enumerate(human_positions, start=1): before = next( - (events[index].text for index in range(position - 1, -1, -1) if events[index].role == "assistant"), + ( + events[index].text + for index in range(position - 1, -1, -1) + if events[index].role == "assistant" + ), None, ) after = next( @@ -656,6 +626,8 @@ def read_session(directory: Path) -> RecordedSession | None: metadata = {} if not isinstance(metadata, dict): metadata = {} + if metadata.get("visibility") == "internal" or metadata.get("purpose") == "memory.suggestion": + return None created = _parse_time(metadata.get("created")) turns: list[str] = [] @@ -671,7 +643,11 @@ def read_session(directory: Path) -> RecordedSession | None: if not isinstance(record, dict) or record.get("role") not in ("user", "assistant"): continue role = record["role"] - text = _turn_text(record.get("content")) if role == "user" else _assistant_text(record.get("content")) + text = ( + _turn_text(record.get("content")) + if role == "user" + else _assistant_text(record.get("content")) + ) if not text.strip(): continue stamp = record.get("metadata") @@ -882,8 +858,7 @@ def _balanced_budgets(total: int, available: dict[str, bool]) -> dict[str, int]: return {name: 0 for name in weights} weight_total = sum(weights[name] for name in present) budgets = { - name: total * weights[name] // weight_total if available[name] else 0 - for name in weights + name: total * weights[name] // weight_total if available[name] else 0 for name in weights } remainder = total - sum(budgets[name] for name in present) for name in ("human", "feedback", "context"): @@ -906,7 +881,10 @@ def _feedback_unit(record: inbox.DeclinedEntry, index: int) -> str: def _context_unit(context: AssistantContext) -> str: - lines = [f"assistant-context for eligible human {context.human_index}:", "role: assistant-context"] + lines = [ + f"assistant-context for eligible human {context.human_index}:", + "role: assistant-context", + ] if context.before is not None: lines.append(f"before: {_safe_data(context.before)}") if context.after is not None: @@ -959,7 +937,9 @@ def compose_request( if len(body) > TURN_CHARS: body = "…" + body[-(TURN_CHARS - 1) :] human_units.append(f"{index}. {body}") - feedback_units = [_feedback_unit(record, index) for index, record in enumerate(declined, start=1)] + feedback_units = [ + _feedback_unit(record, index) for index, record in enumerate(declined, start=1) + ] context_units = { context.human_index: _context_unit(context) for context in assistant_context @@ -976,7 +956,11 @@ def compose_request( raise ValueError(f"mandatory request framing exceeds {REQUEST_CHARS}-character limit") budgets = _balanced_budgets( REQUEST_CHARS - len(fixed) - reserved_labels, - {"human": bool(human_units), "feedback": bool(feedback_units), "context": bool(context_units)}, + { + "human": bool(human_units), + "feedback": bool(feedback_units), + "context": bool(context_units), + }, ) selected_humans = _take_newest(human_units, budgets["human"]) selected_indices = {int(unit.split(".", 1)[0]) for unit in selected_humans} @@ -1031,59 +1015,13 @@ def _json_object_in(stdout: str) -> object: def build_argv(request: str, judge: Judge | None = None) -> list[str]: - """The exact argv the model call runs, as a pure function of the resolved judge. - - With no judge — or one that inherits on a host that cannot resolve roles — this is - `RUN_ARGV + [request]`, byte for byte what the job has always run, so an - unconfigured device sees no change at all. A configured judge gains only the flags - it actually set, in `-p -m -B` order; a role-resolving host gains `--model-role`. - """ + """Serialize historical CLI choices for compatibility; no runner consumes this.""" return [*RUN_ARGV, *(judge.flags() if judge else []), request] -def default_model_call(prompt: str, *, timeout: float = 300.0, call: Judge | None = None) -> str: - """`amplifier run --output-format json [flags] ""`, returning the assistant's text. - - See the module docstring for the argv's verification and the JSON shape. Raises - `RuntimeError` on a nonzero exit or an unreadable reply — `run_suggest` catches it, - counts it, and the run ends `degraded` (Core 10). - - It refuses outright when reached from a test or a conformance probe. The sibling - guard in `service._default_runner` was written after a probe enabled a real timer on - the steward's device (2026-09-06); this one is the same guard on the more expensive - door — a check that reached here would spend real model calls on the steward's own - recorded sessions. Every caller in this repository injects `model_call`. - """ - if os.environ.get("PYTEST_CURRENT_TEST"): - raise RuntimeError( - "refusing to call the model from a test: pass an explicit `model_call=` to " - "`run_suggest` (and point AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH at a fixture)" - ) - argv = build_argv(prompt, call) - # The command as run, minus the request itself: a failure names the flags that - # produced it (a provider id that does not exist on this device is the likely one), - # never the whole transcript. - shown = " ".join(argv[:-1]) - try: - proc = subprocess.run(argv, capture_output=True, text=True, check=False, timeout=timeout) - except (OSError, ValueError, subprocess.TimeoutExpired) as exc: - raise RuntimeError(f"{shown} failed: {type(exc).__name__}: {exc}") from exc - if proc.returncode != 0: - reason = (proc.stderr or proc.stdout or "").strip().splitlines() - raise RuntimeError( - f"{shown} exited {proc.returncode}: {reason[-1] if reason else 'no output'}" - ) - try: - payload = _json_object_in(proc.stdout) - except json.JSONDecodeError as exc: - raise RuntimeError(f"{shown} did not print JSON: {exc}") from exc - response = payload.get("response") if isinstance(payload, dict) else None - if not isinstance(response, str): - raise RuntimeError( # noqa: TRY004 - the caller counts a bad reply, it never type-checks it - f"{shown} printed no assistant text (keys: " - f"{sorted(payload) if isinstance(payload, dict) else type(payload).__name__})" - ) - return response +def default_model_call(prompt: str, *, call: Judge | None = None, home=None) -> Completion: + """One tool-free request in a private session; no CLI or completion deadline.""" + return default_completion(prompt, call=call.call if call else None, home=home) class MalformedReply(ValueError): @@ -1245,14 +1183,14 @@ def run_suggest( 7. one log line (Core 9), whatever happened. `model_call` is injected by every caller in this repository's tests; left None it is - `default_model_call`, whose argv the module docstring documents and verifies. + `default_model_call`, which uses the prepared private session runtime. `config` is the instance's own `config.yaml` (`llm_config.load(home)` when None). It decides which provider/model/bundle the judge uses; `resolve_judge` turns that, plus - what this host's `amplifier run --help` documents, into Core 3's answer, and the run + the host's provider/routing bindings, into Core 3's answer, and the run names it in its log line. A file that cannot be used is one more reason in the status, never an exception (Core 10) — the run still happens, inheriting the app's - default as it always did, because a typo in a config file is not a reason to skip a + declared default, because a typo in a config file is not a reason to skip a night's pass. """ when = now or datetime.now(UTC) @@ -1279,7 +1217,7 @@ def run_suggest( judge = resolve_judge(settings, help_text=help_text, help_runner=help_runner) report.provider, report.model = judge.name, judge.model if settings.reason: - reasons.append(f"{settings.path.name} unusable ({settings.reason}); CLI default used") + reasons.append(f"{settings.path.name} unusable ({settings.reason}); host default requested") try: report.dropped = inbox.expire(path, now=when) @@ -1323,8 +1261,8 @@ def run_suggest( report.invalid_reasons = sum(record.reason_state == "invalid" for record in declined) cutoff = when - timedelta(hours=window_hours) - def call_the_judge(request: str) -> str: - return default_model_call(request, call=judge) + def call_the_judge(request: str) -> Completion: + return default_model_call(request, call=judge, home=path) ask = model_call or call_the_judge @@ -1346,6 +1284,19 @@ def call_the_judge(request: str) -> str: except Exception as exc: # noqa: BLE001 - Core 10: the model is allowed to be absent reasons.append(f"model call failed for {session.id[:8]} ({_reason(exc)})") continue + if isinstance(reply, Completion): + report.inference.append( + { + "provider": reply.provider, + "model": reply.model, + "modelSource": reply.model_source, + "usage": reply.usage, + "status": "completed", + } + ) + report.provider = reply.provider + report.model = reply.model or "" + reply = reply.text try: candidates = parse_reply(reply) except MalformedReply as exc: diff --git a/src/amplifier_memory/update.py b/src/amplifier_memory/update.py index 47fd345..95c76f4 100644 --- a/src/amplifier_memory/update.py +++ b/src/amplifier_memory/update.py @@ -1,93 +1,9 @@ -"""`update` — cli.v2 Core 7, performed rather than described. - -The clause: "upgrades the uv tool and refreshes the registered app bundle, restarts -the timer if installed, ends by running `doctor`, and prints the stale-in-memory -note: sessions started before the refresh keep the old module code until restarted." - -A device runs **three** copies of this bundle, and until 2026-09-06 this verb refreshed -one of them. The steps, in order, each a real argv this module shells out to: - -1. ``uv tool upgrade amplifier-memory`` — the shell verb. -2. ``git -C fetch origin`` then ``git -C reset --hard - origin/``, for every cache clone — **what sessions load the modules and skills - from**. Falls back to ``amplifier bundle remove/add --app`` when - there is no clone to move (nothing installed yet, or a cache that is not a git - checkout), and says which happened. -3. ``uv pip install --python --refresh - --reinstall-package amplifier-memory "amplifier-memory @ git+@"`` — **the - library those modules import**. One warning line and no failure when no amplifier - venv can be found. -4. the suggest timer — skipped while `service_status` says Phase 1 has none. -5. ``amplifier-memory doctor``, in-process (`doctor()`), never as a subprocess: - a wrapper must not call a wrapper (cli.v2 Core 9). - -Every argv above is verified against that CLI's own ``--help`` by -`tests/test_update.py`, which prints the help it relied on (AGENTS.md rule 5). - -Why steps 2 and 3 exist at all ------------------------------- -Measured on the steward's device 2026-09-06 21:55–22:05Z, after four waves of merged -work: `update` reported ``[ok] refresh the app bundle`` and `doctor` reported ``[OK] -update current (0f7e0fc == main)``, and a real session still printed **v1's** ``Loaded 2 -memories (0 topics available).`` The cache clone sat at ``0afc6a8`` (through both -``bundle remove``+``add`` *and* ``amplifier bundle update``) and the library inside the -amplifier venv sat at a pre-K1 commit with ``edit``/``record_citation`` absent — which is -also what raised ``AttributeError: … read_memory_text`` in a lane's real session. The two -commands in steps 2 and 3 are the ones that actually repaired the device by hand; they -are encoded here, not redesigned. `docs/workflow/CHECK-RECORD.md`, addendum 22:05Z. - -Why the fallback is a remove-then-add and not ``amplifier bundle update`` -------------------------------------------------------------------------- -Measured 2026-09-06 against the installed CLI: - -- ``amplifier bundle update `` resolves *registry* names. The app bundle is - registered by URI in ``bundle.app``, not by name, so it answers - ``Error: Failed to load bundle: No handler for URI: memory-session``. -- ``amplifier bundle update --check --source `` answers ``No active bundle.`` -- ``amplifier bundle update --all --check`` enumerates discovered bundles only; the - app-bundle URIs are not among them. - -``amplifier bundle add`` refetches the source ("Fetching bundle from …") and registers -the URI, so it remains the *install* path — but it does not move an existing clone off -its commit, which is why it is no longer the refresh. The remove is tolerated when it -fails: an app entry that is already absent is not an error. - -Why one run is enough ---------------------- -The process executing `update` **is** the pre-upgrade CLI: step 1 replaces the code on -disk, but this interpreter keeps running the code it already imported. Measured on the -steward's device 2026-09-06 22:25Z, that cost a whole run: the first `update` after an -upgrade printed the *old* binary's remaining steps and the old single-leg doctor row -``[OK] update current (662a53a == main)``, and only the second run refreshed the cache -and the venv library. `doctor` said WARN in between, so the truth was visible - but a -steward who runs `update` once and reads `current` should be right. - -So: the installed commit is read before and after step 1, and if it moved, this process -hands the rest of the run to the freshly installed binary with -``os.execv(, [..., "update", "--after-upgrade"])``. The hidden flag -skips step 1 in the new process, which is what makes the hand-off a hand-off rather than -a loop. Nothing after step 1 runs in the old process; the steps it did run are printed -before it is replaced, so the steward reads one continuous report. - -When the hand-off cannot happen - no `amplifier-memory` on PATH, `os.execv` refusing, or -this process being the re-exec already - the remaining steps run here, with the old code, -and one INFO line says exactly that and names the remedy. It is printed only when the -commits actually differ: a run that upgraded nothing has nothing to warn about. - -`installed_commit()` reads the running install's own `direct_url.json` (PEP 610), so -whether it *sees* an in-place `uv tool upgrade` on a real device is a device fact this -bundle's tests cannot establish - a fake commit reader drives both branches here. If it -ever fails to see one, the fallback is the behaviour of the previous version, which -`doctor` already catches with WARN. - -Why the URI carries ``#subdirectory=behaviors/memory-session.yaml`` ------------------------------------------------------------------- -The root-bundle URI composes nothing. The root bundle includes this same behavior, -so an ``--app`` install of the root is a self-include the loader skips ("Circular -Include Skipped"), and the session gets no hook and no memory tool. Measured in -`tests/smoke/evidence/` and fixed in README step 1. - -This module imports only the standard library and this package: no `click`. +"""Upgrade the standalone tool and report whether its inference runtime is ready. + +Shared CLI bundle-cache and environment updates are legacy opt-in library actions; +they never run during ordinary standalone update or inference. Source preparation +is explicit via amplifier-memory setup. The existing re-exec handoff ensures the +freshly upgraded tool performs subsequent checks when its commit changes. """ from __future__ import annotations @@ -105,13 +21,11 @@ SHORT, STALE_NOTE, DoctorReport, - amplifier_env_python, bundle_cache_dirs, commit_of_cache, commit_of_env_library, doctor, installed_commit, - service_status, update_plan, ) from .store import store_home @@ -440,7 +354,7 @@ def run_update( injectable so the conformance kit and the tests can exercise every step — and both sides of the hand-off — with no network and no mutation of this device. `env_python=None` is the "no amplifier venv" branch; leaving it unset resolves the - real one. + real one only through an explicitly supplied interpreter. `after_upgrade=True` is the re-executed process saying so: step 1 is skipped, and a second hand-off is refused (see the module docstring, "Why one run is enough"). `timer_installed` stays False for the whole of Phase 1 — `service_status` is the @@ -469,10 +383,20 @@ def run_update( return UpdateReport(steps=steps) steps.append(note) - steps.extend(_refresh_bundle_cache(run, app_bundle_uri, amplifier_home)) + # Legacy CLI/cache interoperability is opt-in through explicit library arguments. + # Ordinary standalone updates never inspect or install into another app's runtime. + if amplifier_home is not None: + steps.extend(_refresh_bundle_cache(run, app_bundle_uri, amplifier_home)) + if env_python is not _UNSET: + steps.append(_refresh_env_library(run, Path(str(env_python)) if env_python else None)) + from .inference import InferenceError + from .runtime import readiness - python = amplifier_env_python() if env_python is _UNSET else env_python - steps.append(_refresh_env_library(run, python if python is None else Path(str(python)))) + try: + readiness(instance) + except InferenceError as exc: + steps.append(StepResult("inference setup", None, skipped=True, reason=str(exc))) + timer_installed = False if timer_installed is None: # Read the truth off the unit dir through the SAME injected runner, so a fake @@ -496,7 +420,7 @@ def run_update( # on a machine with a real timer installed reaches the real systemctl # (measured 2026-09-07: the cli kit's Core 7 probe went Broken the night # the first timer was installed). - reason=service_status("restart", runner=run, home=instance).splitlines()[0], + reason="not restarted; inference setup is required or no timer is installed", ) ) diff --git a/tests/conftest.py b/tests/conftest.py index ea08c18..bff1694 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -135,3 +135,11 @@ def factory(days_ago: float) -> AbstractContextManager[None]: def human_identity() -> tuple[str, str]: """The stand-in human this device's isolated git config names.""" return HUMAN_IDENTITY + + +@pytest.fixture +def private_runtime(tmp_path, monkeypatch): + """Real Core/Foundation lifecycle with only module implementations synthetic.""" + from runtime_support import make_runtime + + return lambda **kw: make_runtime(tmp_path, monkeypatch, **kw) diff --git a/tests/runtime_support.py b/tests/runtime_support.py new file mode 100644 index 0000000..b79ddbc --- /dev/null +++ b/tests/runtime_support.py @@ -0,0 +1,179 @@ +"""Synthetic source packages mounted by real Core, with no provider network.""" + +import sys +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import yaml + +from amplifier_memory import runtime +from amplifier_memory.llm_config import load + +PROVIDER = """import asyncio +from amplifier_core.message_models import ChatResponse, TextBlock, Usage +__amplifier_module_type__ = "provider" +mounted, calls, closed = [], [], [] +started = asyncio.Event() +class Provider: + name = "fixture" + def __init__(self, config): self.config = config + def get_info(self): + from amplifier_core import ProviderInfo + return ProviderInfo(id=self.name, display_name="Fixture", credential_env_vars=[], defaults={"model":self.config["default_model"]}, capabilities=[]) + async def list_models(self): return [] + def parse_tool_calls(self, response): return [] + async def complete(self, request): + calls.append((self.config, request)) + assert request.tools == [] and request.tool_choice == "none" and request.timeout is None + prompt = str(request.messages[0].content) + if prompt == "FAIL": raise RuntimeError("private-error-body") + if prompt == "WAIT": + started.set() + await asyncio.Event().wait() + return ChatResponse(content=[TextBlock(text=self.config["reply"])], model=request.model or self.config["default_model"], usage=Usage(input_tokens=33, output_tokens=9, total_tokens=42)) +async def mount(coordinator, config): + mounted.append(config) + await coordinator.mount("providers", Provider(config), name="fixture") + async def cleanup(): closed.append(config["account"]) + return cleanup +""" +HOOK = """from types import SimpleNamespace +__amplifier_module_type__ = "hook" +class Resolver: + async def resolve(self, role): + assert role == "fast" + return [SimpleNamespace(provider="account-b", model="role-model", config={"reasoning_effort":"low"})] +async def mount(coordinator, config): + coordinator.register_capability("model_role_resolver", Resolver()) +""" +LOOP = """__amplifier_module_type__ = "orchestrator" +class Loop: + async def execute(self, prompt, context, providers, tools, hooks): raise AssertionError("No agent loop allowed") +async def mount(coordinator, config): await coordinator.mount("orchestrator", Loop()) +""" +CONTEXT = """__amplifier_module_type__ = "context" +class Context: + async def add_message(self, message): pass + async def get_messages_for_request(self): return [] + async def get_messages(self): return [] + async def set_messages(self, messages): pass + async def clear(self): pass +async def mount(coordinator, config): await coordinator.mount("context", Context()) +""" + + +def make_runtime(tmp_path, monkeypatch, *, home=None, reply="[]"): + home = home or tmp_path / "memory" + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + workspace = tmp_path / "workspace" + (workspace / ".amplifier").mkdir(parents=True, exist_ok=True) + (workspace / "AGENTS.md").write_text("MUST NOT BE INCLUDED IN INFERENCE") + monkeypatch.setenv("AMPLIFIER_HOME", str(shared)) + monkeypatch.setenv("FIXTURE_KEY", "process-key") + (shared / "keys.env").write_text("FIXTURE_KEY=file-key\n") + root = runtime._private_root(home) + paths = {} + provider_package = None + for name, code in [ + ("provider-fixture", PROVIDER), + ("hooks-routing", HOOK), + ("loop-basic", LOOP), + ("context-simple", CONTEXT), + ]: + path = root / "generations" / uuid4().hex / name + package = "amplifier_module_" + name.replace("-", "_") + "_" + uuid4().hex[:8] + package_path = path / package + package_path.mkdir(parents=True) + (package_path / "__init__.py").write_text(code) + (path / "pyproject.toml").write_text( + f'[project]\nname="amplifier-module-{name}"\nversion="0.0.0"\ndependencies=[]\n[project.entry-points."amplifier.modules"]\n{name}="{package}:mount"\n' + ) + paths[name] = str(path) + if name == "provider-fixture": + provider_package = package + rows = [ + { + "id": account, + "module": "provider-fixture", + "source": Path(paths["provider-fixture"]).as_uri(), + "config": { + "account": account, + "default_model": "shared-model", + "api_key": "${FIXTURE_KEY}", + "token_file_path": str(shared / (account + ".json")), + "reply": reply, + }, + } + for account in ["account-a", "account-b"] + ] + settings = { + "config": { + "providers": rows, + "hooks": [{"module": "hooks-routing", "source": Path(paths["hooks-routing"]).as_uri()}], + }, + "sources": { + "modules": { + name: Path(paths[name]).as_uri() for name in ["loop-basic", "context-simple"] + } + }, + } + (shared / "settings.yaml").write_text(yaml.safe_dump(settings)) + (workspace / ".amplifier/settings.yaml").write_text( + yaml.safe_dump( + { + "config": { + "providers": [ + { + "id": "account-a", + "module": "provider-fixture", + "config": {"default_model": "workspace-model"}, + } + ] + } + } + ) + ) + (workspace / ".amplifier/settings.local.yaml").write_text( + yaml.safe_dump( + { + "config": { + "providers": [ + { + "id": "account-a", + "module": "provider-fixture", + "config": {"default_model": "local-model"}, + } + ] + } + } + ) + ) + settings_paths = [ + str(shared / "settings.yaml"), + str(workspace / ".amplifier/settings.yaml"), + str(workspace / ".amplifier/settings.local.yaml"), + ] + plan, sources = runtime._plan(settings_paths, load(home).call()) + receipt = { + "schema": 1, + "python": sys.executable, + "versions": runtime._versions(), + "settingsPaths": settings_paths, + "sharedHome": str(shared), + "configurationHash": runtime._hash(runtime._json(plan)), + "sources": sources, + "modulePaths": paths, + "moduleHashes": {name: runtime._tree(path) for name, path in paths.items()}, + } + runtime._write(root / "prepared.json", receipt) + + class Fixture(SimpleNamespace): + @property + def module(self): + return sys.modules[provider_package] + + return Fixture( + home=home, shared=shared, workspace=workspace, receipt=receipt, package=provider_package + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 437e7d9..d43c3ba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,7 +31,17 @@ CLI_SOURCE = REPO_ROOT / "src" / "amplifier_memory" / "cli.py" # cli.v2 Core 1: these verbs and nothing else. -CONTRACT_VERBS = ["init", "status", "why", "review", "doctor", "service", "update", "suggest"] +CONTRACT_VERBS = [ + "init", + "status", + "why", + "review", + "doctor", + "service", + "update", + "suggest", + "setup", +] Backdate = Callable[[float], AbstractContextManager[None]] @@ -199,19 +209,11 @@ def test_update_runs_its_steps_and_ends_in_doctor(run, store: Path, no_shelling_ assert "amplifier-memory doctor" in result.output, "doctor did not run" assert before == after, "update mutated the store" assert no_shelling_out[0] == amplifier_memory.UPGRADE_CLI_ARGV, no_shelling_out - # The verb takes no injection (it is a thin wrapper), so WHICH argv follow the - # upgrade depend on what this device has installed: a cache clone -> git - # fetch/reset; no clone -> the `bundle remove`/`add` install path; an amplifier - # venv -> uv pip install. Each branch is pinned exactly, against a fake device, in - # `tests/test_update.py` and `conformance/cli/run.py`. + # Standalone maintenance never discovers or mutates another app's runtime. recorded = [" ".join(argv) for argv in no_shelling_out] - print("\n".join(recorded)) - assert any("fetch origin" in line for line in recorded) or ( - amplifier_memory.BUNDLE_ADD_ARGV in no_shelling_out - ), recorded - assert any("uv pip install" in line for line in recorded) or ( - "no amplifier environment found" in result.output - ), recorded + assert not any("fetch origin" in line or "uv pip install" in line for line in recorded) + assert amplifier_memory.BUNDLE_ADD_ARGV not in no_shelling_out + assert amplifier_memory.BUNDLE_REMOVE_ARGV not in no_shelling_out # cli.v2 Core 7, "one run is enough": the hand-off's own flag. It is hidden (a # steward never types it), and it makes the verb skip the uv-tool step, because the @@ -601,3 +603,19 @@ def test_every_verb_acts_on_the_instance_home_names(run, tmp_path: Path) -> None printed = run("status", "--home", str(other)).output assert str(other) in printed and "prefer tabs here" not in printed assert run("why", "m-001", "--home", str(other)).output.count("prefer tabs here") >= 1 + + +@pytest.fixture(autouse=True) +def prepared_runtime_for_existing_cli_contracts(monkeypatch, tmp_path): + """CLI delegation tests assume readiness; test_runtime covers its failure gates.""" + monkeypatch.setattr( + "amplifier_memory.runtime.readiness", lambda home=None: {"sharedHome": str(tmp_path)} + ) + + +@pytest.fixture(autouse=True) +def explicit_synthetic_timer_platform(monkeypatch): + """The default timer fixtures model systemd even when tests run on macOS.""" + monkeypatch.setattr( + "amplifier_memory.service.which_platform", lambda platform=None: platform or "systemd" + ) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index b223dbf..9dfb275 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -95,6 +95,7 @@ def test_doctor_rows_cover_every_row_the_clause_names(store: Path) -> None: # cli.v2 §5 enumerates its rows but, unlike Core 1's verb list, does not close # the set. "llm judge", + "inference runtime", "update", ], names caps = next(row for row in report.rows if row.name == "caps") @@ -273,31 +274,13 @@ def test_suggest_status_reads_the_last_run_back_out_of_the_log(store: Path) -> N assert report.log_line in after -def test_update_plan_names_all_five_steps_and_the_stale_in_memory_note() -> None: - """The plan names the three installed things, because `update` refreshes all three. - - Measured 2026-09-06: a plan that named only the uv tool and the app-bundle entry - described an update that left a device running four-waves-old module code and said - `[ok]` while doing it (`docs/workflow/CHECK-RECORD.md`, addendum 22:05Z). - """ +def test_update_plan_is_standalone_and_reports_explicit_setup(): plan = amplifier_memory.update_plan() - print(plan) assert "uv tool upgrade amplifier-memory" in plan - assert "git fetch origin" in plan and "reset --hard origin/main" in plan, ( - "step 2 must name the cache-clone refresh: `amplifier bundle add` re-registers the " - "URI without moving an existing clone off its old commit" - ) - assert "cache/skills/" in plan, "the skills twin is a second clone, and it is loaded from" - assert "uv pip install" in plan and "--reinstall-package amplifier-memory" in plan, ( - "step 3 must name the library inside the amplifier venv: that is what the modules " - "import, and it is resolved once at install time" - ) - assert "doctor" in plan + assert "amplifier-memory setup" in plan and "doctor" in plan assert "keep the old module code until they restart" in plan - assert "amplifier bundle remove" in plan and "--app" in plan, ( - "the remove-then-add stays in the plan as the fallback: `amplifier bundle update` " - "cannot reach an app bundle registered by URI at all" - ) + assert "amplifier bundle" not in plan and "reset --hard" not in plan + assert "other host environments" in plan # --------------------------------------------------------------- AGENTS.md rule 5 @@ -529,14 +512,14 @@ def test_the_llm_row_names_the_resolved_judge_or_the_inheritance( store: Path, tmp_path: Path ) -> None: from amplifier_memory import llm_config - from amplifier_memory.doctor import APP_DEFAULT, INHERITED, llm_row + from amplifier_memory.doctor import llm_row inherited = llm_row(llm_config.load(tmp_path / llm_config.CONFIG_NAME), home=store) print(inherited.render()) assert inherited.level == "OK" - # cli.v3 §5: the clause's own words, the default NAMED, and the measured cost. - assert INHERITED in inherited.detail - assert APP_DEFAULT in inherited.detail, "the inherited default is named" + # The requested role and measured cost are named without promising a fallback. + assert "role fast requested" in inherited.detail + assert "unavailable resolver fails visibly" in inherited.detail.lower() assert llm_config.CONFIG_NAME in inherited.detail, "the file's name and place are named" assert "role fast" in inherited.detail assert "no run yet, so no measured cost" in inherited.detail @@ -549,18 +532,18 @@ def test_the_llm_row_names_the_resolved_judge_or_the_inheritance( assert configured.level == "OK" assert "provider luna" in configured.detail and "model gpt-5.6-luna" in configured.detail assert llm_config.CONFIG_NAME in configured.detail - assert INHERITED not in configured.detail, "a named judge does not inherit" + assert "role fast requested" not in configured.detail, "a named judge does not inherit" def test_the_llm_row_warns_when_the_file_is_there_but_unusable(store: Path, tmp_path: Path) -> None: """Nothing is broken - the pass still runs - but the user's choice did not take.""" - from amplifier_memory.doctor import INHERITED, llm_row + from amplifier_memory.doctor import llm_row row = llm_row(_llm(tmp_path, "llm:\n judge:\n provider: 'luna'\n \tbad\n"), home=store) print(row.render()) assert row.level == "WARN", "a bad config file is not a broken store" assert "not valid YAML" in row.detail, "the reason is named, not merely 'unusable'" - assert INHERITED in row.detail and "remedy" in row.detail + assert "role fast requested" in row.detail and "remedy" in row.detail report = amplifier_memory.doctor( installed_sha=SHA_A, remote_sha=SHA_A, llm=_llm(tmp_path, "llm: [oops\n") diff --git a/tests/test_inference.py b/tests/test_inference.py new file mode 100644 index 0000000..18c2339 --- /dev/null +++ b/tests/test_inference.py @@ -0,0 +1,224 @@ +"""Synthetic providers only; no model, CLI, or saved conversation.""" + +import asyncio +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +pytest.importorskip("amplifier_core") +pytest.importorskip("amplifier_foundation") + +from amplifier_memory.inference import Completion, InferenceError, complete_once +from amplifier_memory.llm_config import CallConfig + + +@pytest.fixture(autouse=True) +def isolated_shared_home(monkeypatch, tmp_path): + monkeypatch.setenv("AMPLIFIER_HOME", str(tmp_path / "shared")) + + +def response(text="[]", **kw): + from amplifier_core.message_models import ChatResponse, TextBlock + + return ChatResponse(content=[TextBlock(text=text)], **kw) + + +class Provider: + def __init__(self, reply=None, error=None): + self.reply, self.error, self.requests, self.closed = reply, error, [], False + + async def complete(self, request): + self.requests.append(request) + if self.error: + raise self.error + return self.reply or response() + + async def close(self): + self.closed = True + + +def test_role_auth_identity_and_one_tool_free_request(): + from amplifier_core.message_models import Usage + + provider = Provider( + response( + usage=Usage( + input_tokens=20, output_tokens=3, total_tokens=23, cost_usd=Decimal("0.0003") + ), + model="actual-model", + ) + ) + other, events = Provider(), [] + + class Resolver: + async def resolve(self, role): + assert role == "fast" + return [ + SimpleNamespace( + provider="b", model="fast-model", config={"reasoning_effort": "low"} + ) + ] + + result = asyncio.run( + complete_once( + "synthetic facts", + providers={"a": other, "b": provider}, + role_resolver=Resolver(), + default_provider="a", + observe=events.append, + ) + ) + assert ( + isinstance(result, Completion) and result.provider == "b" and result.model == "actual-model" + ) + assert result.usage["cost_usd"] == "0.0003" + assert result.model_source == "response" and events[-1]["modelSource"] == "response" + assert len(provider.requests) == 1 and other.requests == [] + req = provider.requests[0] + assert req.model == "fast-model" and req.reasoning_effort == "low" + assert req.tools == [] and req.tool_choice == "none" and req.timeout is None + assert req.max_output_tokens == 4096 and req.conversation_id is None + assert [e["status"] for e in events] == ["started", "completed"] + assert not provider.closed and "synthetic facts" not in str(events) + + +def test_explicit_selection_avoids_role_and_preserves_unknown_usage(): + class Resolver: + async def resolve(self, role): + raise AssertionError("explicit provider must not reroute") + + provider = Provider() + result = asyncio.run( + complete_once( + "facts", + providers={"a": provider}, + call=CallConfig(provider="a", model="chosen"), + role_resolver=Resolver(), + ) + ) + assert provider.requests[0].model == "chosen" and result.usage is None + assert result.model == "chosen" and result.model_source == "request" + + +@pytest.mark.parametrize( + "choice", [CallConfig(provider="missing"), CallConfig(bundle="custom"), CallConfig()] +) +def test_unresolved_selection_does_not_call(choice): + providers = {"a": Provider(), "b": Provider()} + with pytest.raises(InferenceError): + asyncio.run(complete_once("facts", providers=providers, call=choice)) + assert not any(p.requests for p in providers.values()) + + +def test_host_resolved_bundle(): + provider = Provider() + asyncio.run( + complete_once( + "facts", + providers={"a": provider}, + call=CallConfig(bundle="custom"), + resolved_bundle="custom", + ) + ) + assert len(provider.requests) == 1 + + +@pytest.mark.parametrize("error", [RuntimeError("private-secret"), asyncio.CancelledError()]) +def test_failure_cancel_attribution_and_no_retry(error): + provider, events = Provider(error=error), [] + with pytest.raises((InferenceError, asyncio.CancelledError)) as caught: + asyncio.run( + complete_once( + "facts", + providers={"a": provider}, + call=CallConfig(provider="a"), + observe=events.append, + ) + ) + assert len(provider.requests) == 1 and not provider.closed + assert "private-secret" not in str(caught.value) + str(events) + assert events[-1]["status"] == ( + "cancelled" if isinstance(error, asyncio.CancelledError) else "failed" + ) + + +@pytest.mark.parametrize( + "reply", + [ + response(""), + response("partial", finish_reason="length"), + response("[]", tool_calls=[{"id": "call", "name": "exec", "arguments": {}}]), + ], +) +def test_bad_reply_cannot_trigger_more_work(reply): + provider = Provider(reply) + with pytest.raises(InferenceError): + asyncio.run( + complete_once("facts", providers={"a": provider}, call=CallConfig(provider="a")) + ) + assert len(provider.requests) == 1 + + +def test_empty_role_resolution_never_silently_uses_expensive_default(): + provider = Provider() + + class Resolver: + async def resolve(self, role): + return [] + + with pytest.raises(InferenceError, match="no available provider"): + asyncio.run( + complete_once( + "facts", providers={"a": provider}, default_provider="a", role_resolver=Resolver() + ) + ) + assert provider.requests == [] + + +def test_missing_role_resolver_never_silently_uses_unique_default(): + provider = Provider() + with pytest.raises(InferenceError, match="no prepared resolver"): + asyncio.run(complete_once("facts", providers={"a": provider}, default_provider="a")) + assert provider.requests == [] + + +def test_no_role_default_remains_supported_and_sdk_model_is_unknown(): + provider = Provider() + provider.get_info = lambda: SimpleNamespace(defaults={"model": "declared-default"}) + result = asyncio.run( + complete_once("facts", providers={"a": provider}, call=CallConfig(role="")) + ) + assert len(provider.requests) == 1 and provider.requests[0].model is None + assert result.model is None and result.model_source is None + + +def test_role_resolution_error_is_safe_and_does_not_call_default(): + provider = Provider() + + class Resolver: + async def resolve(self, role): + raise RuntimeError("secret provider URL") + + with pytest.raises(InferenceError, match="Model-role resolution failed") as exc: + asyncio.run(complete_once("facts", providers={"a": provider}, role_resolver=Resolver())) + assert "secret" not in str(exc.value) and provider.requests == [] + + +def test_output_budget_is_capped_and_unsupported_routing_fails(): + provider = Provider() + + class Resolver: + def __init__(self): + self.config = {"max_output_tokens": 10000} + + async def resolve(self, role): + return [SimpleNamespace(provider="a", model="chosen", config=self.config)] + + resolver = Resolver() + asyncio.run(complete_once("facts", providers={"a": provider}, role_resolver=resolver)) + assert provider.requests[0].max_output_tokens == 4096 + resolver.config = {"extra_request_params": {"unknown": 1}} + with pytest.raises(InferenceError, match="unsupported inference options"): + asyncio.run(complete_once("facts", providers={"a": provider}, role_resolver=resolver)) + assert len(provider.requests) == 1 diff --git a/tests/test_init_timer.py b/tests/test_init_timer.py index b17949c..c84fdfb 100644 --- a/tests/test_init_timer.py +++ b/tests/test_init_timer.py @@ -41,6 +41,7 @@ def simulated_user_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) exercised with real temporary sockets in test_service.py. """ monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path / "simulated-runtime")) + monkeypatch.setattr("amplifier_memory.runtime.readiness", lambda home=None: {}) @pytest.fixture @@ -583,3 +584,11 @@ def test_the_offer_is_not_made_when_an_instance_was_named( def _never_asked(question: str) -> bool: raise AssertionError(f"init asked about the move when no offer was due: {question!r}") + + +@pytest.fixture(autouse=True) +def explicit_synthetic_timer_platform(monkeypatch): + """The default timer fixtures model systemd even when tests run on macOS.""" + monkeypatch.setattr( + "amplifier_memory.service.which_platform", lambda platform=None: platform or "systemd" + ) diff --git a/tests/test_llm_config.py b/tests/test_llm_config.py index 0b680d7..e62182a 100644 --- a/tests/test_llm_config.py +++ b/tests/test_llm_config.py @@ -49,7 +49,7 @@ def test_no_file_means_defaults_and_no_flags(tmp_path: Path) -> None: assert judge == llm_config.CallConfig(provider="", model="", bundle="", role="fast") assert judge.inherits is True assert judge.flags() == [], "an absent file must add no flag at all" - assert judge.render() == "inherits the CLI default" + assert judge.render() == "requests the host default" def test_the_config_lives_inside_the_instance_it_configures(monkeypatch) -> None: diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..ee0904b --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,336 @@ +"""Private inference runtime; no real provider or shared data.""" + +import asyncio +import json +import subprocess + +import pytest + +from amplifier_memory import runtime +from amplifier_memory.inference import InferenceError +from amplifier_memory.llm_config import CallConfig + + +def test_actual_core_mount_routing_accounts_and_private_cleanup(private_runtime): + fixture = private_runtime() + original = (fixture.shared / "settings.yaml").read_bytes() + result = asyncio.run(runtime.complete("synthetic facts", home=fixture.home)) + assert (result.provider, result.model) == ("account-b", "role-model") + assert sorted(fixture.module.closed) == sorted(row["account"] for row in fixture.module.mounted) + mounted = {row["account"]: row for row in fixture.module.mounted} + assert mounted["account-a"]["default_model"] == "local-model" + assert mounted["account-b"]["api_key"] == "process-key" + assert mounted["account-b"]["token_file_path"] == str(fixture.shared / "account-b.json") + assert (fixture.shared / "settings.yaml").read_bytes() == original + assert not list(fixture.shared.rglob("transcript.jsonl")) + job = next((fixture.home / "runtime/jobs").iterdir()) + meta = json.loads((job / "metadata.json").read_text()) + assert (meta["visibility"], meta["purpose"], meta["status"]) == ( + "internal", + "memory.suggestion", + "completed", + ) + assert meta["usage"]["total_tokens"] == 42 + assert "process-key" not in (job / "metadata.json").read_text() + assert "MUST NOT" not in (job / "transcript.jsonl").read_text() + + +def test_explicit_account_overrides_role(private_runtime): + f = private_runtime() + result = asyncio.run( + runtime.complete( + "facts", home=f.home, call=CallConfig(provider="account-a", model="explicit") + ) + ) + assert (result.provider, result.model) == ("account-a", "explicit") + assert len(f.module.calls) == 1 + + +@pytest.mark.parametrize("cancel", [False, True]) +def test_failure_and_cancel_cleanup(private_runtime, cancel): + f = private_runtime() + + async def run(): + if not cancel: + with pytest.raises(InferenceError, match="RuntimeError") as exc: + await runtime.complete("FAIL", home=f.home) + assert "private-error-body" not in str(exc.value) + return + task = asyncio.create_task(runtime.complete("WAIT", home=f.home)) + # The provider starts after mounting; wait without a completion deadline. + while f.package not in __import__("sys").modules: + if task.done(): + await task + await asyncio.sleep(0) + await f.module.started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(run()) + assert sorted(f.module.closed) == sorted(row["account"] for row in f.module.mounted) + meta = json.loads(next((f.home / "runtime/jobs").glob("*/metadata.json")).read_text()) + assert meta["status"] == ("cancelled" if cancel else "failed") + + +def test_readiness_offline_no_mount_or_install(private_runtime, monkeypatch): + f = private_runtime() + + def forbidden(*a, **kw): + raise AssertionError("No subprocess") + + monkeypatch.setattr(subprocess, "run", forbidden) + assert runtime.readiness(f.home)["sources"] == f.receipt["sources"] + assert f.package not in __import__("sys").modules + (f.shared / "settings.yaml").write_text("config: {}\n") + with pytest.raises(InferenceError): + runtime.readiness(f.home) + + +def test_changed_source_ref_and_bytes_reject(private_runtime): + f = private_runtime() + source = next( + __import__("pathlib").Path(f.receipt["modulePaths"]["provider-fixture"]).rglob("*.py") + ) + source.write_text(source.read_text() + "\n# changed\n") + with pytest.raises(InferenceError, match="changed or disappeared"): + asyncio.run(runtime.complete("facts", home=f.home)) + assert not (f.home / "runtime/jobs").exists() + + +def test_unprepared_never_installs(memory_home, monkeypatch): + monkeypatch.setattr(subprocess, "run", lambda *a, **kw: pytest.fail("hidden install")) + with pytest.raises(InferenceError, match="setup"): + asyncio.run(runtime.complete("facts", home=memory_home)) + assert not (memory_home / "runtime/jobs").exists() + + +def test_timer_install_and_start_require_readiness(memory_home, tmp_path): + from amplifier_memory import service + + calls = [] + runner = lambda argv: calls.append(argv) or (0, "") + unit_dir = tmp_path / "units" + blocked = service.install( + home=memory_home, + config_dir=unit_dir, + executable="/bin/amplifier-memory", + platform="systemd", + runner=runner, + ) + assert not blocked.ok and "setup" in blocked.render() + assert calls == [] and not unit_dir.exists() + + +def test_timer_ready_renders_without_provider_mount(private_runtime, tmp_path): + from amplifier_memory import service + + f = private_runtime() + calls = [] + result = service.install( + home=f.home, + config_dir=tmp_path / "units", + executable="/bin/amplifier-memory", + platform="systemd", + runner=lambda argv: calls.append(argv) or (0, ""), + ) + assert result.ok and calls + assert f.package not in __import__("sys").modules + + +def test_job_is_not_reingested_or_git_committed(private_runtime, store): + from amplifier_memory.suggest import read_session + + private_runtime(home=store) + before = subprocess.check_output(["git", "-C", str(store), "status", "--porcelain"]) + asyncio.run(runtime.complete("facts", home=store)) + after = subprocess.check_output(["git", "-C", str(store), "status", "--porcelain"]) + assert before == after == b"" + job = next((store / "runtime/jobs").iterdir()) + assert read_session(job) is None + + +def test_runtime_symlink_does_not_write_elsewhere(memory_home, tmp_path): + memory_home.mkdir() + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (memory_home / "runtime").symlink_to(elsewhere, target_is_directory=True) + with pytest.raises(InferenceError): + runtime._private_root(memory_home) + assert not list(elsewhere.iterdir()) + + +@pytest.mark.parametrize("existing", [True, False]) +def test_runtime_ignore_symlink_never_writes_target(memory_home, tmp_path, existing): + root = memory_home / "runtime" + root.mkdir(parents=True) + target = tmp_path / "outside-ignore" + if existing: + target.write_text("original") + (root / ".gitignore").symlink_to(target) + with pytest.raises(InferenceError, match="cannot be a symlink"): + runtime._private_root(memory_home) + assert target.exists() == existing + if existing: + assert target.read_text() == "original" + + +def test_bundle_include_only_routing_fails_before_setup_or_request(private_runtime, monkeypatch): + import yaml + + f = private_runtime() + settings_path = f.shared / "settings.yaml" + settings = yaml.safe_load(settings_path.read_text()) + settings["config"].pop("hooks") + settings["config"]["providers"] = settings["config"]["providers"][:1] + settings["bundle"] = {"active": "work"} + settings["includes"] = ["routing-matrix"] + settings_path.write_text(yaml.safe_dump(settings)) + monkeypatch.setattr(subprocess, "run", lambda *a, **kw: pytest.fail("No setup or install")) + with pytest.raises(InferenceError, match="bundle includes are not composed"): + asyncio.run(runtime.complete("facts", home=f.home)) + assert f.package not in __import__("sys").modules + assert not (f.home / "runtime/jobs").exists() + # Explicit selection does not claim to inherit unavailable bundle routing. + plan, _ = runtime._plan(f.receipt["settingsPaths"], CallConfig(provider="account-a")) + assert plan["hooks"] == [] + + +def test_custom_source_required_and_unsupported_bundle_fail(private_runtime): + f = private_runtime() + with pytest.raises(InferenceError, match="host-resolved"): + asyncio.run(runtime.complete("facts", home=f.home, call=CallConfig(bundle="custom"))) + settings = __import__("yaml").safe_load((f.shared / "settings.yaml").read_text()) + for provider in settings["config"]["providers"]: + provider.pop("source") + (f.shared / "settings.yaml").write_text(__import__("yaml").safe_dump(settings)) + with pytest.raises(InferenceError, match="explicit source"): + runtime.readiness(f.home) + + +def test_disabled_instance_setup_and_call_leave_no_jobs(private_runtime): + f = private_runtime() + (f.home / "config.yaml").write_text("enabled: false\n") + with pytest.raises(InferenceError, match="disabled"): + asyncio.run(runtime.prepare(f.home, shared_home=f.shared, workspace=f.workspace)) + with pytest.raises(InferenceError, match="disabled"): + asyncio.run(runtime.complete("facts", home=f.home)) + assert not (f.home / "runtime/jobs").exists() + + +def test_default_update_never_invokes_or_discovers_another_cli(monkeypatch, store): + from amplifier_memory import update + + calls = [] + monkeypatch.setattr("amplifier_memory.runtime.readiness", lambda home=None: {}) + monkeypatch.setattr( + __import__("importlib").import_module("amplifier_memory.doctor"), + "amplifier_env_python", + lambda: pytest.fail("CLI discovery"), + ) + result = update.run_update( + home=store, + runner=lambda argv: calls.append(argv) or (0, ""), + timer_installed=False, + installed_commit_fn=lambda: "unchanged", + doctor_fn=lambda: __import__("amplifier_memory").doctor( + store, installed_sha=None, remote_sha=None + ), + ) + assert calls == [("uv", "tool", "upgrade", "amplifier-memory")] + assert result.exit_code == 0 + + +def test_standalone_credential_boundary_only_populates_missing_values(private_runtime, monkeypatch): + import importlib + import os + + f = private_runtime() + (f.shared / "keys.env").write_text("FIXTURE_KEY=file-key\nMEMORY_TEST_ONLY=file-only\n") + monkeypatch.delenv("MEMORY_TEST_ONLY", raising=False) + seen = [] + monkeypatch.setattr( + importlib.import_module("amplifier_memory.suggest"), + "run_suggest", + lambda home: seen.append(dict(os.environ)), + ) + before = dict(os.environ) + runtime.standalone_suggest(f.home) + assert seen[0]["FIXTURE_KEY"] == "process-key" + assert seen[0]["MEMORY_TEST_ONLY"] == "file-only" + assert all(seen[0][key] == value for key, value in before.items()) + os.environ.pop("MEMORY_TEST_ONLY") + + +def test_library_inference_never_changes_credential_environment(private_runtime, monkeypatch): + import os + + f = private_runtime() + (f.shared / "keys.env").write_text("FIXTURE_KEY=file-key\nMEMORY_TEST_ONLY=file-only\n") + monkeypatch.delenv("MEMORY_TEST_ONLY", raising=False) + before = dict(os.environ) + asyncio.run(runtime.complete("facts", home=f.home)) + assert dict(os.environ) == before + + +def test_cleanup_failure_is_safe_and_recorded(private_runtime, monkeypatch): + import amplifier_core + + real = amplifier_core.AmplifierSession + + class Session: + def __init__(self, *a, **kw): + self.real = real(*a, **kw) + + @property + def coordinator(self): + return self.real.coordinator + + async def initialize(self): + return await self.real.initialize() + + async def cleanup(self): + await self.real.cleanup() + raise RuntimeError("private-cleanup-secret") + + monkeypatch.setattr(amplifier_core, "AmplifierSession", Session) + f = private_runtime() + with pytest.raises(InferenceError, match="cleanup failed") as exc: + asyncio.run(runtime.complete("facts", home=f.home)) + assert "private-cleanup-secret" not in str(exc.value) + meta = json.loads(next((f.home / "runtime/jobs").glob("*/metadata.json")).read_text()) + assert meta["status"] == "cleanup_failed" and meta["cleanupErrorType"] == "RuntimeError" + assert sorted(f.module.closed) == sorted(row["account"] for row in f.module.mounted) + + +def test_failed_explicit_setup_preserves_selected_receipt(private_runtime, monkeypatch): + from pathlib import Path + from types import SimpleNamespace + + from amplifier_foundation.bundle import Bundle, BundleModuleResolver + + f = private_runtime() + selected = f.home / "runtime/prepared.json" + before = selected.read_bytes() + + async def prepared(self, **kwargs): + assert kwargs["strict"] is True and kwargs["install_deps"] is False + return SimpleNamespace( + resolver=BundleModuleResolver( + {name: Path(path) for name, path in f.receipt["modulePaths"].items()} + ) + ) + + monkeypatch.setattr(Bundle, "prepare", prepared) + argv = [] + + def fail(command, **kwargs): + argv.append(command) + return SimpleNamespace(returncode=1, stderr="private-install-error") + + monkeypatch.setattr(subprocess, "run", fail) + with pytest.raises(InferenceError, match="installation failed") as exc: + asyncio.run(runtime.prepare(f.home, workspace=f.workspace, shared_home=f.shared)) + assert selected.read_bytes() == before and len(argv) == 1 + assert "--only-binary" in argv[0] and "amplifier-core" in argv[0] + assert "private-install-error" not in str(exc.value) diff --git a/tests/test_service.py b/tests/test_service.py index edc7f5d..a7de52e 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -745,3 +745,17 @@ def test_the_real_runner_refuses_while_the_unit_dir_is_redirected( real_runner(["systemctl", "--user", "enable", "--now", "whatever.timer"]) print(refused.value) assert "would act on this device's own units" in str(refused.value) + + +@pytest.fixture(autouse=True) +def prepared_runtime_for_existing_host_contracts(monkeypatch): + """These tests isolate timer/update behavior; readiness has dedicated runtime tests.""" + monkeypatch.setattr("amplifier_memory.runtime.readiness", lambda home=None: {}) + + +@pytest.fixture(autouse=True) +def explicit_synthetic_timer_platform(monkeypatch): + """The default timer fixtures model systemd even when tests run on macOS.""" + monkeypatch.setattr( + "amplifier_memory.service.which_platform", lambda platform=None: platform or "systemd" + ) diff --git a/tests/test_suggest.py b/tests/test_suggest.py index 7d25311..2aa2c87 100644 --- a/tests/test_suggest.py +++ b/tests/test_suggest.py @@ -159,30 +159,17 @@ def test_prompt_preserves_placeholder_shaped_list_content() -> None: assert filled.count("literal") == 1 -def test_the_default_argv_matches_amplifier_run_help() -> None: - """AGENTS.md rule 5: the argv is checked against that CLI's own --help, output shown. +def test_default_runtime_never_probes_the_cli(monkeypatch) -> None: + def forbidden(*args, **kwargs): + raise AssertionError("No CLI capability probe") - The lesson this rule exists for: `amplifier run --once` did not exist and shipped - anyway. The flag here is `--output-format`, not `--output`. - """ - proc = subprocess.run( - ["amplifier", "run", "--help"], capture_output=True, text=True, check=False - ) - if proc.returncode != 0: - pytest.skip(f"no `amplifier` on this PATH: {proc.stderr.strip()[:120]}") - print(proc.stdout) - assert suggest.RUN_ARGV == ("amplifier", "run", "--output-format", "json") - assert "--output-format" in proc.stdout - assert "[text|json|json-trace]" in proc.stdout - # The three flags the LLM-call knob adds. Checked the same way and in the same - # change, for the same reason: advice that does not exist is what rule 5 is for. - for flag in ("-B, --bundle", "-p, --provider", "-m, --model"): - assert flag in proc.stdout, f"amplifier run --help does not document {flag}" - print(f"documented: {['-B, --bundle', '-p, --provider', '-m, --model']}") + monkeypatch.setattr(subprocess, "run", forbidden) + assert suggest.host_help() == "" + assert "no CLI" in suggest.judge_detail(help_text="") def test_the_default_model_call_refuses_to_run_from_a_test() -> None: - with pytest.raises(RuntimeError, match="refusing to call the model from a test"): + with pytest.raises(RuntimeError, match="Tests must inject model_call"): suggest.default_model_call("anything") print("default_model_call under pytest: refused, loudly") @@ -592,7 +579,7 @@ def test_every_run_writes_exactly_one_log_line_even_when_empty( "rejected", "dropped_stale", "calls", - "invalid_reasons", + "invalid_reasons", # Core 8 asks for cost that is visible: the line names which provider was # billed. `model=` joins it only when the config named one. "provider", @@ -893,27 +880,44 @@ def test_a_malformed_config_is_reported_the_default_is_inherited_and_the_run_fin assert suggest.parse_log_line(report.log_line)["status"] == report.status -def test_run_suggest_gives_the_default_call_the_judges_flags( +def test_run_suggest_gives_direct_runner_the_exact_judge_and_records_usage( store: Path, substrate: Path, tmp_path: Path, monkeypatch ) -> None: - """The config reaches the process that would actually be spawned, not just the log.""" - seen: list[list[str]] = [] + from amplifier_memory.inference import Completion - def fake_run(argv, **_kwargs): - seen.append(list(argv)) - return subprocess.CompletedProcess(argv, 0, '{"response": "[]"}', "") + seen = [] + + def fake_completion(prompt, *, call, home): + seen.append((prompt, call)) + return Completion( + "[]", + "luna", + "selected", + {"input_tokens": 12, "output_tokens": 2, "total_tokens": 14, "cost_usd": "0.001"}, + ) - monkeypatch.setattr(suggest.subprocess, "run", fake_run) - monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) # past default_model_call's guard - amplifier_memory.run_suggest( + monkeypatch.setattr(suggest, "default_completion", fake_completion) + report = amplifier_memory.run_suggest( store, base_path=substrate, config=_config(tmp_path, 'llm:\n judge:\n provider: "luna"\n'), - help_text="", # the host probe is injected too: this test spawns nothing but the call ) - print(seen[0][:-1], "") - assert len(seen) == 1 - assert seen[0][:-1] == ["amplifier", "run", "--output-format", "json", "-p", "luna"] + assert len(seen) == 1 and seen[0][1].provider == "luna" + assert report.provider == "luna" and report.model == "selected" + assert report.inference == [ + { + "provider": "luna", + "model": "selected", + "modelSource": None, + "status": "completed", + "usage": { + "input_tokens": 12, + "output_tokens": 2, + "total_tokens": 14, + "cost_usd": "0.001", + }, + } + ] # --------------------------------------------- Core 3: the turns are data, not instructions @@ -1243,29 +1247,15 @@ def test_doctor_names_the_judge_through_the_library(tmp_path: Path) -> None: for detail in (inherited, by_role, named): print("-", detail) - assert suggest.INHERITED in inherited - assert f"${suggest.INHERITED_COST_USD:.3f}/call" in inherited, "Core 8: the measured cost" - assert suggest.INHERITED_COST_SOURCE in inherited, "and where it was measured" - assert f"${suggest.INHERITED_COST_USD * suggest.MAX_CALLS:.2f}" in inherited, "a night's bill" - assert suggest.MODEL_ROLE_FLAG in by_role and "role fast" in by_role + assert "role fast requested" in inherited and "no CLI" in inherited + assert "Selected provider, model provenance and usage" in inherited + assert "private memory session" in inherited and "fails visibly" in inherited + assert "model-role resolver" in by_role and "role fast" in by_role assert "provider luna" in named -def test_the_host_probe_reads_amplifier_run_help_and_this_host_has_no_model_role() -> None: - """AGENTS.md rule 5: the flag is checked against that CLI's own --help, output shown.""" - proc = subprocess.run(list(suggest.HELP_ARGV), capture_output=True, text=True, check=False) - if proc.returncode != 0: - pytest.skip(f"no `amplifier` on this PATH: {proc.stderr.strip()[:120]}") - print(proc.stdout) - assert suggest.HELP_ARGV == ("amplifier", "run", "--help") - print( - f"{suggest.MODEL_ROLE_FLAG} documented by this host: " - f"{suggest.host_resolves_roles(proc.stdout)}" - ) - # Measured 2026-09-07: this CLI documents -B/-p/-m and no --model-role, which is why - # an unconfigured run lands on `inherited` and says so rather than inventing a flag. - assert suggest.host_resolves_roles(proc.stdout) is (suggest.MODEL_ROLE_FLAG in proc.stdout) - assert suggest.host_help() == "", "under pytest the probe is inert unless injected" +def test_host_diagnostic_injection_is_inert_without_a_runner() -> None: + assert suggest.host_help() == "" assert suggest.host_help(lambda: "x --model-role y") == "x --model-role y" @@ -1287,3 +1277,21 @@ def test_a_run_over_a_worker_only_night_still_reports(tmp_path: Path, store: Pat assert report.status == "ok", "a night with no human sessions is not a degraded night" assert "sessions=0 origin_excluded=3" in report.log_line assert call.prompts == [], "no session read means no model call" + + +def test_default_suggest_callback_runs_private_session_without_cli( + store, substrate, tmp_path, monkeypatch, private_runtime +): + fixture = private_runtime(home=store, reply=json.dumps([GOOD, POISONED])) + monkeypatch.delenv("PYTEST_CURRENT_TEST") + before = {p.relative_to(substrate): p.read_bytes() for p in substrate.rglob("*") if p.is_file()} + report = amplifier_memory.run_suggest(store, base_path=substrate) + assert (report.status, report.calls, report.proposed, report.rejected) == ("ok", 1, 1, 1) + assert report.provider == "account-b" and report.model == "role-model" + assert report.inference[0]["usage"]["total_tokens"] == 42 + assert { + p.relative_to(substrate): p.read_bytes() for p in substrate.rglob("*") if p.is_file() + } == before + assert len(list((store / "runtime/jobs").glob("*/transcript.jsonl"))) == 1 + assert not list(fixture.shared.rglob("transcript.jsonl")) + assert sorted(fixture.module.closed) == sorted(row["account"] for row in fixture.module.mounted) diff --git a/tests/test_update.py b/tests/test_update.py index 8ed8005..269b193 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -509,8 +509,8 @@ def test_run_update_prints_the_plan_the_stale_note_and_doctor() -> None: print(out) assert "uv tool upgrade amplifier-memory" in out assert "amplifier bundle add" in out and "--app" in out - assert "git fetch origin" in out and "reset --hard origin/main" in out, "the plan's step 2" - assert "uv pip install" in out, "the plan's step 3" + assert "amplifier-memory setup" in out + assert "other host environments" in out assert "keep the old module code until they restart" in out, "the stale-in-memory note" assert "amplifier-memory doctor — store:" in out, "update did not end by running doctor" @@ -525,7 +525,7 @@ def test_phase_1_skips_the_timer_and_says_why_in_the_librarys_own_words() -> Non timer = next(step for step in result.steps if "timer" in step.name) print(timer.render()) assert timer.skipped - assert timer.reason == amplifier_memory.service_status("restart").splitlines()[0] + assert "not restarted" in timer.reason assert not timer.failed @@ -754,3 +754,9 @@ def test_the_default_runner_never_raises_on_a_missing_executable() -> None: print(code, out) assert code == 127 assert "Error" in out or "error" in out.lower() + + +@pytest.fixture(autouse=True) +def prepared_runtime_for_existing_host_contracts(monkeypatch): + """Update mechanics are isolated from separately tested inference readiness.""" + monkeypatch.setattr("amplifier_memory.runtime.readiness", lambda home=None: {}) diff --git a/uv.lock b/uv.lock index 0db6957..fdfa093 100644 --- a/uv.lock +++ b/uv.lock @@ -2,33 +2,80 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "amplifier-core" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/53/90edfa9ff6b213f0be37d1cd2ee49df7b644b7d19ac7c8d0331f1ff43775/amplifier_core-2.0.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:445f1e009d2411d55d6dac2ad88484844e688b2cb2901501096faae8a2ac8fdd", size = 8330603, upload-time = "2026-09-23T19:04:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/f865096994e1777274ffc13895efc0c70fb085f96f017083cf2bb6a17562/amplifier_core-2.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9aa0664c2b349e0d90cf20cb2e5e663c37cd54b04fd0cb9776f57ecf31f6ea4", size = 7424176, upload-time = "2026-09-23T19:04:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/fd2653b45a6aba6667ad13113713b7fabfd93200a7a655ecc81dcb7ed162/amplifier_core-2.0.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2ac4f49bf2858d83bc6ab541ca3fc8042dd17807d25b97f598a1f06b7c305e7", size = 7775409, upload-time = "2026-09-23T19:04:51.956Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/a0360024275c1e214a5d65370ffdb87b35986bd6a1681e1e1c62e34197ac/amplifier_core-2.0.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1a8e7183260b738c32ca16b64f5aacc3e71de2c51918702eae5424ab4f07906", size = 8793506, upload-time = "2026-09-23T19:04:54.038Z" }, + { url = "https://files.pythonhosted.org/packages/42/c0/dbdd12f77d8ded4d76d0170606056d2f493d1d86d08db3b89b579d79904a/amplifier_core-2.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:7d3ea206b6d3f9537115a2afc8b99f5d9e34e9f3236fdc48d8dd6d2d8a631184", size = 9085723, upload-time = "2026-09-23T19:04:56.293Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f3/3042735327bc6fb6216b92632e1d096b6a312c2c0e8f6d111f1b259a56f7/amplifier_core-2.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:7834782f88b764ffb6374b734b74f2e55bc9e57e99197a1ed3d1ab8ae6562e89", size = 7798444, upload-time = "2026-09-23T19:04:58.493Z" }, +] + +[[package]] +name = "amplifier-foundation" +version = "1.0.0" +source = { git = "https://github.com/microsoft/amplifier-foundation?rev=main#2a63c56ef11e5e9b49a1268c4845e3046d397230" } +dependencies = [ + { name = "amplifier-core" }, + { name = "filelock" }, + { name = "pyyaml" }, +] + [[package]] name = "amplifier-memory" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "amplifier-core" }, + { name = "amplifier-foundation" }, { name = "click" }, { name = "pyyaml" }, + { name = "uv" }, ] [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "amplifier-core", specifier = ">=2.0.1" }, + { name = "amplifier-foundation", git = "https://github.com/microsoft/amplifier-foundation?rev=main" }, { name = "click", specifier = ">=8.1" }, { name = "pyyaml", specifier = ">=6" }, + { name = "uv", specifier = ">=0.8" }, ] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, { name = "ruff", specifier = ">=0.6" }, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -47,6 +94,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "filelock" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/38/88cd6eda96c40594a1e3da7d8b40f04bc40ace5a6aef9ac5cb407540f173/filelock-4.0.1.tar.gz", hash = "sha256:fdefc3f3e87716d855ae2b732c1cfd521dd99799ef2b4d00e8c0d4dcdc7cc94b", size = 238888, upload-time = "2026-09-19T01:08:15.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/33/af0635ab07fe83b1788a1dbe370ff3e226062495a998335cb18a1cac81aa/filelock-4.0.1-py3-none-any.whl", hash = "sha256:481a321a27bef441e23c53371c6abc8d7d16e26b97090074ba44f7538a3fd55a", size = 106219, upload-time = "2026-09-19T01:08:14.49Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -74,6 +130,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -99,6 +272,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -178,3 +364,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uv" +version = "0.12.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/5c/f1adb285f759e8ce6ea397ecf45372c20c32588a9834cfeb1125cd0536b6/uv-0.12.18.tar.gz", hash = "sha256:0a8fa0c6408c1bedf8a19219b471d6c14ae98695940b20269fcb7514a136cfa4", size = 7094303, upload-time = "2026-09-22T22:59:00.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/15/c4307b7ac5c82517cc0982e0a4e54ffd1f53b50e18b7e66a24ed92de1a71/uv-0.12.18-py3-none-linux_armv6l.whl", hash = "sha256:98a8a4786db518b4d708eacc96aadfc0626072d5873c708bcab8239e6453176d", size = 22601778, upload-time = "2026-09-22T22:58:15.871Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5d/a00b39652484c4d0a25532f15b701e0959d15803608b9cc35c336d2cf243/uv-0.12.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:799dcef1ef97ab6491b34f5827e2da8dd9153d65be056c779b60a2d4d3d6c45b", size = 20963625, upload-time = "2026-09-22T22:58:19.189Z" }, + { url = "https://files.pythonhosted.org/packages/4b/40/b08c15b1f886c90739241f121b0d9f786ffecc2a682b1e75f29cfb377a7e/uv-0.12.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9aa0ef69c7cb2aff485a0e9ce46feeb7b6dbeef3d796ffd04b944cf355ba8334", size = 17080512, upload-time = "2026-09-22T22:58:21.435Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ee/c8cee0e2531221b5ad1542b5f25cff446f5f605980b88ce6627d81decdac/uv-0.12.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:77b71bc25d274a04594d32167b0f766fb5b6805980c3aed02872c468e1905e7e", size = 22080274, upload-time = "2026-09-22T22:58:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/38/71/c2b5f826432e5ce44fef6112172130c6527fd73934e2b0c4410b96b6a803/uv-0.12.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:145f17e2b7f165da9bd4dd14c4c2525d5b1647cb8daadb684106babedbc502ff", size = 22088832, upload-time = "2026-09-22T22:58:26.399Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/5196295fcde0933d0de2b148484c6e09257e7fd3b9f6dea8377af651c3d6/uv-0.12.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b656468f973ac363ac96b0b6b9bf9c5f5836cd84b664d0ca67b67bb8e35f9bcf", size = 22125303, upload-time = "2026-09-22T22:58:28.93Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ab/2d2d2fd766c936f7740c5c15c075c8754ea2c4a7258d2153a5839ede550a/uv-0.12.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99c64f9f3eea3eb3fd496c29da47462824da0e47775b3b39d444d9832c815549", size = 22784284, upload-time = "2026-09-22T22:58:31.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/a9c3e16fc069b27cc91a56ab3cc005322dbb04eec27cd825df74566baf73/uv-0.12.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5037ec17654c756fc9841270f413c5fc9ecef89108b443f5dd34600a3ccdb19", size = 24206420, upload-time = "2026-09-22T22:58:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/e1/51/df04dac9d701d39d265ffc5bd48667808e6adc22bb9ab7b15224e9348a05/uv-0.12.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c237410b6b193e691c7c37752fd7f3cfebd9b5d5331c53e748f004c6397334f", size = 23951572, upload-time = "2026-09-22T22:58:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/def11543e7bd3b4219ca29afd66f3accab641324bd2e426cb788083c56c5/uv-0.12.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbe0489871e74ebfb70379a32526c62b9fb615acf13576b9f19bc09a142f62b0", size = 20450568, upload-time = "2026-09-22T22:58:39.162Z" }, + { url = "https://files.pythonhosted.org/packages/20/6c/b1f6c714dd960bd6ed57f522051af278a6f811a71a5c1598d141164f7ec7/uv-0.12.18-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:328abf19b5992b4846f96d1d4440cdd9e0b7d20c0f00b0644e68ca8cfb561c26", size = 19866961, upload-time = "2026-09-22T22:58:41.808Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cf/26fc7ed52059975ab572da0299cd991cca8a3790dbbf4f5fb6d4277ddc23/uv-0.12.18-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:9c77a4de02c2b7737f6d4eb5fcd359bc0f0948f2a4133f8095c527addb84fc56", size = 22946695, upload-time = "2026-09-22T22:58:44.622Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/005ce467de35b130014dc5c93a0e546a7f8c8d2ca783c6724fe00bc510a9/uv-0.12.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e7ab1387b222ab640dbd057b95548a199e4c621aa35e9f6a32c0516c76e7637d", size = 23075146, upload-time = "2026-09-22T22:58:46.908Z" }, + { url = "https://files.pythonhosted.org/packages/ac/35/00bc752a8b7e92721fd7773368178ef63c2bdad396c5189603db64414ccd/uv-0.12.18-py3-none-musllinux_1_1_i686.whl", hash = "sha256:03da66a73d0ac01fb7a95d096aebf5993b51ec65df620d57f415e111e6789c88", size = 21920505, upload-time = "2026-09-22T22:58:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e0/48c2918c384fdc4195dacc70736771515b30e77180a75ec49fa39bd9d16d/uv-0.12.18-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:53a035e48cbe94ca47ee304b293060b690d0ac03140a64ff3ff4713eabcede99", size = 23351251, upload-time = "2026-09-22T22:58:51.485Z" }, + { url = "https://files.pythonhosted.org/packages/58/f2/c177f74568713f23ff97746bc300f109560013033ebd21b7e70ccc533040/uv-0.12.18-py3-none-win32.whl", hash = "sha256:cbbfcca37cf678b5135f61f722631a2a85e2eddc459ba64692054011f160f6c2", size = 19981835, upload-time = "2026-09-22T22:58:53.694Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/5f33b9bad8cb875ed547ef8aa403d423a2610f23f619f687308761dd02e1/uv-0.12.18-py3-none-win_amd64.whl", hash = "sha256:0a3c4e415515bd4c24c7516523d9042a2957ce716423e193f8b2be9fb8773340", size = 17986064, upload-time = "2026-09-22T22:58:56.301Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f2/178a7353ba2426a275dc6815a276628ef00912a972d71666ca7f6bb93bb9/uv-0.12.18-py3-none-win_arm64.whl", hash = "sha256:4f9769cf34558a026ff9b8e04cdf9d25c16fe3d61f35956daf6548f2b0379ff0", size = 19304755, upload-time = "2026-09-22T22:58:58.512Z" }, +]