diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..9174f419 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell entrypoints are executed with sh/dash inside the container image. A +# Windows checkout with core.autocrlf=true rewrites them to CRLF, and dash +# then aborts with "set: Illegal option" because the carriage return becomes +# part of the "-e" flag argument (docker/entrypoint.sh). +*.sh text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 039d4e6e..09ed2279 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,48 @@ # # The workflow gates only understand individual @usernames (no @org/team # entries). +# +# Order matters: GitHub applies the LAST matching pattern, so narrower rules +# go further down. Paths not listed here have no automatic reviewer. + +# Telemetry, tracing, metrics. +/src/telemetry/ @akattelu @Rajat-Ahuja1997 + +# Data model, connections, configuration, LLM transport. +/src/db.py @akattelu @eisene +/src/models.py @akattelu @eisene +/src/config.py @akattelu @eisene +/src/cache/ @akattelu @eisene +/src/crud/ @akattelu @eisene +/migrations/ @akattelu @eisene +/src/llm/ @akattelu @eisene + +# Client-facing surfaces and API shape. +/sdks/ @ajspig @akattelu +/mcp/ @ajspig @akattelu +/honcho-cli/ @ajspig @akattelu +/src/routers/ @ajspig @akattelu +/src/schemas/ @ajspig @akattelu + +# The reasoning agents, their prompts, and shared agent tooling. +/src/deriver/ @eisene @akattelu +/src/dreamer/ @eisene @akattelu +/src/dialectic/ @eisene @akattelu +/src/utils/ @eisene @akattelu + +# Deployment, and swappable storage and inference backends. +# /src/llm/backends/ must stay below /src/llm/ above β€” last match wins. +/docker/ @eisene @Rajat-Ahuja1997 +/Dockerfile @eisene @Rajat-Ahuja1997 +/docker-compose.yml.example @eisene @Rajat-Ahuja1997 +/src/vector_store/ @eisene @Rajat-Ahuja1997 +/src/llm/backends/ @eisene @Rajat-Ahuja1997 + +# Documentation and contributor-facing policy. +/docs/ @ajspig @akattelu +/README.md @ajspig @akattelu +/CONTRIBUTING.md @akattelu @ajspig +/SECURITY.md @Rajat-Ahuja1997 @ajspig # Reviewers auto-requested on changes under .github/ (workflows, this file, # templates). @@ -16,4 +58,4 @@ # CI-trigger allowlist only: this path matches no real file, so these people # are never auto-requested for review, but the workflow gates still pick # them up. -/ci-trigger-allowlist @3un01a @adavyas @ajspig @courtlandleer @erosika @lowyelling @matthewlanders @vintrocode +/ci-trigger-allowlist @ajspig @courtlandleer @erosika @lowyelling @vintrocode diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md deleted file mode 100644 index 22a18fcb..00000000 --- a/.github/ISSUE_TEMPLATE/1-bug-report.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -name: "🐞 Bug Report" -about: "Report an issue to help the project improve." -title: "[Bug] " -labels: "bug" -assignees: "" - ---- - -# **🐞 Bug Report** - -## **Describe the bug** - - -* - ---- - -### **Is this a regression?** - - - ---- - -### **To Reproduce** - - - - - -1. -2. -3. -4. - ---- - -### **Expected behaviour** - - -* - ---- - -### **Media prove** - - ---- - -### **Your environment** - - - -* OS: -* Browser name and version: -* Honcho Server Version: -* Honcho Client Version: - ---- - -### **Additional context** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 00000000..38373789 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,72 @@ +name: Bug report +description: Something is broken or incorrect in Honcho (API, deriver, SDK, managed offering, etc.). +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug. Please search [existing issues](https://github.com/plastic-labs/honcho/issues) first. + + **Security vulnerability?** Do not use this form β€” report privately via [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md). + + **Memory / recall quality** (wrong or noisy conclusions, weak dialectic answers) with no crash? Prefer the **Memory / recall quality** template. + + - type: dropdown + id: deploy_mode + attributes: + label: Deploy mode + description: Where are you running Honcho? + options: + - Managed (api.honcho.dev / app.honcho.dev) + - Self-hosted + - Unsure + validations: + required: true + + - type: input + id: version + attributes: + label: Honcho version + description: Server image tag or release, and SDK version if you use one. Write "managed" if you are not self-hosting. + placeholder: e.g. server v2.4.1, honcho-ai 2.1.0 + validations: + required: true + + - type: textarea + id: description + attributes: + label: Describe the bug + description: Clear and concise description of what is wrong. + placeholder: When I…, Honcho… + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Minimal steps or a short script/API sequence. Redact secrets, JWTs, and production user content. + placeholder: | + 1. Create a session with … + 2. POST /v3/... with body … + 3. Observe … + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs and evidence + description: Relevant API or deriver logs or stack traces. Redact secrets and user content. + render: shell + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Config knobs, deployment notes, screenshots, related issues/PRs. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2-failing-test.md b/.github/ISSUE_TEMPLATE/2-failing-test.md deleted file mode 100644 index fed65816..00000000 --- a/.github/ISSUE_TEMPLATE/2-failing-test.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: "πŸ’‰ Failing Test" -about: "Report failing tests or CI jobs." -title: "[Test] " -labels: "Type: Test" -assignees: "" - ---- - -# **πŸ’‰ Failing Test** - -## **Which jobs/test(s) are failing** - - -* - ---- - -## **Reason for failure/description** - - ---- - -### **Media prove** - - ---- - -### **Additional context** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/2-quality-report.yml b/.github/ISSUE_TEMPLATE/2-quality-report.yml new file mode 100644 index 00000000..2a2858a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-quality-report.yml @@ -0,0 +1,87 @@ +name: Memory / recall quality +description: Conclusions, representations, or dialectic answers are wrong, noisy, missing, or low-quality β€” not a hard crash. +title: "[Quality] " +labels: ["quality"] +body: + - type: markdown + attributes: + value: | + Use this when Honcho runs without erroring, but **memory formation or recall quality** is off (bad conclusions, missed facts, weak chat answers, polluted representations, etc.). + + For crashes, 5xxs, auth failures, or incorrect API mechanics, use the **Bug report** template instead. + + **Do not paste production user content, full peer representations, or secrets.** Redact or invent a minimal synthetic example. + + - type: dropdown + id: deploy_mode + attributes: + label: Deploy mode + options: + - Managed (api.honcho.dev / app.honcho.dev) + - Self-hosted + - Unsure + validations: + required: true + + - type: input + id: version + attributes: + label: Honcho version + description: Server image tag or release, and SDK version if you use one. Write "managed" if you are not self-hosting. + placeholder: e.g. server v2.4.1, honcho-ai 2.1.0 + validations: + required: true + + - type: textarea + id: description + attributes: + label: What is wrong with the quality? + description: Describe the failure mode (noise, omission, contradiction, staleness, over/under-generalization, etc.). + placeholder: After ingesting messages about X, Honcho concludes Y / chat answers Z… + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Minimal scenario + description: > + Smallest synthetic message sequence or setup that triggers the issue. + Prefer invented names/facts over real user data. Include observer/observed + peer setup if relevant (self vs cross-peer). + placeholder: | + 1. Peers: alice (user), bot (agent); session S + 2. Messages ingested: … + 3. Query / conclusion listing shows: … + validations: + required: true + + - type: textarea + id: config + attributes: + label: Relevant config + description: > + Custom instructions, provider/model, deriver/dream settings, or workspace/peer + config that affects reasoning. Redact secrets. + placeholder: | + Provider/model: … + Custom instructions: (summary or redacted) + Other: … + validations: + required: false + + - type: textarea + id: evidence + attributes: + label: Evidence + description: Redacted conclusion text, chat excerpts, or counts that show the failure. No production PII. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Frequency, scale (message/conclusion counts), related issues, workarounds. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3-feature-request.yml b/.github/ISSUE_TEMPLATE/3-feature-request.yml new file mode 100644 index 00000000..f0d935ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-feature-request.yml @@ -0,0 +1,58 @@ +name: Feature request +description: Propose a new capability or an improvement to an existing one. +title: "[Feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Tell us what problem you are trying to solve. Concrete use cases beat abstract wishlists. + + Questions about how to use Honcho belong on [Discord](https://discord.gg/honcho), not here. + + - type: dropdown + id: request_type + attributes: + label: Request type + options: + - New capability + - Improve an existing capability + - API / SDK surface + - Managed offering + - Docs / DX + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What is hard or impossible today? Who hits this? + placeholder: I'm always frustrated when… / My integration needs… + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What you would like Honcho to support. Sketches and API shapes welcome. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Workarounds, other APIs, or designs you already tried or ruled out. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Links, prior art, screenshots, related issues/PRs. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/4-feature-request.md b/.github/ISSUE_TEMPLATE/4-feature-request.md deleted file mode 100644 index 00400dca..00000000 --- a/.github/ISSUE_TEMPLATE/4-feature-request.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: "πŸš€πŸ†• Feature Request" -about: "Suggest an idea or possible new feature for this project." -title: "" -labels: 'feature' -assignees: '' - ---- - -# **πŸš€ Feature Request** - -## **Is your feature request related to a problem? Please describe.** - - -* - ---- - -## **Describe the solution you'd like** - - -* - ---- - -## **Describe alternatives you've considered** - - -* - ---- - -### **Additional context** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/4-integration-request.yml b/.github/ISSUE_TEMPLATE/4-integration-request.yml new file mode 100644 index 00000000..a3a45db4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-integration-request.yml @@ -0,0 +1,70 @@ +name: Integration request +description: Add Honcho to an app store, agent framework, plugin marketplace, or other third-party surface β€” or improve an existing integration. +title: "[Integration] " +labels: ["integration"] +body: + - type: markdown + attributes: + value: | + Use this when you want Honcho available in (or better supported by) an external product surface β€” app stores, agent frameworks, plugin marketplaces, IDE extensions, MCP clients, etc. + + For core API/SDK product features that are not about a third-party surface, use the **Feature request** template instead. + + - type: dropdown + id: request_kind + attributes: + label: What kind of request is this? + options: + - New integration / listing (Honcho is not there yet) + - Improve an existing integration + - Official plugin / extension + - Marketplace or app-store listing + - Docs / guide for integrating with a specific tool + - Other + validations: + required: true + + - type: input + id: target + attributes: + label: Target product or platform + description: Name of the app, framework, marketplace, or tool. + placeholder: e.g. Claude Code, Cursor, CrewAI, OpenClaw, VS Code Marketplace… + validations: + required: true + + - type: input + id: target_url + attributes: + label: Link (if any) + description: Docs, marketplace page, repo, or product URL. + placeholder: https://… + validations: + required: false + + - type: textarea + id: why + attributes: + label: Why does this matter? + description: Who would use it, and what does the integration unlock? + validations: + required: true + + - type: textarea + id: shape + attributes: + label: What should the integration look like? + description: > + e.g. one-click install, MCP server listing, native memory backend, + SDK recipe, plugin with slash commands, env-var setup, etc. + Link to prior art or a sketch if you have one. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Related issues, community demand, constraints, offers to help build it. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3-docs-bug.md b/.github/ISSUE_TEMPLATE/5-docs-bug.md similarity index 73% rename from .github/ISSUE_TEMPLATE/3-docs-bug.md rename to .github/ISSUE_TEMPLATE/5-docs-bug.md index 7693d543..1169ff15 100644 --- a/.github/ISSUE_TEMPLATE/3-docs-bug.md +++ b/.github/ISSUE_TEMPLATE/5-docs-bug.md @@ -8,6 +8,10 @@ assignees: "" --- # **πŸ“š Documentation Issue Report** +**Security vulnerability?** Do not use this form β€” report privately via [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md). + +GitHub issues are public. Redact secrets, JWTs, and production user content. + ## **Describe the bug** @@ -33,8 +37,8 @@ assignees: "" --- -### **Media prove** - +### **Screenshots and videos** + --- @@ -46,7 +50,7 @@ assignees: "" --- ### **Additional context** - + * diff --git a/.github/ISSUE_TEMPLATE/5-enhancement-request.md b/.github/ISSUE_TEMPLATE/5-enhancement-request.md deleted file mode 100644 index d75f8756..00000000 --- a/.github/ISSUE_TEMPLATE/5-enhancement-request.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: "πŸš€βž• Enhancement Request" -about: "Suggest an enhancement for this project. Improve an existing feature" -title: "" -labels: "Type: Enhancement" -assignees: "" - ---- - -# **πŸš€ Enhancement Request** - -## **Is your enhancement request related to a problem? Please describe.** - - -* - ---- - -## **Describe the solution you'd like** - - -* - ---- - -## **Describe alternatives you've considered** - - -* - ---- - -### **Additional context** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/6-security-report.md b/.github/ISSUE_TEMPLATE/6-security-report.md deleted file mode 100644 index 213f64c6..00000000 --- a/.github/ISSUE_TEMPLATE/6-security-report.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -name: "⚠️ Security Report" -about: "Report an issue to help the project improve." -title: "" -labels: "security" -assignees: "" - ---- - - - -# **⚠️ Security Report** - -## **Describe the security issue** - - -* - ---- - -### **To Reproduce** - - - - - -1. -2. -3. -4. - ---- - -### **Expected behaviour** - - -* - ---- - -### **Media prove** - - ---- - -### **Your environment** - - - -* OS: -* Browser name and version: -* Honcho Server Version: -* Honcho Client Version: - ---- - -### **Additional context** - - -* diff --git a/.github/ISSUE_TEMPLATE/7-question-support.md b/.github/ISSUE_TEMPLATE/7-question-support.md deleted file mode 100644 index 894359f4..00000000 --- a/.github/ISSUE_TEMPLATE/7-question-support.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: "❓ Question or Support Request" -about: "Questions and requests for support." -title: "" -labels: "question" -assignees: "" - ---- - -# **❓ Question or Support Request** - -## **Describe your question or ask for support.** - - -* - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..237b4532 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/plastic-labs/honcho/security/advisories/new + about: Private vulnerability reporting only β€” do not file public security issues. + - name: Question or support + url: https://discord.gg/honcho + about: Ask the community and maintainers on Discord. + - name: Documentation + url: https://honcho.dev/docs + about: Guides, API reference, and self-hosting docs. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5463b6d0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Description + + + +## Proofs + + + +## Checklist + +- [ ] This PR is correlated to an existing issue, and I understand it will be closed if that issue does not have the `maintainer-approved` label. + + diff --git a/.github/scripts/issue-gate.js b/.github/scripts/issue-gate.js new file mode 100644 index 00000000..d0ff0d04 --- /dev/null +++ b/.github/scripts/issue-gate.js @@ -0,0 +1,286 @@ +'use strict'; + +/** + * Issue gate β€” shared logic for `.github/workflows/issue-gate.yml` (immediate + * feedback on pull request events) and `.github/workflows/pr-sweeper.yml` + * (deferred re-check, close, and stale-draft cleanup). + * + * Both workflows `require` this file through actions/github-script, so it must + * stay dependency-free: neither job runs an install step. + * + * See CONTRIBUTING.md for the policy this enforces. + */ + +const REQUIRED_LABEL = 'maintainer-approved'; +const GATE_LABEL = 'needs-approved-issue'; +const EXEMPT_LABEL = 'gate-exempt'; +const MARKER = ''; +const DISCORD = 'http://discord.gg/honcho'; + +// Hours a labelled pull request has before the sweeper closes it. Measured from +// the notice comment, so the clock starts when the author was actually told β€” +// not when the pull request was opened. +const GRACE_HOURS = 72; + +// Days without activity before a draft from outside the org is closed. +const DRAFT_STALE_DAYS = 30; + +const hasLabel = (pr, name) => (pr.labels || []).some((l) => l.name === name); + +const isBot = (account) => Boolean(account) && account.type === 'Bot'; + +/** + * Why this pull request is exempt from the gate, or null if it is not. + * + * Single source of truth: every caller that acts on a pull request runs this. + */ +const exemptReason = async ({ github, owner, repo, pr }) => { + if (isBot(pr.user)) return 'author is a bot'; + if (hasLabel(pr, EXEMPT_LABEL)) return `carries the ${EXEMPT_LABEL} label`; + + const username = pr.user && pr.user.login; + if (!username) return null; + + const permission = await repoPermission({ github, owner, repo, username }); + if (WRITE_PERMISSIONS.includes(permission)) { + return `author has ${permission} permission`; + } + return null; +}; + +// Repo roles that skip the gate. `read` / `triage` do not. +const WRITE_PERMISSIONS = ['admin', 'maintain', 'write']; + +/** Highest repo permission for `username`, or null if they are not a collaborator. */ +async function repoPermission({ github, owner, repo, username }) { + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username, + }); + return data.permission; + } catch (err) { + if (err && err.status === 404) return null; + throw err; + } +} + +const CLOSING_ISSUES = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { + number + state + labels(first: 50) { nodes { name } } + } + } + } + } + } +`; + +/** + * Decide whether a pull request clears the gate. + * + * Reads GitHub's own resolved issue links rather than parsing the body, so both + * `Fixes #123` and the sidebar "Development" link count. A bare `#123` mention + * deliberately does not β€” that is a reference, not a claim to close. + * + * @returns {Promise<{passed: boolean, skipped?: string, issue?: number, reason?: string}>} + */ +async function checkGate({ github, owner, repo, pr }) { + if (pr.state !== 'open') return { passed: true, skipped: 'pull request is not open' }; + if (pr.draft) return { passed: true, skipped: 'pull request is a draft' }; + const exempt = await exemptReason({ github, owner, repo, pr }); + if (exempt) return { passed: true, skipped: exempt }; + + const data = await github.graphql(CLOSING_ISSUES, { owner, repo, number: pr.number }); + const issues = data.repository.pullRequest.closingIssuesReferences.nodes; + + if (issues.length === 0) { + return { passed: false, reason: 'This pull request is not linked to an issue.' }; + } + + const approved = issues.find( + (i) => i.state === 'OPEN' && i.labels.nodes.some((l) => l.name === REQUIRED_LABEL), + ); + if (approved) return { passed: true, issue: approved.number }; + + const detail = issues + .map((i) => `#${i.number} (${i.state === 'CLOSED' ? 'closed' : 'not approved'})`) + .join(', '); + return { + passed: false, + reason: + `The linked ${issues.length === 1 ? 'issue is' : 'issues are'} not open with the ` + + `\`${REQUIRED_LABEL}\` label: ${detail}.`, + }; +} + +function noticeBody({ owner, repo, reason }) { + return [ + MARKER, + 'Thanks for the contribution. This pull request does not clear our issue gate yet.', + '', + `**${reason}**`, + '', + `Every pull request to Honcho needs to be linked to an open issue carrying the \`${REQUIRED_LABEL}\` label. We do this so the review queue only holds work we have already agreed should be built β€” it means nobody spends time on a change we cannot merge.`, + '', + 'To get this moving:', + '', + `1. Find or open an issue describing the change. [Approved issues are here](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3A${REQUIRED_LABEL}).`, + `2. Make the case for it in [Discord](${DISCORD}) β€” maintainers are most active there, and it is by far the fastest route to a decision.`, + `3. Once the issue has the label, link it: put \`Fixes #\` in this pull request's description, or use **Development** in the sidebar.`, + '', + `**This will close automatically in ${GRACE_HOURS} hours if it is still unlinked.** Nothing is lost if that happens β€” link the issue, reopen, and it goes into the review queue.`, + '', + `See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md) for the full process. If you think this is wrong, say so here and a maintainer will take a look.`, + ].join('\n'); +} + +/** + * Every gate notice this bot posted on a pull request, oldest first. + * + * Authorship is part of the test, not decoration. MARKER is an invisible HTML + * comment, so anyone who can comment on a public repository can paste it. If + * user comments counted, a third party could post one on someone else's pull + * request: `runGate` posts a notice only when none exists, so the author would + * never be told, and `runSweep` would then measure the grace window from the + * stranger's timestamp and close them unwarned. + */ +async function findNotices({ github, owner, repo, number }) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: number, per_page: 100, + }); + return comments.filter((c) => isBot(c.user) && (c.body || '').includes(MARKER)); +} + +/** + * Drop the gate label and delete the notice. + * + * Deleting matters: `runGate` posts a notice only when none exists, and the + * sweeper measures grace from the notice timestamp. A notice left behind after + * the gate clears would make a later re-block look weeks old and be closed with + * no warning. + */ +async function clearGate({ github, owner, repo, pr }) { + if (hasLabel(pr, GATE_LABEL)) { + await github.rest.issues + .removeLabel({ owner, repo, issue_number: pr.number, name: GATE_LABEL }) + .catch(() => {}); + } + for (const notice of await findNotices({ github, owner, repo, number: pr.number })) { + await github.rest.issues + .deleteComment({ owner, repo, comment_id: notice.id }) + .catch(() => {}); + } +} + +/** + * Entry point for `.github/workflows/issue-gate.yml`. + * Labels and explains. Never closes β€” that is the sweeper's job. + */ +async function runGate({ github, core, context }) { + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const result = await checkGate({ github, owner, repo, pr }); + + if (result.passed) { + core.info( + result.skipped ? `Skipping gate: ${result.skipped}` : `Gate passed via #${result.issue}`, + ); + await clearGate({ github, owner, repo, pr }); + return; + } + + core.warning(`Gate failed: ${result.reason}`); + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels: [GATE_LABEL], + }); + + const notices = await findNotices({ github, owner, repo, number: pr.number }); + if (notices.length > 0) return; + + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: noticeBody({ owner, repo, reason: result.reason }), + }); +} + +/** Entry point for `.github/workflows/pr-sweeper.yml`. */ +async function runSweep({ github, core, context, dryRun }) { + const { owner, repo } = context.repo; + + const act = async (what, fn) => { + core.info(dryRun ? `[dry run] ${what}` : what); + if (!dryRun) await fn(); + }; + + const close = (pr, body) => async () => { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); + }; + + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + core.info(`${prs.length} open pull requests${dryRun ? ' (dry run)' : ''}`); + + // Re-check everything wearing the gate label. Never close blind: a pull request + // linked through the sidebar fires no webhook, so the gate workflow cannot have + // noticed it β€” this pass is the only thing that will. + for (const pr of prs.filter((p) => hasLabel(p, GATE_LABEL))) { + const result = await checkGate({ github, owner, repo, pr }); + + if (result.passed) { + const why = result.skipped || `via #${result.issue}`; + await act(`#${pr.number}: gate now clear (${why})`, async () => { + await clearGate({ github, owner, repo, pr }); + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: 'The issue link is in place β€” this pull request has cleared the gate and is waiting on review.', + }); + }); + continue; + } + + const [notice] = await findNotices({ github, owner, repo, number: pr.number }); + if (!notice) { + core.info(`#${pr.number}: labelled but never notified β€” leaving it for the gate workflow`); + continue; + } + + const hours = (Date.now() - Date.parse(notice.created_at)) / 3_600_000; + if (hours < GRACE_HOURS) { + core.info(`#${pr.number}: ${Math.round(GRACE_HOURS - hours)}h of grace left`); + continue; + } + + await act(`#${pr.number}: closing β€” notified ${Math.round(hours)}h ago, still failing`, close(pr, + `Closing this: ${GRACE_HOURS} hours have passed and the gate is still not clear. This is not a judgement on the code. Link an approved issue and reopen β€” it goes straight into the review queue.`, + )); + } + + // Stale drafts. The gate skips drafts entirely, so they never carry the label; + // this pass keys off inactivity and applies the shared exemptions itself. + for (const pr of prs.filter((p) => p.draft)) { + const exempt = await exemptReason({ github, owner, repo, pr }); + if (exempt) { + core.info(`#${pr.number}: leaving stale draft alone β€” ${exempt}`); + continue; + } + + const days = (Date.now() - Date.parse(pr.updated_at)) / 86_400_000; + if (days < DRAFT_STALE_DAYS) continue; + + await act(`#${pr.number}: closing stale draft β€” ${Math.round(days)}d without activity`, close(pr, + `Closing this draft after ${DRAFT_STALE_DAYS} days without activity, to keep the pull request list readable. Reopen whenever you pick it back up β€” nothing here is lost.`, + )); + } +} + +module.exports = { + checkGate, runGate, runSweep, noticeBody, findNotices, exemptReason, + REQUIRED_LABEL, GATE_LABEL, EXEMPT_LABEL, MARKER, GRACE_HOURS, DRAFT_STALE_DAYS, +}; diff --git a/.github/scripts/issue-gate.test.js b/.github/scripts/issue-gate.test.js new file mode 100644 index 00000000..29989cdc --- /dev/null +++ b/.github/scripts/issue-gate.test.js @@ -0,0 +1,161 @@ +'use strict'; + +// Self-check for the gate decision logic. No framework, no install: +// node .github/scripts/issue-gate.test.js +// Covers checkGate() only β€” the side-effecting halves (runGate/runSweep) are +// exercised against the real API via `pr-sweeper.yml`'s dry_run dispatch. + +const assert = require('node:assert'); +const { + checkGate, findNotices, runSweep, REQUIRED_LABEL, EXEMPT_LABEL, MARKER, +} = require('./issue-gate.js'); + +const pull = (over = {}) => ({ + number: 1, state: 'open', draft: false, + user: { type: 'User', login: 'alice' }, labels: [], + ...over, +}); + +const notCollaborator = () => { + const err = new Error('Not Found'); + err.status = 404; + throw err; +}; + +// `linked` is the list of issues GitHub resolves as closing references. +const stub = (linked, permission) => ({ + graphql: async () => ({ + repository: { pullRequest: { closingIssuesReferences: { + nodes: linked.map((i) => ({ + number: i.number, state: i.state || 'OPEN', + labels: { nodes: (i.labels || []).map((name) => ({ name })) }, + })), + } } }, + }), + rest: { + repos: { + getCollaboratorPermissionLevel: async () => { + if (!permission) return notCollaborator(); + return { data: { permission } }; + }, + }, + }, +}); + +const run = (linked, over, permission) => + checkGate({ github: stub(linked, permission), owner: 'o', repo: 'r', pr: pull(over) }); + +const cases = [ + ['no linked issue fails', () => run([]), (r) => r.passed === false], + ['linked but unapproved fails', () => run([{ number: 7 }]), (r) => r.passed === false], + ['linked and approved passes', + () => run([{ number: 7, labels: [REQUIRED_LABEL] }]), + (r) => r.passed === true && r.issue === 7], + ['approved but closed fails', + () => run([{ number: 7, state: 'CLOSED', labels: [REQUIRED_LABEL] }]), + (r) => r.passed === false], + ['picks the approved one out of several', + () => run([{ number: 7 }, { number: 8, labels: [REQUIRED_LABEL] }]), + (r) => r.passed === true && r.issue === 8], + + // Exemptions. + ['write permission skips', () => run([], {}, 'write'), (r) => r.passed === true], + ['maintain permission skips', () => run([], {}, 'maintain'), (r) => r.passed === true], + ['bot skips', () => run([], { user: { type: 'Bot' } }), (r) => r.passed === true], + ['draft skips', () => run([], { draft: true }), (r) => r.passed === true], + [`${EXEMPT_LABEL} skips`, () => run([], { labels: [{ name: EXEMPT_LABEL }] }), (r) => r.passed === true], + + ['triage permission is still gated', () => run([], {}, 'triage'), (r) => r.passed === false], + ['MEMBER association without write is still gated', + () => run([], { author_association: 'MEMBER' }), + (r) => r.passed === false], + ['CONTRIBUTOR with write skips', + () => run([], { author_association: 'CONTRIBUTOR' }, 'write'), + (r) => r.passed === true], +]; + +// --- findNotices: only the bot's own notices count ------------------------- +// A stranger pasting the invisible MARKER into a comment must not suppress the +// notice or become the grace-window clock. +const commentsStub = (comments) => ({ + paginate: async () => comments, + rest: { issues: { listComments: null } }, +}); + +const noticeCases = [ + ['a user comment carrying MARKER is not a notice', + [{ id: 1, user: { type: 'User' }, body: `sneaky ${MARKER}`, created_at: 'x' }], 0], + ['a bot comment carrying MARKER is a notice', + [{ id: 2, user: { type: 'Bot' }, body: `${MARKER}\nnotice`, created_at: 'x' }], 1], + ['a bot comment without MARKER is not a notice', + [{ id: 3, user: { type: 'Bot' }, body: 'unrelated', created_at: 'x' }], 0], + ['a user MARKER does not mask the real bot notice', + [{ id: 4, user: { type: 'User' }, body: MARKER, created_at: 'x' }, + { id: 5, user: { type: 'Bot' }, body: MARKER, created_at: 'y' }], 1], +]; + +// --- runSweep: the stale-draft pass must honour every exemption ------------ +const draft = (over) => ({ + number: 9, draft: true, state: 'open', labels: [], + user: { type: 'User', login: 'alice' }, + updated_at: new Date(Date.now() - 400 * 86400_000).toISOString(), + ...over, +}); + +async function sweepClosed(pr, permission) { + const closed = []; + const github = { + paginate: async (route) => (route === 'pulls' ? [pr] : []), + rest: { + pulls: { + list: 'pulls', + update: async ({ pull_number }) => closed.push(pull_number), + }, + issues: { listComments: 'comments', createComment: async () => {} }, + repos: { + getCollaboratorPermissionLevel: async () => { + if (!permission) return notCollaborator(); + return { data: { permission } }; + }, + }, + }, + }; + await runSweep({ + github, core: { info() {}, warning() {} }, + context: { repo: { owner: 'o', repo: 'r' } }, dryRun: false, + }); + return closed; +} + +const sweepCases = [ + ['stale draft from an outside author closes', draft({}), 1], + ['stale draft from a bot is left alone', draft({ user: { type: 'Bot' } }), 0], + ['stale draft from a writer is left alone', draft({}), 0, 'write'], + [`stale draft with ${EXEMPT_LABEL} is left alone`, draft({ labels: [{ name: EXEMPT_LABEL }] }), 0], + ['recent draft is left alone', draft({ updated_at: new Date().toISOString() }), 0], +]; + +(async () => { + let failed = 0; + for (const [name, comments, want] of noticeCases) { + const got = (await findNotices({ github: commentsStub(comments), owner: 'o', repo: 'r', number: 1 })).length; + if (got === want) console.log(` ok ${name}`); + else { failed++; console.log(` FAIL ${name} -> ${got} notices, wanted ${want}`); } + } + for (const [name, pr, want, permission] of sweepCases) { + const got = (await sweepClosed(pr, permission)).length; + if (got === want) console.log(` ok ${name}`); + else { failed++; console.log(` FAIL ${name} -> closed ${got}, wanted ${want}`); } + } + for (const [name, thunk, ok] of cases) { + const result = await thunk(); + if (ok(result)) { + console.log(` ok ${name}`); + } else { + failed++; + console.log(` FAIL ${name} -> ${JSON.stringify(result)}`); + } + } + assert.strictEqual(failed, 0, `${failed} case(s) failed`); + console.log(`\n${cases.length + noticeCases.length + sweepCases.length} passed`); +})(); diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml new file mode 100644 index 00000000..74df40c7 --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,37 @@ +name: Issue Gate + +# Labels pull requests that are not linked to an issue carrying the +# `maintainer-approved` label, and comments explaining how to fix it. +# +# This workflow never closes anything. `pr-sweeper.yml` re-checks later and closes +# only after the grace period β€” that gives contributors time to link an issue, and +# gives maintainers time to wave through a one-line fix. It is also the only thing +# that can notice a sidebar issue link, which fires no webhook of its own. +# +# `pull_request_target` is required so the job has write access on pull requests +# from forks. It must therefore NEVER run code from the pull request. The checkout +# below is safe because on `pull_request_target` actions/checkout defaults to the +# BASE ref, which is repo-trusted code. Never point it at `pr.head.sha`. +# +# Not triggered on `synchronize`: re-running on every push would be noise. +# Drafts are ignored until marked ready. + +on: + pull_request_target: + types: [opened, edited, reopened, ready_for_review] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + with: + script: | + const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`); + await gate.runGate({ github, core, context }); diff --git a/.github/workflows/live-llm-tests.yml b/.github/workflows/live-llm-tests.yml index ee1b5b2b..dec8e3b4 100644 --- a/.github/workflows/live-llm-tests.yml +++ b/.github/workflows/live-llm-tests.yml @@ -12,6 +12,7 @@ on: - 'tests/live_llm/**' - 'pyproject.toml' - 'uv.lock' + - '.python-version' - '.github/workflows/live-llm-tests.yml' # Manual trigger for PRs: add the `run-live-llm` label to run the suite # against the PR's merge commit. The label is purged as soon as the run @@ -111,7 +112,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version-file: "pyproject.toml" + python-version-file: ".python-version" - name: Install the project run: uv sync --all-extras diff --git a/.github/workflows/pr-sweeper.yml b/.github/workflows/pr-sweeper.yml new file mode 100644 index 00000000..d5bde42c --- /dev/null +++ b/.github/workflows/pr-sweeper.yml @@ -0,0 +1,41 @@ +name: PR Sweeper + +# Deferred half of the issue gate. Every six hours: +# +# 1. Re-check every pull request carrying `needs-approved-issue`. Clear the ones +# that now link an approved issue; close the ones still failing 72h after they +# were told. The re-check is the point β€” linking an issue through the sidebar +# fires no webhook, so `issue-gate.yml` never sees it. +# 2. Close drafts from outside the org after 30 days without activity. +# +# Runs on `schedule`, so it never touches pull request code and needs none of the +# `pull_request_target` precautions. Dispatch manually with dry_run to see what it +# would do before it does it. + +on: + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Log intended actions without closing anything' + type: boolean + default: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + sweep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} + with: + script: | + const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`); + await gate.runSweep({ github, core, context, dryRun: process.env.DRY_RUN === 'true' }); diff --git a/.github/workflows/staticanalysis.yml b/.github/workflows/staticanalysis.yml index 18bb37fd..c41bf0f9 100644 --- a/.github/workflows/staticanalysis.yml +++ b/.github/workflows/staticanalysis.yml @@ -16,7 +16,7 @@ jobs: - name: "Set up Python" uses: actions/setup-python@v5 with: - python-version-file: "pyproject.toml" + python-version-file: ".python-version" - name: Install uv uses: astral-sh/setup-uv@v2 with: @@ -26,3 +26,11 @@ jobs: run: uv sync --all-extras --dev - name: run basedpyright run: uv run basedpyright + + issue-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The gate runs from `pull_request_target`, where a crash is invisible until + # a contributor's PR is silently ungated. Check it here instead. + - run: node .github/scripts/issue-gate.test.js diff --git a/.github/workflows/unified-tests.yml b/.github/workflows/unified-tests.yml index 4813a126..1f1f9d87 100644 --- a/.github/workflows/unified-tests.yml +++ b/.github/workflows/unified-tests.yml @@ -57,7 +57,10 @@ jobs: name: Run Unified Tests runs-on: ${{ fromJSON(format('[{0}]', needs.start-runner.outputs.runner-labels)) }} needs: start-runner - if: needs.start-runner.outputs.runner-ready == 'true' + # !cancelled() so this doesn't inherit gate's skip on push events. + if: >- + !cancelled() && + needs.start-runner.outputs.runner-ready == 'true' timeout-minutes: 90 environment: unified-tests permissions: @@ -148,8 +151,8 @@ jobs: - name: Verify uv and Python run: | uv --version - python3.12 --version - which python3.12 + python3.13 --version + which python3.13 - name: Install the project run: uv sync --all-extras diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 00dfba9f..e29842eb 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -11,6 +11,7 @@ on: - '**.jsx' - 'pyproject.toml' - 'uv.lock' + - '.python-version' - 'sdks/typescript/package.json' - 'sdks/typescript/bun.lock' - '.github/workflows/unittest.yml' @@ -24,6 +25,7 @@ on: - '**.jsx' - 'pyproject.toml' - 'uv.lock' + - '.python-version' - 'sdks/typescript/package.json' - 'sdks/typescript/bun.lock' - '.github/workflows/unittest.yml' @@ -48,6 +50,7 @@ jobs: - '**.py' - 'pyproject.toml' - 'uv.lock' + - '.python-version' - 'migrations/**' - 'sdks/typescript/**' - '.github/workflows/unittest.yml' @@ -85,7 +88,7 @@ jobs: - name: "Set up Python" uses: actions/setup-python@v5 with: - python-version-file: "pyproject.toml" + python-version-file: ".python-version" - name: Install bun uses: oven-sh/setup-bun@v2 diff --git a/.gitignore b/.gitignore index 9fa90b3e..d7ad4cda 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ api/docker-compose.yml *.db data redis-data -docker-compose.yml -compose.yml +/docker-compose.yml +/compose.yml diff --git a/.python-version b/.python-version index 2c073331..24ee5b1b 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11 +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md index e314f867..3bef42b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [3.1.0] - 2026-08-25 + +### Added + +- Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create β€” never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884) +- `scope` read option on chat, representation, session context, and workspace search. A single scope swaps the observer to the backing scope peer so conclusion recall, peer cards, and message tools stay inside that scope's membership. A list of scopes takes the union of member sessions (capped at `MAX_SESSION_ALLOWLIST_ENTRIES`) and executes via the session-allowlist path. Empty scopes fail closed. `scope` is mutually exclusive with `filters` and `session_id`. Workspace- or admin-level key required (403 otherwise). Scope peers are also rejected as `peer_target` / `peer_perspective` on session context and as the path peer or `target` on `GET /peers/{id}/context` (#897) +- Scope backfill-by-copy and removal reconciliation. Adding a session that already has messages copies its explicit-level documents into the scope's collections (no LLM re-derivation; idempotent via `copied_from`). Removing a session soft-deletes those copies and fail-closed cascades to derived documents whose `source_ids` intersect anything removed, then enqueues a `card_refresh` dream with `rebuild=True` plus an omni dream. `GET /v3/workspaces/{workspace_id}/scopes/{scope_id}/status` reports per-session backfill state (`pending` / `completed` / `failed`, plus `docs_copied`) (#904) +- Workspace-level chat at `POST /v3/workspaces/{workspace_id}/chat`: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Prefetches workspace stats and the top active peers' self cards, then searches pair-scoped memory with `[observer->observed]` attribution. Supports `session_id`, `scope`, `reasoning_level`, `response_format`, and SSE streaming (#931) +- MCP workspace discovery: tools accept `workspace_id`, the worker honors an optional `X-Honcho-Workspace-ID` connection header, and `list_workspace` / `create_workspace` tools let clients pick or create a workspace instead of relying on the SDK default (#1020) +- MCP `search` also queries conclusions in parallel with messages when `peer_id` is given, returning `{messages, conclusions}`. The conclusions leg degrades to `[]` on error so search never gets worse than before (#974) +- Prometheus metrics for physical DB connections, visible even under `DB_POOL_CLASS=null`: `db_connections_open` (gauge) and `db_connections_established` (counter), hooked to SQLAlchemy connection-lifecycle events and registered on both the API and the deriver (#1055) +- Bounded-label Prometheus series are zero-initialized at process start so an absent series means a broken scrape rather than "nothing happened" (#927) + +### Changed + +- Workspace and pair chat system prompts now describe Honcho, peers, and the harness on their own terms, and render only the tools the request actually offers. The pair prompt no longer advertises a write tool that is not in the loadout (#1066) +- Deriver idle polling backoff is longer and no longer reset by periodic reconciler work, so downstream connection pools can cull idle DB connections (#1015) +- LLM provider SDKs are lazy-loaded so idle API and deriver processes no longer pay for every provider at import time (#1011) +- Production image is a multi-stage build: LanceDB/PyArrow move behind an optional `lancedb` extra (`INSTALL_LANCEDB=true` to restore them), FastAPI's unused cloud CLI is dropped, and the venv is copied into the runtime image with final ownership so Docker does not double the layer. Default unpacked image is about 663 MB (was 1.7 GB) (#1014) +- Redis Cluster cache keys hash-tag the namespace so one deployment's keys land on a single shard instead of opening a connection to every node. No behaviour change on a non-cluster backend; existing keys age out by TTL (#1058) +- Deriver extraction prompt no longer leaks its own few-shot examples into extracted conclusions (#1028) + +### Fixed + +- Observer-scoped `get_observation_context` no longer materializes every session the observer has ever joined into a `session_name IN (...)` list (twice in one statement). Past ~32k sessions that hit psycopg's bind-parameter ceiling and 500'd. The observer half is now a correlated `EXISTS` over `session_peers`, two bind parameters regardless of membership size (#1065) +- Re-adding an already-active session peer no longer advances `joined_at`, so `peer_perspective` search keeps messages from the original join. Genuine leave-and-rejoin still starts a new window (#1059) +- Transient embedding-provider errors (for example an OpenAI-compatible 200 with empty `data: []`) were relabeled as token-limit errors. Only genuine oversize input raises `EmbeddingTokenLimitError`; other provider errors propagate unchanged (#791) +- The filter DSL now fails closed with a 422 instead of a 500 on bad shapes, coerces operands by column type (so `{"session_id": {"ne": "abc"}}` is a string inequality rather than "invalid numeric"), and treats `NOT` / `ne` as null-safe (`IS NOT TRUE` / `IS DISTINCT FROM`) so negation no longer drops rows whose field is unset. Closed-set columns like `level` reject unknown values. Session-allowlist entries must be well-formed ids (`*` is 422, not a silent widen) (#947) +- `ne` on JSONB metadata keys is null-safe: a missing key is not equal to the compared value, so `{"metadata": {"foo": {"ne": "bar"}}}` includes rows where `foo` is unset (#1036) +- Oversized texts in `simple_batch_embed` are truncated to the embedding token cap instead of failing the whole batch. Representation processing reports failed observer saves in `RepresentationCompletedEvent` and raises when every observer save fails (#1019) +- Assistant `reasoning_content` (DeepSeek / some OpenRouter models) is preserved across tool-loop turns. Previously the tool loop dropped thinking content before building the next assistant history message, so continuation requests failed. `reasoning_details` still takes precedence when both are present (#1034) +- `create_observations` now honors `DERIVER_DEDUPLICATE` instead of hardcoding `deduplicate=True`, matching the representation write path (#1018) +- `provider_params.timeout` is forwarded to the OpenAI-compatible embedding client, not just the LLM client (#1024) +- Conclusions semantic-search validation errors name the field and the constraint instead of returning a generic 422 (#960) +- OpenAI-compatible embedding calls request `encoding_format=float` so providers that default to base64 do not break pgvector inserts (#938) +- Gemini batch embedding works for `gemini-embedding-2*` models, which rejected the previous request shape (#745) +- MCP OAuth with no advertised scopes no longer defaults to read-only (which 403'd chat and search POSTs). Protected-resource metadata advertises read and write (#1004) + ## [3.0.12] - 2026-08-10 ### Added @@ -176,7 +214,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Vector store queries no longer fetch embedding vectors β€” only document metadata is returned, reducing payload size and DB load (pgvector, lancedb, turbopuffer) (#682) - Langfuse trace metadata now includes `namespace`, `model`, and `provider` so traces can be filtered by deployment slice (#565) - Deriver: model-aware tokenizer (replaces the previously hardcoded encoding) and explicit guard on empty message content (#647) -- Dialectic level defaults now merge correctly with per-level overrides in `src/config` (DEV-1733) (#656) +- Dialectic level defaults now merge correctly with per-level overrides in `src/config` (#656) - Default dialectic tool choice switched from forced/required to `auto` (#630) - Vector sync given a substantial retry budget to tolerate transient embedding provider outages (#604) - `AgentToolConclusionsDeletedEvent` payload now carries `levels` for parity with the rest of the conclusion event surface (#612) @@ -194,7 +232,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Removed strict parameter validation for thinking params on Anthropic and OpenAI transports β€” was rejecting valid per-transport configs (#686) - `reverse` query parameter is now honored on the v3 workspace list (`POST /v3/workspaces/list`), peer list (`POST /v3/workspaces/{workspace_id}/peers/list`), workspace-scoped session list (`POST /v3/workspaces/{workspace_id}/sessions/list`), and peer-scoped session list (`POST /v3/workspaces/{workspace_id}/peers/{peer_id}/sessions`). Honcho SDKs at 2.1.0+ were already sending `reverse=true` for these routes but the server silently ignored it. Ties on `created_at` now fall back to the internal nanoid `id` so ordering remains stable across pages (#685) - LLM client factories now receive `base_url` from `LLMSettings` for default providers β€” previously the override path honored `base_url` but the default path didn't, so operators pointing at OpenAI-compatible proxies via `LLM__OPENAI_BASE_URL` were ignored (#643, fixes #641) -- Internal N+1 query in dialectic agent tool execution (DEV-1721) β€” collapsed per-iteration DB lookups into a single fetch (#652) +- Internal N+1 query in dialectic agent tool execution β€” collapsed per-iteration DB lookups into a single fetch (#652) - Dreamer threshold and time-guard semantics: `check_and_schedule_dream` count filter now includes only `documents.level == 'explicit'` (dreamer-created levels are output, not input, and were inflating the threshold and creating a feedback loop); `last_dream_at` write relocated from `enqueue_dream` into `process_dream` so duplicate enqueues or failed runs no longer reset the 8-hour time guard (#573) - Deriver: blank observations are filtered out before embedding (previously triggered noisy embedding calls and persisted empty rows); blank-observation filtering unified across tool paths (#615) - Surprisal module: filter for level observations changed from `{"level": levels}` to `{"level": {"in": levels}}` β€” `apply_filter()` requires operator syntax, so the prior call silently returned 0 results and made the entire Surprisal phase of the Dream cycle a no-op (#581, fixes #559) diff --git a/CLAUDE.md b/CLAUDE.md index c9862b59..6a83066d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,9 @@ The Dreamer is an orchestrated multi-specialist system that runs during schedule - **LLM subsystem** (`src/llm/`): provider-agnostic `honcho_llm_call()`. Backends in `src/llm/backends/` (`anthropic.py`, `gemini.py`, `openai.py`). Includes prompt caching (`caching.py`), structured output (`structured_output.py`), tool loop (`tool_loop.py`), history adapters for cross-provider message formats, and a model registry. Per-retry provider selection is pinned via an `AttemptPlan` so stream-final retries don't bounce back to primary after the tool loop has settled on fallback. - **Per-agent model config**: each agent has its own `MODEL_CONFIG` in `src/config.py` with fallback chains (see `ConfiguredModelSettings`, `FallbackModelSettings`). - **Telemetry**: cloudevents in `src/telemetry/events/` cover API routes, dialectic, dream, deletion, reconciliation, representation, and per-call LLM accounting (`llm.py` β€” `LLMCallCompletedEvent` fires once per provider hit with full cost-attribution context). High-volume events are sampled deterministically per `run_id` via `TelemetrySettings.HIGH_VOLUME_SAMPLE_RATE`. +- **Prometheus metrics** (`src/telemetry/prometheus/`): every metric carries a `namespace` label and every recorder is fail-soft (a metrics error never propagates into a request or a worker loop). Counter children with a *bounded* label domain are zero-initialized per process at startup β€” `initialize_bounded_metrics(instance_type=...)`, called from the `src/main.py` lifespan (`api`) and `src/deriver/__main__.py` (`deriver`) β€” so an absent series means a broken scrape rather than "nothing happened". Two consequences worth knowing before touching telemetry: + - **Adding a `BaseEvent` subclass requires adding its `_event_type` to `ALL_EVENT_TYPES`** in `src/telemetry/events/__init__.py` (and to `HIGH_VOLUME_EVENT_TYPES` if `_volume_class == "high_volume"`). Enforced by the drift guards in `tests/telemetry/test_metric_zero_init.py`, which assert set-equality against the discovered subclasses. + - **A service-wide, non-additive gauge must be refreshed by every replica on its own timer**, and aggregated with `max()`/`avg()`, never `sum()`. `message_embeddings_pending` is the example: it reports a DB-global count, so it is driven from `ReconcilerScheduler._scheduler_loop` (runs on all replicas) rather than from the work-unit-deduped reconciliation cycle β€” otherwise, combined with the zero-init, every replica that never won the work unit would export a confident permanent `0`. ### Project Structure diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f0f44b5..b3e00a27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,215 +1,372 @@ # Contributing to Honcho -Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions. + -## Getting Started +Thanks for your interest in contributing. This guide covers how work gets accepted, how +Honcho is put together, and what a mergeable pull request looks like. -Before you start contributing, please: +Honcho is a small team maintaining a project that gets more proposals than we can review. +The rules below exist so that the work you do has somewhere to land β€” not to keep you out. -1. **Set up your development environment** - Follow the [Local Development guide](./README.md#local-development) in the README to get Honcho running locally. +## Contents -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. +- [Before you write code](#before-you-write-code) +- [What gets prioritized](#what-gets-prioritized) +- [If you're an agent](#if-youre-an-agent) +- [How Honcho works](#how-honcho-works) +- [Where to change what](#where-to-change-what) +- [Local setup](#local-setup) +- [Making the change](#making-the-change) +- [Opening the pull request](#opening-the-pull-request) +- [Reporting bugs and requesting features](#reporting-bugs-and-requesting-features) +- [Security](#security) +- [License](#license) -3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. +## Before you write code -## Contribution Workflow +**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.** -### 1. Fork and Clone +A pull request that is not linked to an approved issue gets labelled +`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one +before it is closed automatically. Reopening costs nothing once the link is in place. This +is automated. We do this because an unreviewable backlog helps nobody: a PR against an +unapproved issue is work you did that we may not be able to merge, no matter how good it +is. -1. Fork the repository on GitHub -2. Clone your fork locally: +So, in order: - ```bash - git clone https://github.com/YOUR_USERNAME/honcho.git - cd honcho - ``` +1. **Find approved work.** Browse + [issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved). + That label is the queue of things we have agreed should be built. Anything in it is fair + game β€” comment on the issue to claim it. -3. Add the upstream repository as a remote: +2. **Or open an issue and get it approved.** Use the + [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers + triage and apply the label. - ```bash - git remote add upstream https://github.com/plastic-labs/honcho.git - ``` +3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).** + This is the fastest path by a wide margin. Maintainers are more active there than in the + issue tracker, and a five-minute conversation about what you want to build usually + resolves whether it fits before either side spends real time on it. -### 2. Create a Branch +4. **Then open the PR** and link the issue β€” either `Fixes #123` in the description, or + **Development β†’ link an issue** in the sidebar. Both work. -Create a new branch for your feature or bug fix: +Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an +obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the +issue linkage. + +## What gets prioritized + +Roughly, work on Honcho falls along these axes. Knowing which one your idea sits on tells +you a lot about how likely it is to get approved. + +| Axis | What it covers | +| --- | --- | +| **Observability** | Understanding how Honcho behaves in production β€” telemetry, tracing, CloudEvents, metrics. | +| **Memory quality** | Better conclusions from the same input β€” the deriver, dreamer, and dialectic; eval results. | +| **Developer experience** | Fitting cleanly into more application architectures β€” SDKs, scopes, composable peers, the CLI. | +| **Breadth of input** | Widening what Honcho can ingest and represent β€” multimodal and non-conversational data. | +| **Ubiquity** | Reachable wherever a developer already works β€” integrations, self-hosting, alternate vector-store and inference backends, local-first defaults. | +| **Reliability and cost** | Trustworthy in production β€” connection and concurrency hardening, queue throughput, cost per token. | + +In practice, **Ubiquity** and **Developer experience** are where outside contributions land +most easily. A new integration, a self-hosting rough edge, a vector-store or inference +backend, an SDK ergonomics fix β€” these are additive and rarely collide with work already in +flight. + +Changes to the reasoning pipeline itself β€” deriver prompts, dialectic tool design, dreamer +strategy β€” are the hardest to accept from outside. Not because they are unwelcome, but +because they are measured against eval results we run internally, and they frequently +conflict with in-flight work. Talk to us in Discord first, always. + +## If you're an agent + +If you are a coding agent working on this repository, read this section before writing code. +The most common failure we see is a well-formed, well-tested pull request against an issue +that was never approved. That gets closed, and the work is wasted. + +- **Check the gate first.** Before writing code: + + ```bash + gh issue view --repo plastic-labs/honcho --json number,title,state,labels + ``` + + Stop if there is no issue number, if the issue is closed, or if `maintainer-approved` is + not in the labels. Report that to the person you are working with instead of proceeding. + +- **Do not open a PR in order to establish the issue link afterwards.** The issue comes + first. + +- **Do not report checks you did not run.** If you did not execute the test command, say so. + A PR body claiming a green run that did not happen costs a maintainer more time than no + claim at all. + +- **Use the checklist.** [`skills/pre-pr/SKILL.md`](./skills/pre-pr/SKILL.md) in this repo + encodes the gate, the test-layer matrix, and the PR body format. If your harness supports + skills, invoke it rather than reimplementing the checks. + +## How Honcho works + +Enough architecture to find your way around. For the user-facing model β€” what a Peer is, what +`get_context` returns β€” see [Core Concepts in the README](./README.md#core-concepts) and the +[documentation](https://honcho.dev/docs/). + +### Two processes + +Honcho runs as two cooperating processes over a shared Postgres database and Redis cache. + +| | API server | Deriver worker | +| --- | --- | --- | +| Start | `uv run fastapi dev src/main.py` | `uv run python -m src.deriver` | +| Entry | `src/main.py` | `src/deriver/__main__.py` | +| Does | Serves HTTP, enqueues background work, returns immediately | Consumes the queue: Deriver, Summarizer, Dreamer, Reconciler | +| Hosts | The Dialectic agent, inline on the request path | Everything else | + +The split is the load-bearing design decision: **an HTTP request never blocks on LLM work**, +with the single exception of the Dialectic chat endpoint, which is synchronous by nature. +If you are adding something slow, it belongs in the worker. + +The deriver is a separate process. If messages go in and nothing ever comes out, the usual +cause is that nobody started it. + +### The path of a message + +Worth tracing once, because it crosses most of the codebase: + +1. `POST /v3/workspaces/{w}/sessions/{s}/messages` lands in `src/routers/messages.py`. +2. The row is written, then `enqueue()` in `src/deriver/enqueue.py` creates `queue_item` + rows β€” one set of work per observing peer. +3. `src/deriver/queue_manager.py` polls the queue, claiming work units so that messages in a + session are processed in order. +4. `process_item()` in `src/deriver/consumer.py` dispatches on task type β€” representation, + summary, deletion, reconciliation. +5. For a representation task, `process_representation_tasks_batch()` in + `src/deriver/deriver.py` makes **one structured-output LLM call for the whole batch** and + writes the resulting conclusions into the collection keyed by the + `(observer, observed)` peer pair. +6. Later, `src/dialectic/` reads those conclusions back at recall time to answer a chat + request. + +Embedding is deliberately *not* on this path. `MessageEmbedding` rows are written with +`sync_state='pending'` and embedded asynchronously by the Reconciler +(`src/reconciler/sync_vectors.py`), which runs on a scheduler inside the deriver process. + +### The four agents + +They share tool definitions in `src/utils/agent_tools.py` and the provider-agnostic LLM +client in `src/llm/`. Each has its own `MODEL_CONFIG` with a fallback chain in +`src/config.py`. + +| Agent | Where | Shape | +| --- | --- | --- | +| **Deriver** | `src/deriver/` | A single structured-output call per message batch. Not a tool loop β€” this is a deliberate cost and latency tradeoff. | +| **Dialectic** | `src/dialectic/` | The one tool-using agent on the request path. Loops over tools until it can answer. Five reasoning tiers from `minimal` to `max`, each with its own model and tool set. | +| **Dreamer** | `src/dreamer/` | Off-queue consolidation. Two specialist phases (deduction, then induction) that build reasoning trees over existing conclusions. | +| **Summarizer** | `src/utils/summarizer.py` | Direct LLM call, no tools. Two tiers β€” short and long summaries at different message counts. | + +Prompts live in `src/deriver/prompts.py`, `src/dialectic/prompts.py`, and +`src/dreamer/specialists.py`. + +### A note on naming + +What the public API and documentation call **conclusions** are called **observations** +throughout the code β€” `create_observations`, `get_observation_context`, and so on. Likewise +**collections** and **documents** are internal storage concepts that are not exposed +directly through the API. Do not rename across that boundary in a drive-by change; the +public and internal vocabularies are being reconciled deliberately. + +## Where to change what + +| I want to change... | Start here | +| --- | --- | +| An HTTP endpoint | `src/routers/` β€” one module per resource | +| A database query | `src/crud/` β€” mirrors the router layout | +| The database schema | `src/models.py`, plus a migration in `migrations/versions/` | +| A configuration value | `src/config.py`, and add it to `config.toml.example` and `.env.template` | +| A tool an agent can call | `src/utils/agent_tools.py` β€” definitions plus the per-agent tool lists | +| A prompt | `src/deriver/prompts.py`, `src/dialectic/prompts.py`, `src/dreamer/specialists.py` | +| LLM provider behavior | `src/llm/backends/` β€” `anthropic.py`, `gemini.py`, `openai.py` | +| Embeddings or vector storage | `src/embedding_client.py`, `src/vector_store/` | +| Telemetry or metrics | `src/telemetry/` β€” see the notes in `CLAUDE.md` before adding an event type | +| Authentication and scoping | `src/security.py`, `src/dependencies.py` | +| The Python or TypeScript SDK | `sdks/python/`, `sdks/typescript/` | +| The CLI | `honcho-cli/` | +| The MCP server | `mcp/` | +| Public documentation | `docs/v3/` β€” Mintlify; nav lives in `docs/docs.json` | + +Tests in `tests/` mirror `src/`. `CLAUDE.md` at the repo root has more detail on house +conventions, and is worth skimming even if you are not using an agent. + +## Local setup + +To run a personal instance, install the CLI (`uv tool install honcho-cli`) and then run `honcho start --setup` (Docker + an LLM provider key β€” not the Honcho API key from `honcho init`) β€” [CLI in the README](./README.md#cli). + +To **develop this repo**, clone it and: + +```bash +uv sync # create the venv and install dependencies +uv run alembic upgrade head # apply migrations +``` + +Run both processes, in separate terminals: + +```bash +uv run fastapi dev src/main.py # API server, reloads on change +uv run python -m src.deriver # background worker +``` + +Everything Python goes through `uv run`. Redis is optional for local development; without it +caching is simply disabled. + +## Making the change + +### Branches and commits ```bash git checkout -b feature/your-feature-name -# or -git checkout -b fix/your-bug-fix-name ``` -**Branch naming conventions:** +Prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`. -- `feature/description` - for new features -- `fix/description` - for bug fixes -- `docs/description` - for documentation updates -- `refactor/description` - for code refactoring -- `test/description` - for adding or updating tests - -### 3. Make Your Changes - -- Write clean, readable code that follows our coding standards (see below) -- Add tests for new functionality -- Update documentation as needed -- Make sure your changes don't break existing functionality - -### 4. Commit Your Changes - -We follow conventional commit standards. Format your commit messages as: - -``` -type(scope): description - -[optional body] - -[optional footer] -``` - -**Types:** - -- `feat`: A new feature -- `fix`: A bug fix -- `docs`: Documentation only changes -- `style`: Changes that do not affect the meaning of the code -- `refactor`: A code change that neither fixes a bug nor adds a feature -- `test`: Adding missing tests or correcting existing tests -- `chore`: Changes to the build process or auxiliary tools - -**Examples:** +Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a +`commit-msg` hook: ```bash git commit -m "feat(api): add new dialectic endpoint for user insights" git commit -m "fix(db): resolve connection pool timeout issue" -git commit -m "docs(readme): update installation instructions" ``` -### 5. Submit a Pull Request +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. -1. Push your branch to your fork: +### Pre-commit hooks - ```bash - git push origin your-branch-name - ``` - -2. Create a pull request on GitHub from your branch to the `main` branch - -3. Fill out the pull request template with: - - A clear description of what changes you've made - - The motivation for the changes - - Any relevant issue numbers (use "Closes #123" to auto-close issues) - - Screenshots or examples if applicable - -## Pre-commit Hooks - -Honcho uses pre-commit hooks to enforce code quality and consistency. They run linting, formatting, type checking, and security scans before each commit. - -### Installation +Install them. CI runs the same checks, and it is much faster to find out locally. ```bash -uv add --dev pre-commit uv run pre-commit install \ --hook-type pre-commit \ --hook-type commit-msg \ --hook-type pre-push ``` -### What the hooks do +At **commit** time: ruff lint and format, biome for TypeScript, basedpyright, bandit, +markdownlint, and file hygiene. At **push** time: pytest, the alembic migration tests, and +the SDK builds. -- **Code Quality** β€” Python linting and formatting (ruff), TypeScript linting (biome) -- **Type Checking** β€” Static analysis with basedpyright -- **Security** β€” Vulnerability scanning with bandit -- **Documentation** β€” Markdown linting and license header checks -- **Testing** β€” Automated test runs for Python and TypeScript -- **File Hygiene** β€” Trailing whitespace, line endings, file size checks -- **Commit Standards** β€” Conventional commit message validation +That split matters β€” **a clean commit is not a clean push.** The test suite only runs at +`pre-push`, so the first time you see test failures may be well after you thought you were +done. -### Manual execution - -Run against all files without committing: +Run them by hand at any time: ```bash uv run pre-commit run --all-files +uv run pre-commit run ruff --all-files ``` -Run a specific hook: +Or the individual tools: ```bash -uv run pre-commit run ruff --all-files -uv run pre-commit run basedpyright --all-files +uv run ruff check src/ +uv run ruff format src/ +uv run basedpyright ``` -## Coding Standards +### Tests -### Python Code Style +Write tests for new functionality, in the directory under `tests/` that mirrors the code you +changed. Which layer you need depends on what you touched: -- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines -- Use [ruff](https://docs.astral.sh/ruff/) for linting and code formatting -- Use type hints where possible -- Write docstrings for functions and classes using Google style docstrings +| What you changed | What to run | +| --- | --- | +| Anything in `src/` | Unit tests in the matching `tests/` tree β€” `uv run pytest tests/...` | +| Deriver, dialectic, dreamer, or the LLM path | Unit tests, and consider `tests/live_llm/` (gated behind `--live-llm`) | +| Queue behavior, config hierarchy, multi-turn flows, SDK contracts | `uv run python -m tests.unified.run` | +| A `/v3` endpoint or deriver queue behavior | Actually run the stack and exercise it β€” not just pytest | +| A migration | `uv run python scripts/run_alembic_tests.py`; every revision needs a test file | -### Code Organization - -- Keep functions focused and single-purpose -- Use meaningful variable and function names -- Add comments for complex logic -- Follow existing patterns in the codebase - -### Testing - -- Write unit tests for new functionality -- Ensure existing tests pass before submitting -- Use descriptive test names that explain what is being tested -- Mock external dependencies appropriately +The TypeScript SDK tests need a running server with a database and Redis, which pytest +orchestrates. Run them with `uv run pytest tests/ -k typescript` from the repo root β€” +`bun test` on its own will fail. To type-check the SDK alone: +`cd sdks/typescript && bun run tsc --noEmit`. ### Documentation -- Update relevant documentation for new features -- Include examples in docstrings where helpful -- Keep README and other docs up to date with changes +Update docs in the same PR when you change a public surface: `/v3` endpoints, SDK exports, +or anything in `config.toml` / settings. Docs live in `docs/v3/`, and new pages need an entry +in `docs/docs.json` or they will not appear in the nav. -## Review Process +## Opening the pull request -1. **Automated checks** - Your PR will run through automated checks including tests and linting -2. **Project maintainer review** - A project maintainer will review your code for: - - Code quality and adherence to standards - - Functionality and correctness - - Test coverage - - Documentation completeness -3. **Discussion and iteration** - You may be asked to make changes or clarifications -4. **Approval and merge** - Once approved, your PR will be merged into `main` +### Leave "Allow edits by maintainers" checked -## Types of Contributions +This is the single most useful thing you can do to get your PR merged quickly. -We welcome various types of contributions: +Most contributor PRs arrive nearly right, needing a rename, a missing test, or a lint fix. +If we can push that commit ourselves, it merges the same day. If we cannot, it becomes a +review comment, and then we wait β€” sometimes for weeks β€” for a round trip on a two-line +change. -- **Bug fixes** - Help us squash bugs and improve stability -- **New features** - Add functionality that benefits the community -- **Documentation** - Improve or expand our documentation -- **Tests** - Increase test coverage and reliability -- **Performance improvements** - Help make Honcho faster and more efficient -- **Examples and tutorials** - Help other developers use Honcho +GitHub checks the box by default when you fork. Leave it checked. -## Issue Reporting +One caveat worth knowing: **the option does not exist on forks owned by an organization.** +If you have the choice, fork from your personal account. -When reporting bugs or requesting features: +### Fill out the template -1. Check if the issue already exists -2. Use the appropriate issue template -3. Provide clear reproduction steps for bugs -4. Include relevant environment information -5. Be specific about expected vs actual behavior +`.github/pull_request_template.md` asks for a description, proofs, and the issue checkbox. -## Questions and Support +"Proofs" means evidence the change works: the command you ran and its result, a log snippet, +a screenshot, the failing case before and after. This is the section that most determines +how fast your PR gets reviewed. Do not add sections to the template. -- **General questions** - Join our [Discord](http://discord.gg/honcho) -- **Bug reports** - Use GitHub issues -- **Feature requests** - Use GitHub issues with the feature request template -- **Security issues** - Please email us privately rather than opening a public issue +Link the issue so the gate can see it: `Fixes #123` in the description, or the +**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so +either route works β€” but a bare `#123` mention is only a reference and does not count. + +### Review + +1. Automated checks run β€” tests, linting, static analysis, and the issue gate. +2. A maintainer reviews for correctness, test coverage, and fit with the surrounding code. + `.github/CODEOWNERS` routes the request to whoever owns the area you touched. +3. You may be asked for changes. Or we may just push them, if you left edits enabled. +4. Once approved, we merge to `main`. + +If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho). + +## Reporting bugs and requesting features + +Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is +one per kind of report, and picking the right one is most of what gets an issue triaged +quickly: + +- **Bug report** β€” something is broken or behaves incorrectly +- **Memory / recall quality** β€” the deriver or dialectic returns poor, wrong, or missing context +- **Feature request** β€” a new capability or API surface +- **Integration request** β€” plugins, framework integrations, app-store listings +- **Documentation issue** β€” anything wrong or missing in the docs +- **General questions** β€” not an issue at all; ask in [Discord](https://discord.gg/honcho) + +Before opening one, search existing issues, including closed ones. + +A good bug report has the Honcho version or commit, whether you are self-hosted or on +`api.honcho.dev`, the steps to reproduce, and what you expected instead. If it involves the +deriver, logs from the worker process are usually the thing we ask for first. + +**Redact before you post.** Issues are public, and Honcho stores conversational data β€” strip +API keys, JWTs, and production user content out of any log or payload you attach. + +## Security + +Do not open a public issue for a suspected vulnerability. Report it privately through +[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new), +which is the preferred channel, or by email. See [SECURITY.md](./SECURITY.md) for what to +include, and note that Honcho does not operate a bug bounty. ## License -By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./LICENSE) that covers the project. +By contributing to Honcho, you agree that your contributions will be licensed under the same +[AGPL-3.0 License](./LICENSE) that covers the project. Thank you for helping make Honcho better! 🫑 diff --git a/Dockerfile b/Dockerfile index 8fd9be47..00ce1485 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,9 @@ COPY --chown=app:app migrations/ /app/migrations/ COPY --chown=app:app scripts/ /app/scripts/ COPY --chown=app:app docker/ /app/docker/ COPY --chown=app:app alembic.ini /app/alembic.ini +# src/_version.py reads the service version from here at runtime, so this +# is a runtime input as well as a build input. +COPY --chown=app:app pyproject.toml /app/pyproject.toml # Copy config files - this will copy config.toml if it exists, and config.toml.example COPY --chown=app:app config.toml* /app/ diff --git a/README.md b/README.md index bef96c61..2d354c66 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,15 @@ --- -![Static Badge](https://img.shields.io/badge/Server-3.0.9-blue) +![Static Badge](https://img.shields.io/badge/Server-3.1.0-blue) [![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/) [![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk) +[![CLI](https://img.shields.io/pypi/v/honcho-cli.svg?label=honcho-cli)](https://pypi.org/project/honcho-cli/) [![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/honcho) **Honcho is memory infrastructure for building stateful agents that understand changing people, agents, groups, projects, and ideas over time.** -Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev) or self-host the FastAPI server yourself. +Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev), run a local stack with [`honcho start`](#cli), or self-host the FastAPI server yourself. Using Honcho as your memory system will earn your agents higher retention, more trust, and help you build data moats to out-compete incumbents. @@ -29,6 +30,7 @@ Using Honcho as your memory system will earn your agents higher retention, more - [Quickstart](#quickstart) - [What Honcho Gives You](#what-honcho-gives-you) - [Integrations](#integrations) +- [CLI](#cli) - [Core Concepts](#core-concepts) - [Benchmarks & Evals](#benchmarks--evals) - [Self-hosting](#self-hosting) @@ -39,7 +41,7 @@ Using Honcho as your memory system will earn your agents higher retention, more - [Contributing](#contributing) - [License](#license) -The Honcho project is split between several repositories, with this one hosting the core service logic β€” implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory. +The Honcho project is split between several repositories, with this one hosting the core service logic β€” implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory. The [`honcho-cli`](./honcho-cli) package lives here too. ## Start Here @@ -47,7 +49,9 @@ The Honcho project is split between several repositories, with this one hosting | -------------------------------------- | ---------------------------------------------------------- | ----------------------------- | | Give my coding agent persistent memory | Claude Code, OpenCode, OpenClaw, Hermes, or any MCP client | [Integrations](#integrations) | | Add memory to my product | Python or TypeScript SDK | [Quickstart](#quickstart) | -| Self-host Honcho | Docker / local development | [Self-hosting](#self-hosting) | +| Run Honcho locally | Install CLI, then `honcho start --setup` | [CLI](#cli) | +| Inspect a deployment | `honcho workspace inspect`, `honcho doctor` | [CLI](#cli) | +| Self-host from source | Docker Compose or local development | [Self-hosting](#self-hosting) | ## Why Honcho @@ -56,7 +60,7 @@ The Honcho project is split between several repositories, with this one hosting | Reasoning-first memory | Extracts conclusions from conversations and events, not just matching chunks. | | Peer-centric model | Tracks users, agents, groups, projects, and ideas as entities that change over time. | | Multi-peer perspective | Models what one peer knows about another when configured. | -| Managed or self-hosted | Use `api.honcho.dev` or run the FastAPI server yourself. | +| Managed or self-hosted | Use `api.honcho.dev`, `honcho start` locally, or run the FastAPI server yourself. | | Agent-tool integrations | MCP, Claude Code, OpenCode, OpenClaw, Hermes, Cursor-compatible clients. | ## The Honcho Loop @@ -70,7 +74,7 @@ Concretely: workspaces hold peers, peers participate in sessions, messages live ## Quickstart -Get an API key at [app.honcho.dev](https://app.honcho.dev) β€” when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or [self-host](#self-hosting) and run against `http://localhost:8000`. +Get an API key at [app.honcho.dev](https://app.honcho.dev) β€” when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or install the CLI and run [`honcho start --setup`](#cli), then point the SDK at `http://localhost:8000`. ### Python @@ -226,12 +230,27 @@ For wiring the Honcho SDK into an existing application, install the integration npx skills add plastic-labs/honcho ``` -Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). The same command also installs the memory skills β€” `honcho-memory` (concepts: the recall/record loop, session and peer strategy, plus how to connect and drive an MCP-connected Honcho) and `honcho-cli` (inspecting and debugging a deployment). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding). +Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). The same command also installs the memory skills β€” `honcho-memory` (concepts: the recall/record loop, session and peer strategy, plus how to connect and drive an MCP-connected Honcho) and `honcho-cli` (inspecting a deployment, or running a local stack with `honcho start`). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding). ### Other MCP clients The same `claude mcp add` form (or its client-specific equivalent) works in any MCP-compatible client. See [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp). +## CLI + +[`honcho-cli`](https://pypi.org/project/honcho-cli/) inspects a Honcho deployment from the terminal, or runs a personal local stack with Docker. + +```bash +uv tool install honcho-cli +honcho init # Honcho API key or browser login + server URL +honcho start --setup basic # local stack: LLM provider key + Docker +honcho doctor +``` + +`honcho init` authenticates the CLI against a Honcho server. `honcho start --setup` is a separate step: it writes the LLM provider key the local deriver needs and starts API + deriver + Postgres + Redis. + +Full commands and local-stack details: [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) Β· [`honcho-cli/README.md`](./honcho-cli/README.md). To develop the server from source, see [Self-hosting](#self-hosting). + ## Core Concepts Honcho organises everything around **peers** β€” humans and AI agents alike are first-class entities. The peer model enables: @@ -246,6 +265,7 @@ Peers exchange messages within sessions; Honcho reasons over those messages to b - **Workspace** (formerly App): top-level container; isolates data between use cases. - **Peer** (formerly User): any participant β€” human user or AI agent. - **Session**: a conversation context; many-to-many with peers. +- **Scope**: a named grouping of sessions that bounds recall (chat, representation, search) to those members. - **Message**: an atomic data unit (peer-to-peer communication or ingested document chunk). What you query out of Honcho: @@ -274,9 +294,9 @@ Honcho's evals span LongMemEval, LoCoMo, and other long-conversation benchmarks. ## Self-hosting -Honcho is open source under AGPL-3.0. You can run the full server locally with Docker, then point the SDKs at `http://localhost:8000`. +Honcho is open source under AGPL-3.0. To **run** a personal instance, install the CLI (`uv tool install honcho-cli`) and then [`honcho start --setup`](#cli). The paths below are for building from source, contributing, or deploying without the CLI. -### Quick start (Docker) +### Quick start (from source, Docker) ```bash git clone https://github.com/plastic-labs/honcho.git @@ -458,79 +478,19 @@ Contributors: see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for pre-commit setup. D Honcho uses a flexible configuration system that supports both TOML files and environment variables. Configuration values are loaded in priority order: **environment variables > `.env` file > `config.toml` > defaults**. - -
-Full configuration reference - -### Using config.toml - -Copy the example configuration file to get started: +Copy the example file to get started: ```bash cp config.toml.example config.toml ``` -Then modify the values as needed. The TOML file is organized into sections: +The file is organized by subsystem β€” `[app]`, `[db]`, `[auth]`, `[cache]`, `[llm]`, `[deriver]`, `[dialectic]`, `[summary]`, `[dream]`, `[peer_card]`, `[webhook]`, `[metrics]`, `[telemetry]`, `[vector_store]`, and `[sentry]`. Any value can be overridden by an environment variable named `{SECTION}_{KEY}`, using `__` for nesting (`DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL`), or just `{KEY}` for app-level settings. -- `[app]` - Application-level settings (log level, session limits, embedding settings, namespace) -- `[db]` - Database connection and pool settings -- `[auth]` - Authentication configuration -- `[cache]` - Redis cache configuration -- `[llm]` - LLM provider API keys and general settings -- `[deriver]` - Background worker settings and representation configuration -- `[peer_card]` - Peer card generation settings -- `[dialectic]` - Chat Endpoint configuration with per-level reasoning settings -- `[summary]` - Session summarization settings -- `[dream]` - Dream processing configuration (including specialist models and surprisal settings) -- `[webhook]` - Webhook configuration -- `[metrics]` - Prometheus pull-based metrics -- `[telemetry]` - CloudEvents telemetry for analytics -- `[vector_store]` - Vector store configuration (pgvector, turbopuffer, or lancedb) -- `[sentry]` - Error tracking and monitoring settings - -### Using Environment Variables - -All configuration values can be overridden using environment variables. The environment variable names follow this pattern: - -- `{SECTION}_{KEY}` for top-level section settings -- Use `__` inside `{KEY}` for nested settings -- Just `{KEY}` for app-level settings - -Examples: - -- `DB_CONNECTION_URI` - Database connection string -- `AUTH_JWT_SECRET` - JWT secret key -- `DERIVER_MODEL_CONFIG__TRANSPORT` - Transport for the background deriver -- `SUMMARY_MODEL_CONFIG__MODEL` - Summary model override -- `DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL` - Model for low reasoning level -- `LOG_LEVEL` - Application log level -- `METRICS_ENABLED` - Enable Prometheus metrics -- `TELEMETRY_ENABLED` - Enable CloudEvents telemetry - -### Example - -If you have this in `config.toml`: - -```toml -[db] -CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev" -POOL_SIZE = 10 -``` - -You can override just the connection URI in production: - -```bash -export DB_CONNECTION_URI="postgresql+psycopg://prod-server/honcho_prod" -``` - -The application will use the production connection URI while keeping the pool size from config.toml. - -
- +See the [configuration reference](https://honcho.dev/docs/v3/contributing/configuration) for every available option, and [`.env.template`](./.env.template) for an annotated list of environment variables. ## Architecture -Honcho splits into two services: **Storage** (workspaces, peers, sessions, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process. +Honcho splits into two services: **Storage** (workspaces, peers, sessions, scopes, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process. **Key features:** @@ -558,16 +518,18 @@ Workspaces β”‚ β”œβ”€β”€ Sessions β”‚ β”‚ └── (internal collections, keyed by observer/observed peer pair) β”‚ β”‚ +β”œβ”€β”€ Scopes ←────────────────── (many-to-many with sessions) β”‚ β”‚ -└── Sessions ←──────────────── (many-to-many) +└── Sessions ←──────────────── (many-to-many with peers) β”œβ”€β”€ Peers β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ └── Messages (session-level) ``` **Relationship Details:** -- A **Workspace** contains multiple **Peers**. +- A **Workspace** contains multiple **Peers** and **Scopes**. - **Peers** and **Sessions** have a many-to-many relationship (peers can participate in multiple sessions, sessions can have multiple peers). +- **Scopes** and **Sessions** have a many-to-many relationship (a session can belong to several scopes; a scope groups many sessions). - **Messages** belong to a session and are labelled by their source peer. - **Internal collections** of vector-embedded **documents** are keyed by `(observer, observed)` peer pairs. They are not directly exposed via the API; the observations stored in them are exposed as **Conclusions**. @@ -591,6 +553,28 @@ This unified model enables complex multi-participant interactions. The `Session` object represents a set of interactions between `Peers` within a `Workspace`. Other applications may refer to this as a thread or conversation. Sessions can involve multiple peers with configurable observation settings. +A session can optionally join one or more **Scopes** at creation, or later via +the scopes API. + +#### Scopes + +A `Scope` is a named grouping of sessions inside a `Workspace`. It is a +visibility boundary on recall: chat, representation, session context, and +workspace search answered through a scope see only what happened in that +scope's member sessions. The underlying peers keep their unified +representations across everything they have participated in. + +Developers manage scopes through the scopes API (`honcho.scope(...)` / +`honcho.scopes()`) and an optional `scopes` field on session create β€” not +through observer/observed configuration. Adding a session that already has +messages copies its existing explicit conclusions into the scope (no +re-derivation); removing one reconciles those copies back out. Query +backfill progress with the scope `status` endpoint. + +A single scope name answers from that scope's collection and card. A list of +scopes restricts recall to the union of their member sessions. Empty scopes +fail closed. `scope` is mutually exclusive with `session` / `filters` on the +same read. #### Messages @@ -668,6 +652,7 @@ For low-latency use cases, Honcho provides access to a `representation` endpoint - **Python** β€” [`honcho-ai`](https://pypi.org/project/honcho-ai/) on PyPI Β· source in [`sdks/python/`](./sdks/python) - **TypeScript** β€” [`@honcho-ai/sdk`](https://www.npmjs.com/package/@honcho-ai/sdk) on npm Β· source in [`sdks/typescript/`](./sdks/typescript) +- **CLI** β€” [`honcho-cli`](https://pypi.org/project/honcho-cli/) on PyPI Β· source in [`honcho-cli/`](./honcho-cli) Β· [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) SDKs are versioned independently of the server. Current SDK versions track each other; the server badge above reflects the deployed server version. @@ -676,11 +661,14 @@ See the [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/sdk) ## Learn More - [Developer documentation](https://honcho.dev/docs/) β€” full API surface, guides, integrations. +- [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) β€” local stack, inspect/debug commands, scripting. - [Plastic Labs blog](https://blog.plasticlabs.ai/) β€” design philosophy and history of the project. ## Contributing -We welcome contributions to Honcho! Please read our [Contributing Guide](./CONTRIBUTING.md) for details on our development process, coding conventions, and how to submit pull requests. +We welcome contributions to Honcho. One thing to know before you start: **pull requests must be linked to an issue carrying the `maintainer-approved` label**, or they are closed automatically. [Browse the approved queue](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved), or make your case in [Discord](http://discord.gg/honcho) β€” that is where maintainers are most active. + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full process, an architecture walkthrough, and a map of where to change what. For vulnerabilities, see [SECURITY.md](./SECURITY.md) β€” note that Honcho does not operate a bug bounty. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..18d0ebfb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,73 @@ +# Security Policy + +## Supported Versions + +The `main` branch of this repo maps to the latest canary version of Honcho. To see which versions are supported please refer to the git tags in the repo or the [compatibility guide](https://honcho.dev/docs/changelog/compatibility-guide). + +## Reporting a Vulnerability + +Do not open a public issue for a suspected vulnerability. Report it privately through one of: + +1. **[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new)** β€” preferred; it keeps the report, our replies, and any fix coordinated in one place. +2. Email [support@honcho.dev](mailto:support@honcho.dev) with `[SECURITY]` in the subject. + +Include as much of the following as you have: + +- **Version** β€” a git commit SHA, or the release tag you are running +- **Deployment** β€” self-hosted or the managed service at `api.honcho.dev` +- **Affected component** β€” API, deriver, dialectic, auth/JWT, an SDK, or the managed offering +- **Reproduction** β€” the exact steps, requests, or script that trigger it +- **Proof of concept** β€” the smallest thing that demonstrates the issue actually works +- **Impact** β€” what an attacker gains, and what they need to already have to get it +- **How you found it** β€” manual review, fuzzing, a scanner, or model-assisted analysis + +Reports with a working proof of concept get looked at first. A report that only describes a +theoretical problem is much slower for us to act on, because we have to build the repro +ourselves before we can confirm anything. + +Honcho stores conversational data and peer representations. **Do not attach production user +content, API keys, or JWTs** to a report β€” if we need a sample, we will ask for a redacted +one. + +## Testing + +Test against an instance you operate. Do not run security testing against `api.honcho.dev` +or against any Honcho deployment that is not yours β€” self-hosting is a first-class path and +takes a few minutes to set up β€” install the CLI (`uv tool install honcho-cli`) then run `honcho start --setup` (Docker + an LLM provider key), or see [Self-hosting](./README.md#self-hosting). + +## What to Expect + +We will acknowledge your report and tell you whether we consider it in scope. If it is, we +will let you know when a fix ships. + +We do not commit to a response SLA, we do not coordinate CVE assignment on request, and we +do not operate a disclosure timeline you can hold us to. This is a small team. + +## Out of Scope + +The following are not treated as vulnerabilities. Reports consisting only of these will be +closed without a detailed response: + +- Automated scanner output with no working proof of concept +- Model-generated findings that have not been verified by a human against a running instance +- Missing security headers or TLS configuration with no demonstrated exploit +- Rate limiting, or resource exhaustion with no demonstrated impact beyond your own instance +- Vulnerabilities in dependencies with no demonstrated exploit path through Honcho +- Configuration weaknesses that require an already-compromised host, or that come from + deliberately insecure settings (for example running with `AUTH_USE_AUTH=false`, which is + the documented local-development default and is not intended for a public deployment) +- Social engineering, phishing, and physical access + +For ordinary bugs, memory or recall quality problems, and feature requests, use the +[issue templates](https://github.com/plastic-labs/honcho/issues/new/choose) instead. + +## No Bug Bounty + +The Honcho project does not offer any rewards for reported bugs or +vulnerabilities. We do not aid security researchers to get such rewards for +Honcho problems from other sources. + +A bug bounty gives people too strong incentives to find and make up "problems" +in bad faith that cause overload and abuse. + +We still appreciate and value valid vulnerability reports. diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index 42b2f373..abdd1361 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -10,14 +10,14 @@ This guide helps you match the right SDK version to your Honcho API version. New - **Latest:** v2.1.2 + **Latest:** v2.4.0 ```bash npm install @honcho-ai/sdk ``` - **Latest:** v2.1.2 + **Latest:** v2.4.0 ```bash pip install honcho-ai @@ -30,7 +30,9 @@ This guide helps you match the right SDK version to your Honcho API version. New | Honcho API Version | TypeScript SDK | Python SDK | |-------------------|---------------|------------| -| v3.0.11 (Current) | v2.1.2 | v2.1.2 | +| v3.1.0 (Current) | v2.4.0 | v2.4.0 | +| v3.0.12 | v2.3.0 | v2.3.0 | +| v3.0.11 | v2.1.2 | v2.1.2 | | v3.0.10 | v2.1.2 | v2.1.2 | | v3.0.9 | v2.1.2 | v2.1.2 | | v3.0.8 | v2.1.2 | v2.1.2 | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index 356c1eb5..a152d16a 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,45 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Added + + - Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create β€” never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884) + - `scope` read option on chat, representation, session context, and workspace search. A single scope swaps the observer to the backing scope peer so conclusion recall, peer cards, and message tools stay inside that scope's membership. A list of scopes takes the union of member sessions (capped at `MAX_SESSION_ALLOWLIST_ENTRIES`) and executes via the session-allowlist path. Empty scopes fail closed. `scope` is mutually exclusive with `filters` and `session_id`. Workspace- or admin-level key required (403 otherwise). Scope peers are also rejected as `peer_target` / `peer_perspective` on session context and as the path peer or `target` on `GET /peers/{id}/context` (#897) + - Scope backfill-by-copy and removal reconciliation. Adding a session that already has messages copies its explicit-level documents into the scope's collections (no LLM re-derivation; idempotent via `copied_from`). Removing a session soft-deletes those copies and fail-closed cascades to derived documents whose `source_ids` intersect anything removed, then enqueues a `card_refresh` dream with `rebuild=True` plus an omni dream. `GET /v3/workspaces/{workspace_id}/scopes/{scope_id}/status` reports per-session backfill state (`pending` / `completed` / `failed`, plus `docs_copied`) (#904) + - Workspace-level chat at `POST /v3/workspaces/{workspace_id}/chat`: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Prefetches workspace stats and the top active peers' self cards, then searches pair-scoped memory with `[observer->observed]` attribution. Supports `session_id`, `scope`, `reasoning_level`, `response_format`, and SSE streaming (#931) + - MCP workspace discovery: tools accept `workspace_id`, the worker honors an optional `X-Honcho-Workspace-ID` connection header, and `list_workspace` / `create_workspace` tools let clients pick or create a workspace instead of relying on the SDK default (#1020) + - MCP `search` also queries conclusions in parallel with messages when `peer_id` is given, returning `{messages, conclusions}`. The conclusions leg degrades to `[]` on error so search never gets worse than before (#974) + - Prometheus metrics for physical DB connections, visible even under `DB_POOL_CLASS=null`: `db_connections_open` (gauge) and `db_connections_established` (counter), hooked to SQLAlchemy connection-lifecycle events and registered on both the API and the deriver (#1055) + - Bounded-label Prometheus series are zero-initialized at process start so an absent series means a broken scrape rather than "nothing happened" (#927) + + ### Changed + + - Workspace and pair chat system prompts now describe Honcho, peers, and the harness on their own terms, and render only the tools the request actually offers. The pair prompt no longer advertises a write tool that is not in the loadout (#1066) + - Deriver idle polling backoff is longer and no longer reset by periodic reconciler work, so downstream connection pools can cull idle DB connections (#1015) + - LLM provider SDKs are lazy-loaded so idle API and deriver processes no longer pay for every provider at import time (#1011) + - Production image is a multi-stage build: LanceDB/PyArrow move behind an optional `lancedb` extra (`INSTALL_LANCEDB=true` to restore them), FastAPI's unused cloud CLI is dropped, and the venv is copied into the runtime image with final ownership so Docker does not double the layer. Default unpacked image is about 663 MB (was 1.7 GB) (#1014) + - Redis Cluster cache keys hash-tag the namespace so one deployment's keys land on a single shard instead of opening a connection to every node. No behaviour change on a non-cluster backend; existing keys age out by TTL (#1058) + - Deriver extraction prompt no longer leaks its own few-shot examples into extracted conclusions (#1028) + + ### Fixed + + - Observer-scoped `get_observation_context` no longer materializes every session the observer has ever joined into a `session_name IN (...)` list (twice in one statement). Past ~32k sessions that hit psycopg's bind-parameter ceiling and 500'd. The observer half is now a correlated `EXISTS` over `session_peers`, two bind parameters regardless of membership size (#1065) + - Re-adding an already-active session peer no longer advances `joined_at`, so `peer_perspective` search keeps messages from the original join. Genuine leave-and-rejoin still starts a new window (#1059) + - Transient embedding-provider errors (for example an OpenAI-compatible 200 with empty `data: []`) were relabeled as token-limit errors. Only genuine oversize input raises `EmbeddingTokenLimitError`; other provider errors propagate unchanged (#791) + - The filter DSL now fails closed with a 422 instead of a 500 on bad shapes, coerces operands by column type (so `{"session_id": {"ne": "abc"}}` is a string inequality rather than "invalid numeric"), and treats `NOT` / `ne` as null-safe (`IS NOT TRUE` / `IS DISTINCT FROM`) so negation no longer drops rows whose field is unset. Closed-set columns like `level` reject unknown values. Session-allowlist entries must be well-formed ids (`*` is 422, not a silent widen) (#947) + - `ne` on JSONB metadata keys is null-safe: a missing key is not equal to the compared value, so `{"metadata": {"foo": {"ne": "bar"}}}` includes rows where `foo` is unset (#1036) + - Oversized texts in `simple_batch_embed` are truncated to the embedding token cap instead of failing the whole batch. Representation processing reports failed observer saves in `RepresentationCompletedEvent` and raises when every observer save fails (#1019) + - Assistant `reasoning_content` (DeepSeek / some OpenRouter models) is preserved across tool-loop turns. Previously the tool loop dropped thinking content before building the next assistant history message, so continuation requests failed. `reasoning_details` still takes precedence when both are present (#1034) + - `create_observations` now honors `DERIVER_DEDUPLICATE` instead of hardcoding `deduplicate=True`, matching the representation write path (#1018) + - `provider_params.timeout` is forwarded to the OpenAI-compatible embedding client, not just the LLM client (#1024) + - Conclusions semantic-search validation errors name the field and the constraint instead of returning a generic 422 (#960) + - OpenAI-compatible embedding calls request `encoding_format=float` so providers that default to base64 do not break pgvector inserts (#938) + - Gemini batch embedding works for `gemini-embedding-2*` models, which rejected the previous request shape (#745) + - MCP OAuth with no advertised scopes no longer defaults to read-only (which 403'd chat and search POSTs). Protected-resource metadata advertises read and write (#1004) + + + ### Added - Session allowlist on the Dialectic and representation via a constrained `filters` body on `POST /peers/{peer_id}/chat` and `/representation`, supporting only the `session_id` key (a session id, a bare list, or `{"in": [...]}`). Unsupported keys and shapes are rejected with 422 rather than silently ignored, it composes with `session_id` (which must be included in the allowlist when both are given), and it is capped at 1,000 sessions per request. Enforcement is uniform and fail-closed at every recall chokepoint: scoped conclusion recall is restricted to `level == "explicit"` (dream-derived conclusions carry a single `session_name` but are synthesized across all sessions, so that stamp can't be scoped on), `get_reasoning_chain` is unavailable under an allowlist, and an empty allowlist short-circuits to empty results everywhere. Workspace keys pass the allowlist as-given; peer-scoped JWTs must be an active member of every allowlisted session (401 otherwise) (#882) @@ -747,6 +785,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) + + ### Added + + - Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + - `scope` option on `Peer.chat()` / `chat_stream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`. + - Workspace-level chat: `Honcho.chat()` / `HonchoAio.chat()` and `chat_stream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoning_level`, and `response_format` options as `Peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + + ### Changed + + - `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair. + ### Added @@ -915,6 +964,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk) + + ### Added + + - Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + - `scope` option on `peer.chat()` / `chatStream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`. + - Workspace-level chat: `honcho.chat()` / `honcho.chatStream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoningLevel`, and `responseFormat` options as `peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + + ### Changed + + - `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair. + ### Added @@ -1110,6 +1170,26 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Honcho CLI](https://pypi.org/project/honcho-cli/) + + ### Added + + - A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK` + + ### Fixed + + - `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` (#1068) + - `--setup` API key prompts echo `*` per character so a paste is visibly received instead of a blank getpass field + + + ### Added + + - `honcho start`, `honcho stop`, and `honcho status` β€” run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029) + - `honcho session view` β€” session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006) + + ### Fixed + + - `honcho message list --last N` no longer stops at the first page of 50 β€” it walks pages to fill the requested window (#1006) + ### Added diff --git a/docs/docs.json b/docs/docs.json index 8afbcd77..00e07438 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -10,6 +10,10 @@ { "source": "/v3/guides/integrations/claudecode", "destination": "/v3/guides/integrations/claude-code" + }, + { + "source": "/v3/documentation/features/advanced/representation-scopes", + "destination": "/v3/documentation/features/advanced/directional-representations" } ], "colors": { @@ -24,7 +28,7 @@ "navigation": { "versions": [ { - "version": "v3.0.12", + "version": "v3.1.0", "api": { "openapi": ["v3/openapi.json"] }, @@ -62,7 +66,8 @@ "v3/documentation/features/advanced/reasoning-configuration", "v3/documentation/features/advanced/summarizer", "v3/documentation/features/advanced/peer-card", - "v3/documentation/features/advanced/representation-scopes", + "v3/documentation/features/advanced/directional-representations", + "v3/documentation/features/advanced/scopes", "v3/documentation/features/advanced/dreaming", "v3/documentation/features/advanced/queue-status", "v3/documentation/features/advanced/webhooks", @@ -208,6 +213,18 @@ "v3/api-reference/endpoint/sessions/search-session" ] }, + { + "group": "scopes", + "pages": [ + "v3/api-reference/endpoint/scopes/get-or-create-scope", + "v3/api-reference/endpoint/scopes/get-scopes", + "v3/api-reference/endpoint/scopes/get-scope", + "v3/api-reference/endpoint/scopes/add-sessions-to-scope", + "v3/api-reference/endpoint/scopes/get-scope-sessions", + "v3/api-reference/endpoint/scopes/remove-session-from-scope", + "v3/api-reference/endpoint/scopes/get-scope-status" + ] + }, { "group": "messages", "pages": [ @@ -594,8 +611,8 @@ } }, "integrations": { - "posthog": { - "apiKey": "phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk" + "gtm": { + "tagId": "GTM-NSPT9PJF" } } } diff --git a/docs/posthog-consent.js b/docs/posthog-consent.js new file mode 100644 index 00000000..acb47860 --- /dev/null +++ b/docs/posthog-consent.js @@ -0,0 +1,69 @@ +// Loads PostHog only when the CookieConsent cookie grants Statistics; the +// cookie is host-scoped, so a landing-page answer covers the docs. +;(function () { + var KEY = 'phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk' + var loaded = false + + function granted() { + var m = document.cookie.match(/(?:^|;\s*)CookieConsent=([^;]*)/) + if (!m) return false + var v = decodeURIComponent(m[1]) + // "-1" is Cookiebot's consent-not-required marker. + return v === '-1' || /statistics\s*:\s*true/.test(v) + } + + function loadPosthog() { + if (loaded) return + loaded = true + var s = document.createElement('script') + s.src = 'https://us-assets.i.posthog.com/static/array.js' + s.async = true + s.onerror = function () { + loaded = false + } + s.onload = function () { + // Consent withdrawn while array.js was downloading: skip init, allow a retry on re-grant. + if (!granted()) { + loaded = false + return + } + window.posthog.init(KEY, { + api_host: 'https://us.i.posthog.com', + ui_host: 'https://us.posthog.com', + cross_subdomain_cookie: true, + person_profiles: 'identified_only', + capture_pageview: 'history_change', + }) + } + document.head.appendChild(s) + } + + function sync() { + if (granted()) { + if (!loaded) { + loadPosthog() + } else if ( + window.posthog && + window.posthog.has_opted_out_capturing && + window.posthog.has_opted_out_capturing() + ) { + window.posthog.opt_in_capturing() + } + return + } + // Withdrawal mid-session: an already running instance must stop. + if (loaded && window.posthog && window.posthog.opt_out_capturing) { + window.posthog.opt_out_capturing() + } + } + + sync() + var events = [ + 'CookiebotOnConsentReady', + 'CookiebotOnAccept', + 'CookiebotOnDecline', + ] + for (var i = 0; i < events.length; i++) { + window.addEventListener(events[i], sync) + } +})() diff --git a/docs/snippets/cli-commands.mdx b/docs/snippets/cli-commands.mdx index 800d45a7..739416b0 100644 --- a/docs/snippets/cli-commands.mdx +++ b/docs/snippets/cli-commands.mdx @@ -505,6 +505,69 @@ honcho session view [] +## honcho start + +Start a local Honcho stack (API, deriver, Postgres, Redis). + +Requires Docker. Uses cloud LLM providers. Does not change the CLI's +configured server URL β€” pass HONCHO_BASE_URL to talk to this stack. +``--setup basic`` or ``--setup advanced`` runs an interactive config wizard. + +```bash +honcho start +``` + + + Local stack profile name. + + + Host port for the API. + + + Host port for Postgres. + + + Host port for Redis. + + + Interactive config wizard: basic (provider/model) or advanced (embeddings, deriver, dialectic, dreams, flush). + + + Honcho image to pull and pin by digest (default: ghcr.io/plastic-labs/honcho:latest). + + + Seconds to wait for /health after compose up. + + +## honcho status + +Show local stack endpoints and container health. + +With no ``--profile``, lists every stack under ``~/.honcho/profiles/``. + +```bash +honcho status +``` + + + Limit to this profile. Omit to show every local stack. + + +## honcho stop + +Stop the local stack started by `honcho start`. Keeps data unless --wipe. + +```bash +honcho stop +``` + + + Local stack profile name. + + + Also delete volumes (Postgres data). + + ## honcho workspace List, create, inspect, delete, and search workspaces. diff --git a/docs/v2/contributing/guidelines.mdx b/docs/v2/contributing/guidelines.mdx index 398a8b09..2fa66f1f 100644 --- a/docs/v2/contributing/guidelines.mdx +++ b/docs/v2/contributing/guidelines.mdx @@ -5,13 +5,31 @@ icon: 'handshake' Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions. +## Before you write code + +**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.** + +A pull request that is not linked to an approved issue gets labelled `needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one before it is closed automatically. Reopening costs nothing once the link is in place. This is automated. We do this because an unreviewable backlog helps nobody: a PR against an unapproved issue is work you did that we may not be able to merge, no matter how good it is. + +So, in order: + +1. **Find approved work.** Browse [issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved). That label is the queue of things we have agreed should be built. Anything in it is fair game β€” comment on the issue to claim it. + +2. **Or open an issue and get it approved.** Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers triage and apply the label. + +3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).** This is the fastest path by a wide margin. Maintainers are more active there than in the issue tracker, and a five-minute conversation about what you want to build usually resolves whether it fits before either side spends real time on it. + +4. **Then open the PR** and link the issue β€” either `Fixes #123` in the description, or **Development β†’ link an issue** in the sidebar. Both work. + +Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the issue linkage. + ## Getting Started Before you start contributing, please: 1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally. -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. +2. **Join our community** - Feel free to join us in our [Discord](https://discord.gg/honcho) to discuss your changes, get help, or ask questions. 3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. @@ -94,7 +112,7 @@ git commit -m "docs(readme): update installation instructions" 3. Fill out the pull request template with: - A clear description of what changes you've made - The motivation for the changes - - Any relevant issue numbers (use "Closes #123" to auto-close issues) + - A link to the approved issue β€” `Fixes #123` in the description, or **Development β†’ link an issue** in the sidebar. This is required; see [Before you write code](#before-you-write-code). - Screenshots or examples if applicable ## Coding Standards @@ -128,7 +146,7 @@ git commit -m "docs(readme): update installation instructions" ## Review Process -1. **Automated checks** - Your PR will run through automated checks including tests and linting +1. **Automated checks** - Your PR will run through automated checks including tests, linting, and the issue gate 2. **Project maintainer review** - A project maintainer will review your code for: - Code quality and adherence to standards - Functionality and correctness @@ -153,17 +171,21 @@ We welcome various types of contributions: When reporting bugs or requesting features: 1. Check if the issue already exists -2. Use the appropriate issue template +2. Use the appropriate [issue template](https://github.com/plastic-labs/honcho/issues/new/choose) (bug, memory/recall quality, feature, integration, or documentation) 3. Provide clear reproduction steps for bugs -4. Include relevant environment information +4. Include relevant environment information (managed vs self-hosted, server version, SDK) 5. Be specific about expected vs actual behavior +6. Redact secrets, JWTs, and production user content ## Questions and Support -- **General questions** - Join our [Discord](http://discord.gg/honcho) -- **Bug reports** - Use GitHub issues -- **Feature requests** - Use GitHub issues with the feature request template -- **Security issues** - Please email us privately rather than opening a public issue +- **General questions** - Join our [Discord](https://discord.gg/honcho) +- **Bug reports** - GitHub issues β†’ Bug report template +- **Memory / recall quality** - GitHub issues β†’ Memory / recall quality template +- **Feature requests** - GitHub issues β†’ Feature request template +- **Integrations / plugins / app-store listings** - GitHub issues β†’ Integration request template +- **Documentation issues** - GitHub issues β†’ Documentation issue template +- **Security issues** - Report **privately** only β€” see [`SECURITY.md`](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) (GitHub Private Vulnerability Reporting or email). Do not open a public issue. ## License diff --git a/docs/v3/api-reference/endpoint/scopes/add-sessions-to-scope.mdx b/docs/v3/api-reference/endpoint/scopes/add-sessions-to-scope.mdx new file mode 100644 index 00000000..e03b8941 --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/add-sessions-to-scope.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-or-create-scope.mdx b/docs/v3/api-reference/endpoint/scopes/get-or-create-scope.mdx new file mode 100644 index 00000000..9908c7bb --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-or-create-scope.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/scopes +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-scope-sessions.mdx b/docs/v3/api-reference/endpoint/scopes/get-scope-sessions.mdx new file mode 100644 index 00000000..d3e9840d --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-scope-sessions.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-scope-status.mdx b/docs/v3/api-reference/endpoint/scopes/get-scope-status.mdx new file mode 100644 index 00000000..489ce9f6 --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-scope-status.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id}/status +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-scope.mdx b/docs/v3/api-reference/endpoint/scopes/get-scope.mdx new file mode 100644 index 00000000..192fc74b --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-scope.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id} +--- diff --git a/docs/v3/api-reference/endpoint/scopes/get-scopes.mdx b/docs/v3/api-reference/endpoint/scopes/get-scopes.mdx new file mode 100644 index 00000000..6362d59d --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/get-scopes.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v3/workspaces/{workspace_id}/scopes/list +--- diff --git a/docs/v3/api-reference/endpoint/scopes/remove-session-from-scope.mdx b/docs/v3/api-reference/endpoint/scopes/remove-session-from-scope.mdx new file mode 100644 index 00000000..164e912c --- /dev/null +++ b/docs/v3/api-reference/endpoint/scopes/remove-session-from-scope.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id} +--- diff --git a/docs/v3/contributing/guidelines.mdx b/docs/v3/contributing/guidelines.mdx index 398a8b09..92c42fcf 100644 --- a/docs/v3/contributing/guidelines.mdx +++ b/docs/v3/contributing/guidelines.mdx @@ -3,170 +3,360 @@ title: 'Contributing Guidelines' icon: 'handshake' --- -Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions. +{/* This file mirrors CONTRIBUTING.md in the repo root. Update both. */} -## Getting Started +Thanks for your interest in contributing. This guide covers how work gets accepted, how +Honcho is put together, and what a mergeable pull request looks like. -Before you start contributing, please: +Honcho is a small team maintaining a project that gets more proposals than we can review. +The rules below exist so that the work you do has somewhere to land β€” not to keep you out. -1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally. +## Before you write code -2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions. +**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.** -3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to. +A pull request that is not linked to an approved issue gets labelled +`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one +before it is closed automatically. Reopening costs nothing once the link is in place. This +is automated. We do this because an unreviewable backlog helps nobody: a PR against an +unapproved issue is work you did that we may not be able to merge, no matter how good it +is. -## Contribution Workflow +So, in order: -### 1. Fork and Clone +1. **Find approved work.** Browse + [issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved). + That label is the queue of things we have agreed should be built. Anything in it is fair + game β€” comment on the issue to claim it. -1. Fork the repository on GitHub -2. Clone your fork locally: - ```bash - git clone https://github.com/YOUR_USERNAME/honcho.git - cd honcho - ``` -3. Add the upstream repository as a remote: - ```bash - git remote add upstream https://github.com/plastic-labs/honcho.git - ``` +2. **Or open an issue and get it approved.** Use the + [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers + triage and apply the label. -### 2. Create a Branch +3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).** + This is the fastest path by a wide margin. Maintainers are more active there than in the + issue tracker, and a five-minute conversation about what you want to build usually + resolves whether it fits before either side spends real time on it. -Create a new branch for your feature or bug fix: +4. **Then open the PR** and link the issue β€” either `Fixes #123` in the description, or + **Development β†’ link an issue** in the sidebar. Both work. + +Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an +obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the +issue linkage. + +## What gets prioritized + +Roughly, work on Honcho falls along these axes. Knowing which one your idea sits on tells +you a lot about how likely it is to get approved. + +| Axis | What it covers | +| --- | --- | +| **Observability** | Understanding how Honcho behaves in production β€” telemetry, tracing, CloudEvents, metrics. | +| **Memory quality** | Better conclusions from the same input β€” the deriver, dreamer, and dialectic; eval results. | +| **Developer experience** | Fitting cleanly into more application architectures β€” SDKs, scopes, composable peers, the CLI. | +| **Breadth of input** | Widening what Honcho can ingest and represent β€” multimodal and non-conversational data. | +| **Ubiquity** | Reachable wherever a developer already works β€” integrations, self-hosting, alternate vector-store and inference backends, local-first defaults. | +| **Reliability and cost** | Trustworthy in production β€” connection and concurrency hardening, queue throughput, cost per token. | + +In practice, **Ubiquity** and **Developer experience** are where outside contributions land +most easily. A new integration, a self-hosting rough edge, a vector-store or inference +backend, an SDK ergonomics fix β€” these are additive and rarely collide with work already in +flight. + +Changes to the reasoning pipeline itself β€” deriver prompts, dialectic tool design, dreamer +strategy β€” are the hardest to accept from outside. Not because they are unwelcome, but +because they are measured against eval results we run internally, and they frequently +conflict with in-flight work. Talk to us in Discord first, always. + +## If you're an agent + +If you are a coding agent working on this repository, read this section before writing code. +The most common failure we see is a well-formed, well-tested pull request against an issue +that was never approved. That gets closed, and the work is wasted. + +- **Check the gate first.** Before writing code: + + ```bash + gh issue view --repo plastic-labs/honcho --json number,title,state,labels + ``` + + Stop if there is no issue number, if the issue is closed, or if `maintainer-approved` is + not in the labels. Report that to the person you are working with instead of proceeding. + +- **Do not open a PR in order to establish the issue link afterwards.** The issue comes + first. + +- **Do not report checks you did not run.** If you did not execute the test command, say so. + A PR body claiming a green run that did not happen costs a maintainer more time than no + claim at all. + +- **Use the checklist.** [`skills/pre-pr/SKILL.md`](https://github.com/plastic-labs/honcho/blob/main/skills/pre-pr/SKILL.md) in this repo + encodes the gate, the test-layer matrix, and the PR body format. If your harness supports + skills, invoke it rather than reimplementing the checks. + +## How Honcho works + +Enough architecture to find your way around. For the user-facing model β€” what a Peer is, what +`get_context` returns β€” see [Core Concepts](https://github.com/plastic-labs/honcho#core-concepts) and the +[documentation](https://honcho.dev/docs/). + +### Two processes + +Honcho runs as two cooperating processes over a shared Postgres database and Redis cache. + +| | API server | Deriver worker | +| --- | --- | --- | +| Start | `uv run fastapi dev src/main.py` | `uv run python -m src.deriver` | +| Entry | `src/main.py` | `src/deriver/__main__.py` | +| Does | Serves HTTP, enqueues background work, returns immediately | Consumes the queue: Deriver, Summarizer, Dreamer, Reconciler | +| Hosts | The Dialectic agent, inline on the request path | Everything else | + +The split is the load-bearing design decision: **an HTTP request never blocks on LLM work**, +with the single exception of the Dialectic chat endpoint, which is synchronous by nature. +If you are adding something slow, it belongs in the worker. + +The deriver is a separate process. If messages go in and nothing ever comes out, the usual +cause is that nobody started it. + +### The path of a message + +Worth tracing once, because it crosses most of the codebase: + +1. `POST /v3/workspaces/{w}/sessions/{s}/messages` lands in `src/routers/messages.py`. +2. The row is written, then `enqueue()` in `src/deriver/enqueue.py` creates `queue_item` + rows β€” one set of work per observing peer. +3. `src/deriver/queue_manager.py` polls the queue, claiming work units so that messages in a + session are processed in order. +4. `process_item()` in `src/deriver/consumer.py` dispatches on task type β€” representation, + summary, deletion, reconciliation. +5. For a representation task, `process_representation_tasks_batch()` in + `src/deriver/deriver.py` makes **one structured-output LLM call for the whole batch** and + writes the resulting conclusions into the collection keyed by the + `(observer, observed)` peer pair. +6. Later, `src/dialectic/` reads those conclusions back at recall time to answer a chat + request. + +Embedding is deliberately *not* on this path. `MessageEmbedding` rows are written with +`sync_state='pending'` and embedded asynchronously by the Reconciler +(`src/reconciler/sync_vectors.py`), which runs on a scheduler inside the deriver process. + +### The four agents + +They share tool definitions in `src/utils/agent_tools.py` and the provider-agnostic LLM +client in `src/llm/`. Each has its own `MODEL_CONFIG` with a fallback chain in +`src/config.py`. + +| Agent | Where | Shape | +| --- | --- | --- | +| **Deriver** | `src/deriver/` | A single structured-output call per message batch. Not a tool loop β€” this is a deliberate cost and latency tradeoff. | +| **Dialectic** | `src/dialectic/` | The one tool-using agent on the request path. Loops over tools until it can answer. Five reasoning tiers from `minimal` to `max`, each with its own model and tool set. | +| **Dreamer** | `src/dreamer/` | Off-queue consolidation. Two specialist phases (deduction, then induction) that build reasoning trees over existing conclusions. | +| **Summarizer** | `src/utils/summarizer.py` | Direct LLM call, no tools. Two tiers β€” short and long summaries at different message counts. | + +Prompts live in `src/deriver/prompts.py`, `src/dialectic/prompts.py`, and +`src/dreamer/specialists.py`. + +### A note on naming + +What the public API and documentation call **conclusions** are called **observations** +throughout the code β€” `create_observations`, `get_observation_context`, and so on. Likewise +**collections** and **documents** are internal storage concepts that are not exposed +directly through the API. Do not rename across that boundary in a drive-by change; the +public and internal vocabularies are being reconciled deliberately. + +## Where to change what + +| I want to change... | Start here | +| --- | --- | +| An HTTP endpoint | `src/routers/` β€” one module per resource | +| A database query | `src/crud/` β€” mirrors the router layout | +| The database schema | `src/models.py`, plus a migration in `migrations/versions/` | +| A configuration value | `src/config.py`, and add it to `config.toml.example` and `.env.template` | +| A tool an agent can call | `src/utils/agent_tools.py` β€” definitions plus the per-agent tool lists | +| A prompt | `src/deriver/prompts.py`, `src/dialectic/prompts.py`, `src/dreamer/specialists.py` | +| LLM provider behavior | `src/llm/backends/` β€” `anthropic.py`, `gemini.py`, `openai.py` | +| Embeddings or vector storage | `src/embedding_client.py`, `src/vector_store/` | +| Telemetry or metrics | `src/telemetry/` β€” see the notes in `CLAUDE.md` before adding an event type | +| Authentication and scoping | `src/security.py`, `src/dependencies.py` | +| The Python or TypeScript SDK | `sdks/python/`, `sdks/typescript/` | +| The CLI | `honcho-cli/` | +| The MCP server | `mcp/` | +| Public documentation | `docs/v3/` β€” Mintlify; nav lives in `docs/docs.json` | + +Tests in `tests/` mirror `src/`. `CLAUDE.md` at the repo root has more detail on house +conventions, and is worth skimming even if you are not using an agent. + +## Local setup + +To run a personal instance, install the CLI (`uv tool install honcho-cli`) and then run +`honcho start --setup` (Docker + an LLM provider key) β€” [CLI reference](/v3/documentation/reference/cli). + +To **develop this repo**, clone it and: + +```bash +uv sync # create the venv and install dependencies +uv run alembic upgrade head # apply migrations +``` + +Run both processes, in separate terminals: + +```bash +uv run fastapi dev src/main.py # API server, reloads on change +uv run python -m src.deriver # background worker +``` + +Everything Python goes through `uv run`. Redis is optional for local development; without it +caching is simply disabled. + +## Making the change + +### Branches and commits ```bash git checkout -b feature/your-feature-name -# or -git checkout -b fix/your-bug-fix-name ``` -**Branch naming conventions:** -- `feature/description` - for new features -- `fix/description` - for bug fixes -- `docs/description` - for documentation updates -- `refactor/description` - for code refactoring -- `test/description` - for adding or updating tests +Prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`. -### 3. Make Your Changes +Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a +`commit-msg` hook: -- Write clean, readable code that follows our coding standards (see below) -- Add tests for new functionality -- Update documentation as needed -- Make sure your changes don't break existing functionality - -### 4. Commit Your Changes - -We follow conventional commit standards. Format your commit messages as: - -``` -type(scope): description - -[optional body] - -[optional footer] -``` - -**Types:** -- `feat`: A new feature -- `fix`: A bug fix -- `docs`: Documentation only changes -- `style`: Changes that do not affect the meaning of the code -- `refactor`: A code change that neither fixes a bug nor adds a feature -- `test`: Adding missing tests or correcting existing tests -- `chore`: Changes to the build process or auxiliary tools - -**Examples:** ```bash git commit -m "feat(api): add new dialectic endpoint for user insights" git commit -m "fix(db): resolve connection pool timeout issue" -git commit -m "docs(readme): update installation instructions" ``` -### 5. Submit a Pull Request +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. -1. Push your branch to your fork: - ```bash - git push origin your-branch-name - ``` +### Pre-commit hooks -2. Create a pull request on GitHub from your branch to the `main` branch +Install them. CI runs the same checks, and it is much faster to find out locally. -3. Fill out the pull request template with: - - A clear description of what changes you've made - - The motivation for the changes - - Any relevant issue numbers (use "Closes #123" to auto-close issues) - - Screenshots or examples if applicable +```bash +uv run pre-commit install \ + --hook-type pre-commit \ + --hook-type commit-msg \ + --hook-type pre-push +``` -## Coding Standards +At **commit** time: ruff lint and format, biome for TypeScript, basedpyright, bandit, +markdownlint, and file hygiene. At **push** time: pytest, the alembic migration tests, and +the SDK builds. -### Python Code Style +That split matters β€” **a clean commit is not a clean push.** The test suite only runs at +`pre-push`, so the first time you see test failures may be well after you thought you were +done. -- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines -- Use [Black](https://black.readthedocs.io/) for code formatting (we may add this to CI in the future) -- Use type hints where possible -- Write docstrings for functions and classes using Google style docstrings +Run them by hand at any time: -### Code Organization +```bash +uv run pre-commit run --all-files +uv run pre-commit run ruff --all-files +``` -- Keep functions focused and single-purpose -- Use meaningful variable and function names -- Add comments for complex logic -- Follow existing patterns in the codebase +Or the individual tools: -### Testing +```bash +uv run ruff check src/ +uv run ruff format src/ +uv run basedpyright +``` -- Write unit tests for new functionality -- Ensure existing tests pass before submitting -- Use descriptive test names that explain what is being tested -- Mock external dependencies appropriately +### Tests + +Write tests for new functionality, in the directory under `tests/` that mirrors the code you +changed. Which layer you need depends on what you touched: + +| What you changed | What to run | +| --- | --- | +| Anything in `src/` | Unit tests in the matching `tests/` tree β€” `uv run pytest tests/...` | +| Deriver, dialectic, dreamer, or the LLM path | Unit tests, and consider `tests/live_llm/` (gated behind `--live-llm`) | +| Queue behavior, config hierarchy, multi-turn flows, SDK contracts | `uv run python -m tests.unified.run` | +| A `/v3` endpoint or deriver queue behavior | Actually run the stack and exercise it β€” not just pytest | +| A migration | `uv run python scripts/run_alembic_tests.py`; every revision needs a test file | + +The TypeScript SDK tests need a running server with a database and Redis, which pytest +orchestrates. Run them with `uv run pytest tests/ -k typescript` from the repo root β€” +`bun test` on its own will fail. To type-check the SDK alone: +`cd sdks/typescript && bun run tsc --noEmit`. ### Documentation -- Update relevant documentation for new features -- Include examples in docstrings where helpful -- Keep README and other docs up to date with changes +Update docs in the same PR when you change a public surface: `/v3` endpoints, SDK exports, +or anything in `config.toml` / settings. Docs live in `docs/v3/`, and new pages need an entry +in `docs/docs.json` or they will not appear in the nav. -## Review Process +## Opening the pull request -1. **Automated checks** - Your PR will run through automated checks including tests and linting -2. **Project maintainer review** - A project maintainer will review your code for: - - Code quality and adherence to standards - - Functionality and correctness - - Test coverage - - Documentation completeness -3. **Discussion and iteration** - You may be asked to make changes or clarifications -4. **Approval and merge** - Once approved, your PR will be merged into `main` +### Leave "Allow edits by maintainers" checked -## Types of Contributions +This is the single most useful thing you can do to get your PR merged quickly. -We welcome various types of contributions: +Most contributor PRs arrive nearly right, needing a rename, a missing test, or a lint fix. +If we can push that commit ourselves, it merges the same day. If we cannot, it becomes a +review comment, and then we wait β€” sometimes for weeks β€” for a round trip on a two-line +change. -- **Bug fixes** - Help us squash bugs and improve stability -- **New features** - Add functionality that benefits the community -- **Documentation** - Improve or expand our documentation -- **Tests** - Increase test coverage and reliability -- **Performance improvements** - Help make Honcho faster and more efficient -- **Examples and tutorials** - Help other developers use Honcho +GitHub checks the box by default when you fork. Leave it checked. -## Issue Reporting +One caveat worth knowing: **the option does not exist on forks owned by an organization.** +If you have the choice, fork from your personal account. -When reporting bugs or requesting features: +### Fill out the template -1. Check if the issue already exists -2. Use the appropriate issue template -3. Provide clear reproduction steps for bugs -4. Include relevant environment information -5. Be specific about expected vs actual behavior +`.github/pull_request_template.md` asks for a description, proofs, and the issue checkbox. -## Questions and Support +"Proofs" means evidence the change works: the command you ran and its result, a log snippet, +a screenshot, the failing case before and after. This is the section that most determines +how fast your PR gets reviewed. Do not add sections to the template. -- **General questions** - Join our [Discord](http://discord.gg/honcho) -- **Bug reports** - Use GitHub issues -- **Feature requests** - Use GitHub issues with the feature request template -- **Security issues** - Please email us privately rather than opening a public issue +Link the issue so the gate can see it: `Fixes #123` in the description, or the +**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so +either route works β€” but a bare `#123` mention is only a reference and does not count. + +### Review + +1. Automated checks run β€” tests, linting, static analysis, and the issue gate. +2. A maintainer reviews for correctness, test coverage, and fit with the surrounding code. + `.github/CODEOWNERS` routes the request to whoever owns the area you touched. +3. You may be asked for changes. Or we may just push them, if you left edits enabled. +4. Once approved, we merge to `main`. + +If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho). + +## Reporting bugs and requesting features + +Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is +one per kind of report, and picking the right one is most of what gets an issue triaged +quickly: + +- **Bug report** β€” something is broken or behaves incorrectly +- **Memory / recall quality** β€” the deriver or dialectic returns poor, wrong, or missing context +- **Feature request** β€” a new capability or API surface +- **Integration request** β€” plugins, framework integrations, app-store listings +- **Documentation issue** β€” anything wrong or missing in the docs +- **General questions** β€” not an issue at all; ask in [Discord](https://discord.gg/honcho) + +Before opening one, search existing issues, including closed ones. + +A good bug report has the Honcho version or commit, whether you are self-hosted or on +`api.honcho.dev`, the steps to reproduce, and what you expected instead. If it involves the +deriver, logs from the worker process are usually the thing we ask for first. + +**Redact before you post.** Issues are public, and Honcho stores conversational data β€” strip +API keys, JWTs, and production user content out of any log or payload you attach. + +## Security + +Do not open a public issue for a suspected vulnerability. Report it privately through +[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new), +which is the preferred channel, or by email. See [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) for what +to include, and note that Honcho does not operate a bug bounty. ## License -By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./license) that covers the project. +By contributing to Honcho, you agree that your contributions will be licensed under the same +[AGPL-3.0 License](./license) that covers the project. Thank you for helping make Honcho better! 🫑 diff --git a/docs/v3/contributing/self-hosting.mdx b/docs/v3/contributing/self-hosting.mdx index 41a439f2..02d361f1 100644 --- a/docs/v3/contributing/self-hosting.mdx +++ b/docs/v3/contributing/self-hosting.mdx @@ -7,6 +7,8 @@ icon: 'computer' This guide helps you set up a local environment to run Honcho for development, testing, or self-hosting. +**Just want a running instance?** `uv tool install honcho-cli` only installs the `honcho` command. Then run [`honcho start --setup`](/v3/documentation/reference/cli#local-stack) (Docker + an LLM provider key) β€” that pulls a published image and starts API, deriver, Postgres, and Redis. The rest of this page is for building from source, contributing, or deploying without the CLI. + ## Overview By the end of this guide, you'll have: @@ -22,7 +24,7 @@ Before you begin, ensure you have the following installed: ### Required Software - **uv** - Python package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh` or `brew install uv` - **Git** - [Download from git-scm.com](https://git-scm.com/downloads) -- **Docker** (required for Docker setup, not needed for manual setup) - [Download from docker.com](https://www.docker.com/products/docker-desktop/) +- **Docker** - required for the CLI local stack and the compose-from-source path; not needed for a fully manual setup. [Download from docker.com](https://www.docker.com/products/docker-desktop/) ### Database Options You'll need a PostgreSQL database with the pgvector extension. Choose one: @@ -59,13 +61,22 @@ DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1 For recommended model tiers per feature, using multiple providers, or direct vendor API keys, see the [Configuration Guide](./configuration#llm-configuration). - -**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers, interactive provider setup, and Hermes Agent integration. - +## Personal local stack (CLI) -## Docker Setup (Recommended) +Recommended if you want Honcho running locally without cloning this repo or building an image. Install the CLI, then run the setup wizard: -Docker Compose handles the database, Redis, and Honcho server. The compose file **builds the image from source** (there is no pre-built image on Docker Hub). This requires Docker with BuildKit enabled β€” see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails. +```bash +uv tool install honcho-cli +honcho start --setup basic # prompts for LLM provider + key, then starts Docker +``` + +`honcho start` pulls `ghcr.io/plastic-labs/honcho:latest`, pins that digest, and starts API + deriver + Postgres + Redis. Stack files live under `~/.honcho/profiles/local/`. It does **not** rewrite `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). Talk to the stack with `HONCHO_BASE_URL=http://127.0.0.1:8000`, or run `honcho init --base-url http://127.0.0.1:8000` to persist local as the CLI default. + +See the [CLI reference](/v3/documentation/reference/cli#local-stack) for `--setup`, profiles, `--image`, ports, `honcho status` / `stop`, and pointing the CLI at local. + +## From source (Docker Compose) + +Docker Compose in this repo handles the database, Redis, and Honcho server. The compose file **builds the image from source** so you can develop against local code. A pre-built image is published at `ghcr.io/plastic-labs/honcho:latest` (what `honcho start` uses); it is not on Docker Hub. Building from source requires Docker with BuildKit enabled β€” see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails. The compose file is production-oriented by default (ports bound to `127.0.0.1`, restart policies, caching enabled). For development, uncomment the source mounts and monitoring services inside the file. @@ -321,6 +332,7 @@ const client = new Honcho({ ### Next Steps - **Configure Honcho**: Visit the [Configuration Guide](./configuration) for model tiers, provider options, and tuning +- **Use the CLI**: install with `uv tool install honcho-cli`, then [`honcho start --setup`](/v3/documentation/reference/cli#local-stack) for a local stack; inspect with `honcho workspace inspect` / `honcho doctor` - **Explore the API**: Check out the [API Reference](../api-reference/introduction) - **Try the SDKs**: See our [guides](../guides) for examples - **Join the community**: [Discord](https://discord.gg/honcho) @@ -334,11 +346,12 @@ Running into issues? See the [Troubleshooting Guide](./troubleshooting) for deta - Deriver not processing messages - Database connection and migration issues - Docker and Redis problems +- CLI local stack (`honcho start`) β€” missing LLM key, health timeout, still talking to api.honcho.dev **Quick checks:** - Verify the server is running: `curl http://localhost:8000/health` -- Check logs: `docker compose logs api` (Docker) or check terminal output (manual setup) -- Ensure migrations ran: `uv run alembic upgrade head` +- Check logs: `docker compose logs api` (from-source Docker), `docker compose -p honcho-local logs` (`honcho start`), or terminal output (manual setup) +- Ensure migrations ran: `uv run alembic upgrade head` (from-source only; `honcho start` runs them in the image entrypoint) ## Production Considerations diff --git a/docs/v3/contributing/troubleshooting.mdx b/docs/v3/contributing/troubleshooting.mdx index ffb73417..76d9a268 100644 --- a/docs/v3/contributing/troubleshooting.mdx +++ b/docs/v3/contributing/troubleshooting.mdx @@ -109,7 +109,6 @@ Messages are stored but no observations, summaries, or representations are being ```bash DERIVER_WORKERS=4 ``` -5. **Representation Batching** β€” By default the deriver buffers representation work until a work unit has accumulated enough tokens, set via `DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS` (`0` disables the accumulation gate). A separate setting, `DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS`, caps the conversation window fed to each deriver LLM call when draining a claimed work unit. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details ## Alternative Provider Issues @@ -316,11 +315,37 @@ docker compose build --no-cache docker compose up -d ``` +## CLI local stack (`honcho start`) + +The CLI local stack lives under `~/.honcho/profiles/` (default profile `local`) and uses the published image `ghcr.io/plastic-labs/honcho:latest`. Logs: `docker compose -p honcho-local logs`. Full flags: [CLI reference](/v3/documentation/reference/cli#local-stack). + +### `MISSING_LLM_KEY` + +**Cause:** No provider key in the environment or the profile `.env`. + +**Fix:** Export `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY`, or run `honcho start --setup basic` in a TTY (not with `--json`). + +### Timed out waiting for `/health` + +**Cause:** The API container started but did not become ready within `--timeout` (default 180s). + +**Fix:** Check `docker compose -p honcho-local logs api` and `... logs deriver`. Increase `--timeout`. Confirm Docker is running. A first-time GHCR pull happens *before* this wait (during image pin) β€” if that step hung, look at Docker pull logs instead. + +### CLI still talks to `api.honcho.dev` + +**Cause:** `honcho start` does not rewrite `environmentUrl` in `~/.honcho/config.json`. + +**Fix:** Prefix commands with `HONCHO_BASE_URL=http://127.0.0.1:8000`, or run `honcho init --base-url http://127.0.0.1:8000` to persist local as the default. `honcho status` prints the one-shot hint. + +### Port already in use + +The CLI remaps 8000/5432/6379 automatically unless you pinned them with `--api-port` / `--db-port` / `--redis-port`. Pass those flags if you need a specific host port. + ## Getting Help If your issue isn't covered here: -- **Check the logs** β€” most issues are diagnosed from server or deriver logs +- **Check the logs** β€” most issues are diagnosed from server or deriver logs (`docker compose logs` for from-source compose; `docker compose -p honcho-local logs` for `honcho start`) - **GitHub Issues** β€” [Report bugs](https://github.com/plastic-labs/honcho/issues) - **Discord** β€” [Join our community](https://discord.gg/plasticlabs) - **Configuration** β€” See the [Configuration Guide](./configuration) for all available settings diff --git a/docs/v3/documentation/core-concepts/architecture.mdx b/docs/v3/documentation/core-concepts/architecture.mdx index b3f0f7a1..5035d968 100644 --- a/docs/v3/documentation/core-concepts/architecture.mdx +++ b/docs/v3/documentation/core-concepts/architecture.mdx @@ -34,7 +34,7 @@ Honcho has a hierarchical data model centered around the entities below. Workspaces are the top-level containers in Honcho. They provide complete isolation between different applications or environments, essentially serving as a namespace to keep different workloads separate. You might use separate workspaces for development, staging, and production environments, or to isolate different product lines. They also enable multi-tenant SaaS applications where each customer gets their own isolated workspace with complete data separation. -Authentication is scoped to the workspace level, and configuration settings can be applied workspace-wide to control behavior across all peers and sessions within that workspace. +Authentication is issued at the workspace level, and configuration settings can be applied workspace-wide to control behavior across all peers and sessions within that workspace. --- @@ -50,12 +50,14 @@ You can use peers for any entity that persists over time--individual users in ch ### Sessions -Sessions represent interaction threads or contexts between peers. A session can involve multiple peers and provides temporal boundaries for when a set of interactions starts and ends. This lets you scope context and memory to specific interactions while still maintaining longer-term peer representations that span sessions. +Sessions represent interaction threads or contexts between peers. A session can involve multiple peers and provides temporal boundaries for when a set of interactions starts and ends. This lets you confine context and memory to specific interactions while still maintaining longer-term peer representations that span sessions. -Use sessions to scope things like support tickets, meeting transcripts, learning sessions, or conversations. You can also use single-peer sessions as a way to import external data--create a session with just one peer and structure emails, documents, or files as messages to enrich that peer's representation. +Use sessions for things like support tickets, meeting transcripts, learning sessions, or conversations. You can also use single-peer sessions as a way to import external data--create a session with just one peer and structure emails, documents, or files as messages to enrich that peer's representation. Session-level configuration gives you fine-grained control over perspective-taking behavior. You can configure whether a peer should form representations of other peers in the session, and whether other peers should form representations of them. +Sessions are also the unit of visibility: when one peer's history spans contexts that shouldn't inform each other, you can group sessions into named [scopes](/v3/documentation/features/advanced/scopes) that bound recall to just those sessions. + --- ### Messages @@ -84,7 +86,7 @@ Honcho runs as two cooperating processes: an **API server** that handles request **Write path (synchronous).** A message is stored and a reasoning task is enqueued in the same request; the API returns immediately. Nothing about the reasoning that follows blocks the caller. -**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks in small batches. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message (well, per-batch) rather than on a schedule. +**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message rather than on a schedule. **Dreamer (periodic).** On a schedule (or triggered on demand), the Dreamer revisits existing conclusions to consolidate and deepen them: removing redundant or stale ones, drawing inductive conclusions across patterns that span multiple messages, and updating peer cards--compact biographical summaries of a peer. This is where memory gets richer over time, not just larger. diff --git a/docs/v3/documentation/core-concepts/design-patterns.mdx b/docs/v3/documentation/core-concepts/design-patterns.mdx index 9893d3f4..481dd769 100644 --- a/docs/v3/documentation/core-concepts/design-patterns.mdx +++ b/docs/v3/documentation/core-concepts/design-patterns.mdx @@ -12,22 +12,26 @@ Ready to add Honcho to your codebase? The **`/honcho-integration` skill** applie ## Quick Reference -**Workspaces isolate, peers persist, and sessions scope the active context.** +**Workspaces isolate, peers persist, and sessions bound the active context.** | Decision | Recommendation | |----------|---------------| | How many workspaces? | One workspace per application, tool, tenant, or collaboration boundary. Split workspaces only when you need hard isolation between products, customers, environments, or agents. | | When should agents share a workspace? | When agents collaborate over the same product, project, team, user, customer, or game state. Separate them when they should not see or influence each other's memory. | | Who should be a peer? | Any persistent participant whose messages should be attributed or reasoned about: users, agents, assistants, NPCs, students, or customers. Use one peer for the same entity across sessions and platforms. | -| How should I scope sessions? | Scope sessions to the active interaction: per-conversation, per-channel, per-task run, per-project, per-import, or other bounded context. Reuse a session when local context should keep accumulating. | +| How should I divide sessions? | Match each session to the active interaction: per-conversation, per-channel, per-task run, per-project, per-import, or other bounded context. Reuse a session when local context should keep accumulating. | | How does cross-session reasoning work? | Session memory stays local to one session. Peer representations accumulate across every session where the peer is included, and `session.context()` becomes cross-session when you include a peer target. | | Should I set `observe_me: false`? | Yes, for deterministic peers Honcho does not need to model, like bots or tool agents. Still save their messages so other peers have session context. Keep it enabled for users and evolving agents. | | Do I need `observe_others`? | Only when a peer needs its own perspective on another participant, such as in games, multi-agent systems, or parent/subagent workflows. | +| When do I need a scope? | When one peer's history spans contexts that must not leak into each other's recall β€” but you still want one workspace and one unified peer. Group the confidential sessions into a [scope](/v3/documentation/features/advanced/scopes) and pass it at query time. | +| Perspectives or scopes? | `observe_others` gives a *participant* its own view of another peer. A scope bounds recall to *where things were said*, for a reader that isn't a participant. If the reader is in the session, use perspectives; if you're fencing off a set of sessions, use a scope. | ## Workspace Design A workspace is a hard isolation boundary. **Default to one workspace per application,** and split only at a real privacy, compliance, or product boundary (e.g. per-tenant SaaS, or a tool that needs intentionally isolated memory). Agents that collaborate over the same product, user, or game state belong in the *same* workspace so each can retrieve what the others produced. +If what you actually need is "this part of a peer's history shouldn't inform that assistant," don't split the workspace β€” that severs the peer's identity too. Use a [scope](/v3/documentation/features/advanced/scopes) instead: the peer stays whole, and recall through the scope sees only its member sessions. + Honcho plugins default to one workspace *per host* (`hermes`, `claude_code`, `cursor`, `opencode`). To unify memory across them, point each at the same workspace β€” see [Unified Memory Setup](/v3/guides/recipes/unified-memory-setup). @@ -48,11 +52,11 @@ For unified context across Honcho plugins, set the same user peer ID (`peerName` ## Session Design -Sessions define the temporal boundaries of an interaction. How you scope them affects how summaries are generated, how context is retrieved, and when reasoning fires. +Sessions define the temporal boundaries of an interaction. Where you draw those boundaries affects how summaries are generated and how context is retrieved. **Common session patterns** -| Pattern | Session scoped to | Example | +| Pattern | Session covers | Example | |---------|-------------------|---------| | Per-conversation | Each new chat thread | ChatGPT or Claude Code style UI where each thread is a session | | Per-channel | A persistent channel or room | Discord channel, Slack thread | @@ -62,10 +66,6 @@ Sessions define the temporal boundaries of an interaction. How you scope them af Create a **new** session when context resets (new conversation, new day, new topic); **reuse** one when context should keep accumulating (ongoing channel, persistent thread). - -**Don't scope sessions too thin.** Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*, with a default age-based flush for quiet tails ([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Low-volume or trickle inputs should still append to one ongoing session rather than fragment across many, so reasoning runs with useful context instead of many small delayed batches. - - **How cross-session reasoning works** - **Session memory** is local to an interaction β€” summaries and recent-message context describe only what happened there. @@ -75,14 +75,33 @@ So you can start a session fresh or pull in a peer's long-term memory. [`session --- +## Choosing an Isolation Boundary + +Honcho gives you three boundaries at different strengths. Pick the weakest one that solves your problem: + +| Boundary | Strength | Use when | +|----------|----------|----------| +| **Workspace** | Hard isolation β€” nothing crosses, including the peer itself | Different products, tenants, or environments | +| **[Scope](/v3/documentation/features/advanced/scopes)** | Recall boundary β€” one peer, but queries through the scope see only its sessions | One peer's contexts must not leak into each other (clinical vs. billing, per-reseller support) | +| **Session allowlist** (`sessions=[...]`) | Ad-hoc recall restriction, decided per request | The session set varies per query, or you need a quick boundary without provisioning anything | + +Two things scopes are **not**: + +- **Not authorization.** A workspace key reads any session, scoped or not. A scope constrains queries that name it; it doesn't protect data from queries that don't. +- **Not topic filtering.** Scopes bound recall by *where something was said*, not what it's about. A therapy detail mentioned in a billing session lands in the billing scope. If you might ever need a scope boundary, align your session boundaries with your confidentiality boundaries from the start β€” the session is the unit scopes can enforce. + +--- + ## Common Mistakes - **Splitting one identity across peer IDs** -- If the same user is `alice`, `alice-discord`, and `alice-cursor`, Honcho builds separate representations. Use one stable peer ID when you want unified memory. -- **Too many tiny sessions** -- Summaries and recent messages are session-scoped, and reasoning only fires past ~1,000 tokens per session. Splitting a continuous conversation across many sessions fragments local context and can stall reasoning. Reuse a session when context should flow continuously. +- **Too many tiny sessions** -- Summaries and recent messages are local to one session. Splitting a continuous conversation across many sessions fragments that local context. Reuse a session when context should flow continuously. - **Separating agents that should collaborate** -- If agents need shared product, customer, or team context, put them in the same workspace. Separate workspaces are hard isolation boundaries. - **Leaving `observe_me` on for assistants** -- Wastes reasoning compute on a peer you control. Deterministic behavior doesn't need to be modeled. - **Turning on `observe_others` everywhere** -- Directional representations are powerful, but they add complexity. Use them when peers need distinct perspectives, not just because a session has multiple peers. -- **Forgetting `peer_target` on session context** -- `session.context()` defaults to the active session's summary and recent messages, which are session-scoped. It becomes cross-session only through adding a peer_target which includes the peer representation. +- **A scope per reader** -- Scopes should map to real confidentiality boundaries, not to consumers. If every assistant gets its own scope, you've rebuilt workspace fragmentation inside one workspace, and each projection reasons over a thin slice. Fewer, boundary-shaped scopes; many readers can share one. +- **Treating scopes as access control** -- A scope bounds *recall*, not *access*. Enforce who may query what in your application layer; use scopes to keep the answers themselves from drawing on out-of-bounds sessions. +- **Forgetting `peer_target` on session context** -- `session.context()` defaults to the active session's summary and recent messages, which are local to that session. It becomes cross-session only through adding a peer_target which includes the peer representation. - **Blocking on processing** -- Messages are processed asynchronously in the background. Don't poll or wait for reasoning to complete before continuing your application flow. ## Next Steps @@ -94,6 +113,9 @@ So you can start a session fresh or pull in a peer's long-term memory. [`session Retrieve formatted context from sessions for your LLM + + Bound recall to named sets of sessions + Query Honcho about your peers with natural language diff --git a/docs/v3/documentation/core-concepts/reasoning.mdx b/docs/v3/documentation/core-concepts/reasoning.mdx index aa4de900..cc2add5b 100644 --- a/docs/v3/documentation/core-concepts/reasoning.mdx +++ b/docs/v3/documentation/core-concepts/reasoning.mdx @@ -66,21 +66,11 @@ The reasoning outputs--conclusions, summaries, peer cards--are stored as part of The diagram above shows how agents write messages to Honcho, which triggers reasoning that updates peer representations. Agents can then query representations to get additional context for their next response. -### Token Batching - -Rather than running inference on every individual message, Honcho accumulates messages in the queue and processes them as a batch once the total token count of pending messages for a given peer representation crosses a threshold--roughly **1,000 tokens** at the current batch size. This keeps ingestion costs down, since Honcho charges based on reasoning passes, and ensures each pass has a meaningful amount of context to work with. At ~1,000 tokens the batch comfortably fits in the context window of any modern LLM, so no content is lost. - -If a user sends several short messages in a row (e.g., "yes", "ok", "sounds good"), those messages sit in the queue until enough content has accumulated. Once the threshold is met, the full batch is processed together in a single reasoning call. - - -This batching only applies to **representation** tasks (conclusion extraction). Summary and dream tasks have their own scheduling logic and are not subject to the token threshold. - - ## Balances & Design Choices Off-the-shelf LLMs can perform formal logical reasoning, but they aren't optimized for it. Honcho uses custom models trained specifically for logical rigor (following formal reasoning rules rather than plausible-sounding text), structured output (consistent JSON schema with premises and conclusions), and efficiency (smaller, faster models tuned for this specific task). This allows Honcho to reason more reliably and at lower cost than general-purpose frontier LLMs. -The approach balances quality with practical constraints. Custom models are smaller and cheaper to run, scaffolded conclusions are more token-efficient than raw conversation history, and we batch where appropriate to optimize update frequency. +The approach balances quality with practical constraints. Custom models are smaller and cheaper to run, and scaffolded conclusions are more token-efficient than raw conversation history. Honcho's reasoning capabilities are actively being improved. Current areas of development include enhanced inductive and abductive reasoning, multi-hop and temporal reasoning, and expanded file types and modalities. The system is designed to be extensible--new reasoning capabilities can be added without breaking existing functionality. diff --git a/docs/v3/documentation/features/advanced/representation-scopes.mdx b/docs/v3/documentation/features/advanced/directional-representations.mdx similarity index 95% rename from docs/v3/documentation/features/advanced/representation-scopes.mdx rename to docs/v3/documentation/features/advanced/directional-representations.mdx index 30e2ae32..7a0c9562 100644 --- a/docs/v3/documentation/features/advanced/representation-scopes.mdx +++ b/docs/v3/documentation/features/advanced/directional-representations.mdx @@ -1,6 +1,6 @@ --- -title: 'Representation Scopes' -description: 'Advanced configuration and querying for representations' +title: 'Directional Representations' +description: 'How peers build and query representations of other peers' icon: 'circle' --- @@ -214,7 +214,7 @@ Most applications don't need directional representations. Start with the default Under the hood, Honcho stores representations as (observer, observed) pairs in internal collections: - **Collection**: A unique (observer, observed, workspace) tuple containing documents -- **Documents**: Individual conclusions and artifacts (deductive, inductive, abductive conclusions, summaries, peer cards) with session scoping +- **Documents**: Individual conclusions and artifacts (deductive, inductive, abductive conclusions, summaries, peer cards) with per-session filtering When you retrieve with `target`, Honcho fetches documents from the specific (observer, observed) collection. When you retrieve without `target`, it fetches from the (peer, peer) collectionβ€”the peer's self-representation. @@ -225,7 +225,7 @@ This architecture enables: ## Semantic Search Parameters -Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session β€” pass `session` to scope to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to scope to a set of sessions: +Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session β€” pass `session` to restrict to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to restrict to a set of sessions: | Parameter | Type | Description | |-----------|------|-------------| @@ -265,7 +265,7 @@ Directional representations update automatically through the reasoning pipeline 2. The message sender has `observe_me=true` (or session-level equivalent) 3. Other peers in the session have `observe_others=true` -The pipeline respects scopingβ€”Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant. +The pipeline respects these boundariesβ€”Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant. ### Peer Join Order Matters diff --git a/docs/v3/documentation/features/advanced/overview.mdx b/docs/v3/documentation/features/advanced/overview.mdx index 09f92fea..d0d7cc2c 100644 --- a/docs/v3/documentation/features/advanced/overview.mdx +++ b/docs/v3/documentation/features/advanced/overview.mdx @@ -12,7 +12,8 @@ Advanced features give you fine-grained control over Honcho's behavior and imple - [Configuration](/v3/documentation/features/advanced/reasoning-configuration) - Configure reasoning models and behavior - [Summarizer](/v3/documentation/features/advanced/summarizer) - Automatic session summarization - [Peer Card](/v3/documentation/features/advanced/peer-card) - Quick-reference profile of stable biographical facts about a peer -- [Representation Scopes](/v3/documentation/features/advanced/representation-scopes) - Directional representations for multi-peer scenarios +- [Directional Representations](/v3/documentation/features/advanced/directional-representations) - How peers build separate representations of each other +- [Scopes](/v3/documentation/features/advanced/scopes) - Named sets of sessions that act as visibility boundaries for recall - [Dreaming](/v3/documentation/features/advanced/dreaming) - Autonomous memory consolidation and self-improvement - [Queue Status](/v3/documentation/features/advanced/queue-status) - Monitor background processing and reasoning tasks diff --git a/docs/v3/documentation/features/advanced/peer-card.mdx b/docs/v3/documentation/features/advanced/peer-card.mdx index fbc08772..5db6a1db 100644 --- a/docs/v3/documentation/features/advanced/peer-card.mdx +++ b/docs/v3/documentation/features/advanced/peer-card.mdx @@ -79,7 +79,7 @@ console.log(card); ## Directional Peer Cards -Peer cards follow the same observer-observed model as [representations](/v3/documentation/features/advanced/representation-scopes). When `observe_others` is enabled, a peer can have a **different** card for each peer it observes. +Peer cards follow the same observer-observed model as [representations](/v3/documentation/features/advanced/directional-representations). When `observe_others` is enabled, a peer can have a **different** card for each peer it observes. For example, if Alice and Bob are in a session together and Alice has `observe_others: true`, Alice will build her own peer card for Bob--separate from Honcho's peer card for Bob. You can read and write these directional cards using the `target` parameter. diff --git a/docs/v3/documentation/features/advanced/queue-status.mdx b/docs/v3/documentation/features/advanced/queue-status.mdx index 7c4d363c..809c2e9f 100644 --- a/docs/v3/documentation/features/advanced/queue-status.mdx +++ b/docs/v3/documentation/features/advanced/queue-status.mdx @@ -8,9 +8,9 @@ Whenever messages are stored in Honcho, background processes kick off to [reason Reasoning is an asynchronous process and will not immediately generate insights for the latest message you've sent. This is -by design: we want to reason efficiently over batches of messages -rather than assessing each message in a vacuum. Honcho provides -several utilities to check the status of the queue. +by design: Honcho reasons in the background rather than on the +write path. Honcho provides several utilities to check the status +of the queue. ```python Python @@ -95,7 +95,7 @@ not the total number of items ever processed. The `queue_status` method can take additional -parameters to scope the status to a specific work unit: +parameters to filter the status by a matching observer, sender, or session: ```python Python diff --git a/docs/v3/documentation/features/advanced/reasoning-configuration.mdx b/docs/v3/documentation/features/advanced/reasoning-configuration.mdx index 8642254a..492dd4b9 100644 --- a/docs/v3/documentation/features/advanced/reasoning-configuration.mdx +++ b/docs/v3/documentation/features/advanced/reasoning-configuration.mdx @@ -157,7 +157,7 @@ You may therefore disable observation of a peer by setting the `observe_me` flag If the peer has a session-level configuration, it will override this configuration. If the flag is not set, or is set to `true`, the peer will be observed. -For session-level observation controls and local representations (where peers build separate models of each other), see [Representation Scopes](/v3/documentation/features/advanced/representation-scopes). +For session-level observation controls and local representations (where peers build separate models of each other), see [Directional Representations](/v3/documentation/features/advanced/directional-representations). diff --git a/docs/v3/documentation/features/advanced/scopes.mdx b/docs/v3/documentation/features/advanced/scopes.mdx new file mode 100644 index 00000000..4fa827fd --- /dev/null +++ b/docs/v3/documentation/features/advanced/scopes.mdx @@ -0,0 +1,355 @@ +--- +title: 'Scopes' +description: 'Named sets of sessions that act as visibility boundaries for recall' +icon: 'shield-halved' +--- + +A **scope** is a named set of sessions that acts as a visibility boundary. Recall +performed through a scope sees only what happened in that scope's sessions, +while the peer keeps its single unified representation of everything it has ever +participated in. + +Use scopes when one peer's history spans contexts that must not leak into each +other β€” a therapy app where the clinical sessions must not inform the billing +assistant, a support product where a reseller's agent may only answer from its +own tickets, a multi-tenant deployment where one human works across tenants. + +## Projection, Not Partition + +The peer keeps one representation. A scope is a **projection** of it: a view +built only from evidence in the member sessions. + +```mermaid +graph TB + P[Peer: user-123
one unified representation] + + P --> S1[session: therapy-1] + P --> S2[session: therapy-2] + P --> S3[session: billing-1] + P --> S4[session: onboarding-1] + + SC1[scope: therapy] -.->|projects| S1 + SC1 -.->|projects| S2 + SC2[scope: billing] -.->|projects| S3 + + style P fill:#B6DBFF,stroke:#333,color:#000 + style S1 fill:#B6DBFF,stroke:#333,color:#000 + style S2 fill:#B6DBFF,stroke:#333,color:#000 + style S3 fill:#B6DBFF,stroke:#333,color:#000 + style S4 fill:#B6DBFF,stroke:#333,color:#000 + style SC1 fill:#FFE0B2,stroke:#333,color:#000 + style SC2 fill:#FFE0B2,stroke:#333,color:#000 +``` + +- **Sessions can belong to more than one scope.** Membership is many-to-many. +- **Sessions can belong to no scope.** `onboarding-1` above is reachable + by an unscoped request and by nothing else. +- **An unscoped request still sees everything.** A scope constrains the requests + that name it; it does not hide the sessions from requests that don't. + + +Scopes are a recall boundary, not an authorization boundary. Who may call the +API is still governed by workspace, session, and peer keys. + + +## The Two Arms + +There are two ways to confine recall, and they behave differently. Picking the +wrong one is the most common mistake with this feature. + +| | `scope="therapy"` (named scope) | `sessions=[...]` / `scope=["a","b"]` (allowlist) | +|---|---|---| +| **Mechanism** | Reads the scope's own representation of the peer | Restricts the peer's own representation to a set of sessions | +| **Conclusions** | All levels β€” `explicit`, plus `deductive` / `inductive` reasoned **within** the scope | `explicit` only | +| **Reasoning chains** | Available | Unavailable | +| **Setup required** | Yes β€” create the scope, add sessions, wait for backfill | None β€” pass session IDs ad hoc | +| **Accepts** | One scope name | A list of up to 100 scope names, or up to 1,000 session IDs | + +### Named scope: depth + +Passing a **single** scope name swaps the observer. Recall runs against the +scope's own view of the target peer, which the deriver and dreamer have been +building from the scope's member sessions all along. That view contains +higher-order inferences β€” but only ones reasoned from evidence inside the scope. + +```python +answer = user.chat("What is stressing them out?", scope="therapy") +``` + +This is the arm you want for a durable, meaningful boundary. + +### Allowlist: breadth + +Passing a **list** of scopes, or a bare list of session IDs, keeps the peer as +the observer and restricts recall to the union of those sessions. Because a +dream-derived conclusion is synthesized across sessions, it cannot be attributed +to any one of them β€” so this arm recalls `explicit` conclusions only, and answers +from directly-stated facts rather than inference. + +```python +answer = user.chat("What did they say about billing?", sessions=[s1, s2]) +answer = user.chat("What did they say?", scope=["therapy", "intake"]) +``` + +Reach for this when the set of sessions is decided per-request, or when you want +a quick boundary without provisioning a scope. See +[Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) +for the full allowlist rules. + + +A list of scopes is the allowlist arm, not "several named scopes at once". It +gives you the union of their *sessions*, at explicit-only depth β€” it does not +give you the union of their reasoned views. If you need depth, query one scope. + + +## Creating a Scope and Managing Membership + + +```python Python +from honcho import Honcho + +honcho = Honcho(workspace_id="my-app") + +# Get or create β€” idempotent; passing metadata updates the existing scope +therapy = honcho.scope("therapy") + +# Add existing sessions (max 100 per call) +therapy.add_sessions(["therapy-session-1", "therapy-session-2"]) + +# Or attach at session creation β€” the scope is created if it doesn't exist +session = honcho.session("therapy-session-3", scopes=["therapy"]) + +# Inspect +for s in therapy.sessions(): + print(s.id) + +therapy.remove_session("therapy-session-1") + +for scope in honcho.scopes(): + print(scope.id, scope.metadata) +``` + +```typescript TypeScript +import { Honcho } from "@honcho-ai/sdk"; + +const honcho = new Honcho({ workspaceId: "my-app" }); + +// Get or create β€” idempotent; passing metadata updates the existing scope +const therapy = await honcho.scope("therapy"); + +// Add existing sessions (max 100 per call) +await therapy.addSessions(["therapy-session-1", "therapy-session-2"]); + +// Or attach at session creation β€” the scope is created if it doesn't exist +const session = await honcho.session("therapy-session-3", { + scopes: ["therapy"], +}); + +// Inspect +for await (const s of await therapy.sessions()) { + console.log(s.id); +} + +await therapy.removeSession("therapy-session-1"); + +for await (const scope of await honcho.scopes()) { + console.log(scope.id, scope.metadata); +} +``` + +```bash REST +# Get or create (201 created / 200 existing) +curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes" \ + -H "Authorization: Bearer $HONCHO_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"id": "therapy"}' + +# Add sessions +curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions" \ + -H "Authorization: Bearer $HONCHO_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"session_ids": ["therapy-session-1", "therapy-session-2"]}' + +# List membership +curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/list" \ + -H "Authorization: Bearer $HONCHO_API_KEY" + +# Remove one session +curl -X DELETE "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/therapy-session-1" \ + -H "Authorization: Bearer $HONCHO_API_KEY" +``` + + +Scope IDs are unprefixed, must match `^[a-zA-Z0-9_-]+$`, and are at most 506 +characters. Get-or-create is idempotent: if the scope already exists, the same +call returns it, and any `metadata` you pass is written onto it. + + +Every scopes route β€” and every read that passes `scope` β€” requires a +**workspace-level or admin key**. A scope's membership can exceed any single +peer's own session membership, so peer- and session-scoped keys are rejected +with `401`. + + +## Membership Changes Copy, They Don't Re-Derive + +A session added to a scope while empty needs nothing special: messages sent +after the change flow into the scope through the normal deriver fan-out. + +A session that **already has messages** is handled retroactively by a background +job rather than by re-running the LLM over its history: adding it copies the +session's existing `explicit` conclusions into the scope, and removing it +retracts that session's contributions β€” including conclusions derived from them. +Copying rather than re-deriving is why membership changes are cheap and +deterministic β€” and why they are also **asynchronous**. It also means a freshly +backfilled scope starts at explicit depth and accrues deeper reasoning through +subsequent dreams. + +Poll `status()` to tell "the scope hasn't caught up yet" apart from "the scope +has caught up and there is genuinely nothing to recall": + + +```python Python +therapy.add_sessions(["old-session-with-history"]) + +status = therapy.status() +# {"old-session-with-history": {"state": "pending", "updated_at": "..."}} +# β†’ later: {"state": "completed", "docs_copied": 42, "updated_at": "..."} +``` + +```typescript TypeScript +await therapy.addSessions(["old-session-with-history"]); + +const status = await therapy.status(); +// { "old-session-with-history": { state: "pending", updatedAt: "..." } } +``` + +```bash REST +curl "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/status" \ + -H "Authorization: Bearer $HONCHO_API_KEY" +``` + + +`state` is `pending`, `completed`, or `failed`; `docs_copied` appears once a +backfill completes. Only sessions that have had a backfill enqueued appear, so an +empty result means none have β€” not that the scope is empty. + +## Reading Through a Scope + +`scope` is accepted on these surfaces: + +| Surface | Accepts | Notes | +|---------|---------|-------| +| [`peer.chat()`](/v3/documentation/features/chat) | one scope or a list | Confines both conclusion recall and the messages the agent reads | +| `peer.representation()` | one scope or a list | Confines conclusion recall | +| [`session.context()`](/v3/documentation/features/get-context) | one scope only | Perspective source for `peer_target`'s representation and card. Requires `peer_target`; mutually exclusive with `peer_perspective` | +| `honcho.search()` | one scope only | Restricts message search to the scope's member sessions | +| `honcho.chat()` | one scope or a list | Always the allowlist arm β€” even a single name. There is no observer to swap | + + +```python Python +# Chat β€” answered only from the therapy sessions +answer = user.chat("What is stressing them out?", scope="therapy") + +# Representation +rep = user.representation(scope="therapy") + +# Session context, using the scope as the perspective source +ctx = session.context(peer_target="user-123", scope="therapy") + +# Message search, restricted to the scope's sessions +messages = honcho.search("insomnia", scope="therapy") +``` + +```typescript TypeScript +// Chat β€” answered only from the therapy sessions +const answer = await user.chat("What is stressing them out?", { + scope: "therapy", +}); + +// Representation +const rep = await user.representation({ scope: "therapy" }); + +// Session context, using the scope as the perspective source +const ctx = await session.context({ + peerTarget: "user-123", + scope: "therapy", +}); + +// Message search, restricted to the scope's sessions +const messages = await honcho.search("insomnia", { scope: "therapy" }); +``` + +```bash REST +curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/chat" \ + -H "Authorization: Bearer $HONCHO_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "What is stressing them out?", "scope": "therapy"}' +``` + + +### Rules + +`scope` is mutually exclusive with `filters`, `sessions`, and `session` / +`session_id` β€” and on session context, with `peer_perspective` (where it also +requires `peer_target`). Like the session allowlist, it **fails closed**: a +contradiction is rejected with a `422` rather than silently widened, a scope +with no member sessions recalls nothing, and an empty list (`scope=[]`) is +rejected rather than treated as "no boundary". Per-surface caps and error +shapes are in the [API reference](/v3/api-reference/endpoint/scopes/get-or-create-scope). + +## Provenance, Not Topic + +A scope is defined by **where a fact was said**, not what it is about. + +If a user mentions a therapy detail in a billing session, that conclusion is +formed from the billing session and lands in the `billing` scope. Querying +`scope="therapy"` will not find it, and querying `scope="billing"` will. + + +Scopes give you provenance-based privacy, not topic-based privacy. If you need +"no clinical content in the billing assistant's answers" regardless of where it +was said, that is content classification and has to be enforced above Honcho β€” +by controlling what reaches which session in the first place, or by filtering +the answer. + + +Design accordingly: keep the session boundary aligned with the confidentiality +boundary you actually care about, since that session boundary is the one scopes +can enforce. + +## Guardrails + +A few behaviors follow from how scopes are built: + +- **The `scope.` prefix is reserved.** Creating a peer, or adding a peer to a + session, with a `scope.`-prefixed name is rejected. +- **List scopes through the scopes surface.** `honcho.scopes()` / + `POST /scopes/list` returns unprefixed ids. Peer listings hide scopes by + default; `kind="scope"` on `POST /peers/list` returns the backing peers named + `scope.`, and `kind="all"` includes both regular peers and those backing + peers. +- **A scope can't be observed.** No representation is formed *of* a scope, so a + scope is rejected in any `target` / observed position, including as a dream + target. +- **Membership is managed only through the scopes surface.** The session + add-peers, set-peers, and remove-peers routes reject scope names and point you + at `/scopes/{scope_id}/sessions` or the `scopes` field on session create. + +If you want the exact mechanics for scopes, read: [`src/routers/scopes.py`](https://github.com/plastic-labs/honcho/blob/main/src/routers/scopes.py), +[`src/crud/scope.py`](https://github.com/plastic-labs/honcho/blob/main/src/crud/scope.py), +and [`src/deriver/scope_backfill.py`](https://github.com/plastic-labs/honcho/blob/main/src/deriver/scope_backfill.py). + +## Limits + +| Limit | Value | +|-------|-------| +| Scope ID length | 506 characters | +| Scope ID charset | `^[a-zA-Z0-9_-]+$` | +| Sessions per membership call | 100 | +| Scopes in one `scope` read option | 100 | +| Scopes on session create | 100 | +| Sessions in a resolved allowlist | 1,000 | + +Full request and response shapes are in the +[API reference](/v3/api-reference/endpoint/scopes/get-or-create-scope). diff --git a/docs/v3/documentation/features/advanced/search.mdx b/docs/v3/documentation/features/advanced/search.mdx index a95fff15..cf40648c 100644 --- a/docs/v3/documentation/features/advanced/search.mdx +++ b/docs/v3/documentation/features/advanced/search.mdx @@ -49,6 +49,20 @@ import { Honcho } from "@honcho-ai/sdk"; ```
+Pass `scope` on workspace search to restrict matches to that +[scope](/v3/documentation/features/advanced/scopes)'s member sessions. A scope +with no members returns nothing. + + +```python Python +results = honcho.search("budget planning", scope="therapy") +``` + +```typescript TypeScript +const results = await honcho.search("budget planning", { scope: "therapy" }); +``` + + ### Session Search Search within a specific session's conversation history: diff --git a/docs/v3/documentation/features/advanced/using-filters.mdx b/docs/v3/documentation/features/advanced/using-filters.mdx index 4a1eefec..e28096ab 100644 --- a/docs/v3/documentation/features/advanced/using-filters.mdx +++ b/docs/v3/documentation/features/advanced/using-filters.mdx @@ -727,7 +727,7 @@ messages = session.messages(filters={ ### Filtering Conclusions -Conclusions are scoped to an observer/observed peer pair (accessed via +Conclusions belong to an observer/observed peer pair (accessed via `peer.conclusions` for self-conclusions or `peer.conclusions_of(target)` for conclusions about another peer). The observer and observed are filled in automatically by the scope, so the `filters` you pass add to them. @@ -843,7 +843,7 @@ a **session allowlist**, restricting what the request can recall to the sessions you name β€” conclusions on both endpoints, and on chat the messages the agent reads as well. -This is how you scope recall to more than one session. The `session_id` +This is how you restrict recall to more than one session. The `session_id` parameter pins a request to exactly one session; an allowlist accepts a set. Only the `session_id` key is supported here, in three shapes: @@ -875,10 +875,34 @@ curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/representation" \ ```
+Both SDKs expose this as a `sessions` option, which goes on the wire as the +`filters` body above: + + +```python Python +answer = user.chat("What did the user ask about billing?", + sessions=["support-chat-1", "support-chat-2"]) + +rep = user.representation(sessions=["support-chat-1", "support-chat-2"]) +``` + +```typescript TypeScript +const answer = await user.chat("What did the user ask about billing?", { + sessions: ["support-chat-1", "support-chat-2"], +}); + +const rep = await user.representation({ + sessions: ["support-chat-1", "support-chat-2"], +}); +``` + + -The session allowlist is REST-only today. The SDKs cover the single-session case -with `session`, but do not yet expose the allowlist β€” call the endpoint directly -when you need a set of sessions. +If the same set of sessions is a boundary you reuse, name it: a +[scope](/v3/documentation/features/advanced/scopes) is a persistent version of +this allowlist, and querying a single scope recalls at full depth rather than +`explicit`-only. `sessions` is the right tool when the set is decided +per-request. ### Rules @@ -907,7 +931,7 @@ can only narrow. ### What Changes Under an Allowlist -Scoping recall by session narrows what the reasoning agent can draw on: +Restricting recall by session narrows what the reasoning agent can draw on: - **Only `explicit` conclusions are recalled.** Dream-derived conclusions (`deductive`, `inductive`) are synthesized across sessions, so they can't be diff --git a/docs/v3/documentation/features/chat.mdx b/docs/v3/documentation/features/chat.mdx index b6e7e96c..c83a0ac2 100644 --- a/docs/v3/documentation/features/chat.mdx +++ b/docs/v3/documentation/features/chat.mdx @@ -110,11 +110,17 @@ const answer = await peer.chat("What did the user ask about?", { session: sessio ```
-To scope a request to a *set* of sessions, use the session allowlist β€” a +To restrict a request to a *set* of sessions, use the session allowlist β€” a constrained `filters` body on the endpoint. See [Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) for the accepted shapes and for what an allowlist changes about the answer. +Pass `scope="therapy"` to answer from that [scope](/v3/documentation/features/advanced/scopes)'s +own representation of the peer. A list (`scope=["therapy", "intake"]`) is an +allowlist of those scopes' sessions, not named-scope depth. +`honcho.chat(scope=)` is always the allowlist arm, even with one name. Details +are on the [scopes page](/v3/documentation/features/advanced/scopes#the-two-arms). + ## Structured Outputs When your application needs a machine-readable answer instead of prose, pass a schema as `response_format` and the answer is guaranteed to conform to it: diff --git a/docs/v3/documentation/features/get-context.mdx b/docs/v3/documentation/features/get-context.mdx index 60ff45bc..1fa9fec1 100644 --- a/docs/v3/documentation/features/get-context.mdx +++ b/docs/v3/documentation/features/get-context.mdx @@ -99,7 +99,7 @@ context = session.context(summary=False, tokens=2000) ### Peer Representation in Context -You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer. +You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer. Pass `scope` with `peer_target` to use a [named scope](/v3/documentation/features/advanced/scopes) as the perspective source (`scope` is mutually exclusive with `peer_perspective` and requires a workspace-level or admin-level key). ```python Python @@ -119,6 +119,14 @@ context = session.context( peer_target="user-123", peer_perspective="assistant" # From assistant's viewpoint ) + +# Or use a named scope as the perspective source (requires peer_target; +# mutually exclusive with peer_perspective) +context = session.context( + tokens=2000, + peer_target="user-123", + scope="therapy", +) ``` ```typescript TypeScript @@ -139,6 +147,14 @@ context = session.context( peerTarget: "user-123", peerPerspective: "assistant" // From assistant's viewpoint }); + + // Or use a named scope as the perspective source (requires peerTarget; + // mutually exclusive with peerPerspective) + const scopedContext = await session.context({ + tokens: 2000, + peerTarget: "user-123", + scope: "therapy", + }); })(); ``` @@ -211,6 +227,7 @@ context = session.context( | `tokens` | `int` | Maximum tokens to include | | `peer_target` | `str` | Peer ID to include representation for | | `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) | +| `scope` | `str` | Named scope as the perspective source for `peer_target`'s representation and card. Requires `peer_target` and a workspace-level or admin-level key; mutually exclusive with `peer_perspective`. See [Scopes](/v3/documentation/features/advanced/scopes) | | `search_query` | `str` | Query for semantic search (requires peer_target) | | `limit_to_session` | `bool` | Limit to session conclusions only | | `search_top_k` | `int` | Semantic search results to include (1-100) | diff --git a/docs/v3/documentation/introduction/overview.mdx b/docs/v3/documentation/introduction/overview.mdx index 29cc2815..bc4ebd9f 100644 --- a/docs/v3/documentation/introduction/overview.mdx +++ b/docs/v3/documentation/introduction/overview.mdx @@ -94,6 +94,9 @@ Welcome to Honcho. We're excited to have you at the frontier of AI with us 🫑. Build your first stateful agent in minutes + + Inspect a deployment, or `honcho start --setup` a local stack + Deep dive into how Honcho's primitives fit together diff --git a/docs/v3/documentation/introduction/quickstart.mdx b/docs/v3/documentation/introduction/quickstart.mdx index 85884a67..d3bbe01b 100644 --- a/docs/v3/documentation/introduction/quickstart.mdx +++ b/docs/v3/documentation/introduction/quickstart.mdx @@ -11,9 +11,11 @@ Let's get started with Honcho. In this quickstart, you will: - Query the reasoning Honcho produces to get synthesized insights about the user -Running the code below requires an API key. Create and account and get your API key at [app.honcho.dev](https://app.honcho.dev) under "API KEYS". +Running the code below requires an API key. Create an account and get your API key at [app.honcho.dev](https://app.honcho.dev) under "API KEYS". Every new tenant gets \$100.00 in free credits on sign up. The code below costs ~\$0.04 to run, so don't worry--still plenty of free credits for iterating. + +To run against a local stack instead, install the CLI (`uv tool install honcho-cli`) and then run `honcho start --setup` (Docker + an LLM provider key). See the [CLI reference](/v3/documentation/reference/cli#local-stack). #### 1. Install the SDK diff --git a/docs/v3/documentation/introduction/vibecoding.mdx b/docs/v3/documentation/introduction/vibecoding.mdx index cd5e8ac6..be2a7362 100644 --- a/docs/v3/documentation/introduction/vibecoding.mdx +++ b/docs/v3/documentation/introduction/vibecoding.mdx @@ -68,16 +68,19 @@ claude mcp add honcho \ ## CLI -Inspect and debug a running Honcho deployment from your terminal. The honcho CLI wraps the Python SDK with agent-friendly defaults β€” JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions). +Inspect and debug a running Honcho deployment from your terminal, or run a personal local stack. The honcho CLI wraps the Python SDK with agent-friendly defaults β€” JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions). **Get started:** ```bash uv tool install honcho-cli -honcho init # configure apiKey + environmentUrl -honcho doctor # verify connectivity +honcho init # Honcho API key / browser login (talk *to* a server) +honcho start --setup basic # local stack: LLM provider key + Docker +honcho doctor # verify connectivity ``` +`honcho start --setup` pulls the published GHCR image β€” no clone required. It does not rewrite `environmentUrl` in the shared config file; prefix commands with `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local. + The CLI also ships an agent skill. Install it with `npx skills add plastic-labs/honcho` and pick `honcho-cli` from the list. See the [full CLI reference](/v3/documentation/reference/cli) for all commands, flags, and environment variables. @@ -147,7 +150,7 @@ Invoke with `/honcho-integration` in your coding agent. #### honcho-cli -**For inspection & debugging.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) β€” peer memory, session context, queue status, dialectic quality. +**For inspection & debugging, and for running a local stack.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) β€” peer memory, session context, queue status, dialectic quality, `honcho start` / `status` / `stop`. Invoke implicitly when you ask your agent to inspect a Honcho deployment. @@ -170,7 +173,7 @@ I want to start building with Honcho - an open source memory library for buildin - Core repo: https://github.com/plastic-labs/honcho - Python SDK: https://github.com/plastic-labs/honcho-python - TypeScript SDK: https://github.com/plastic-labs/honcho-node -- CLI (inspect & debug a deployment): https://github.com/plastic-labs/honcho/tree/main/honcho-cli +- CLI (inspect, debug, or `honcho start` a local stack): https://github.com/plastic-labs/honcho/tree/main/honcho-cli - Discord bot starter: https://github.com/plastic-labs/discord-python-starter - Telegram bot example: https://github.com/plastic-labs/telegram-python-starter diff --git a/docs/v3/documentation/reference/cli.mdx b/docs/v3/documentation/reference/cli.mdx index 2b460d48..74b5fb34 100644 --- a/docs/v3/documentation/reference/cli.mdx +++ b/docs/v3/documentation/reference/cli.mdx @@ -18,14 +18,45 @@ uvx honcho-cli ``` +This only installs the `honcho` command. It does not start a server. Use `honcho start --setup` (Docker + an LLM provider key) when you want a local stack. + ## Quick Start ```bash -honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json -honcho doctor # verify your config + connectivity -honcho # show banner + command list +honcho init # Honcho API key or browser login + server URL (talk *to* Honcho) +honcho start --setup basic # local stack: LLM provider key + Docker (not set by init) +honcho doctor # verify your config + connectivity +honcho # show banner + command list ``` +`honcho init` authenticates the CLI against a Honcho server. It does **not** configure the LLM key a local stack needs β€” that is `honcho start --setup`. + +## Local stack + +`honcho start --setup basic` is the fastest way to run Honcho on your machine. It does **not** require cloning the Honcho repo. The wizard prompts for an LLM provider and API key, writes them into the profile `.env`, pulls `ghcr.io/plastic-labs/honcho:latest`, **pins that digest**, and starts API + deriver + Postgres + Redis via Docker. + +Default profile is `local` (`--profile` / `HONCHO_PROFILE`). First start copies the image `config.toml.example` into the profile directory; later starts leave that file alone so your edits persist β€” including when you re-pin the image. Delete `config.toml` yourself if you want a fresh copy from a new image. Pass `--image` to pin a different tag or digest. Ports bind to `127.0.0.1`; if 8000/5432/6379 are taken, the CLI remaps them (or pass `--api-port` / `--db-port` / `--redis-port`). Auth is off (`AUTH_USE_AUTH=false`). + +Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (environment variables win over `config.toml`). This is TTY-only. `basic` covers provider and chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and deriver flush. Re-running `--setup` while the stack is up recreates the API and deriver containers. + +This does **not** change `environmentUrl` in the shared config file. To talk to the local stack: + +```bash +HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list +honcho init --base-url http://127.0.0.1:8000 # persist local as the CLI default +``` + +```bash +honcho start --setup basic +honcho start --setup advanced +LLM_OPENAI_API_KEY=sk-... honcho start # skip the wizard if the key is already in the env +honcho status +honcho stop # keep data +honcho stop --wipe # also delete volumes +``` + +To **develop the server** (live reload, from-source image), see [Local Environment Setup](/v3/contributing/self-hosting). + ## Configuration The CLI resolves config in this order: **flag β†’ env var β†’ config file β†’ default**. @@ -38,12 +69,17 @@ The CLI resolves config in this order: **flag β†’ env var β†’ config file β†’ de | Peer | β€” | `HONCHO_PEER_ID` | `-p` / `--peer` | No | | Session | β€” | `HONCHO_SESSION_ID` | `-s` / `--session` | No | | JSON output | β€” | `HONCHO_JSON` | `--json` | No | +| Update nag | β€” | `HONCHO_NO_UPDATE_CHECK` | β€” | No | +| Local stack | β€” | `HONCHO_PROFILE` | `--profile` | No | ### Persisted config -The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns only +The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns `apiKey` and `environmentUrl` at the top level β€” everything else (`hosts`, `sessions`, etc.) is written by other tools and left untouched on save. +On managed servers that advertise the device grant in OAuth metadata, +`honcho init` can log you in via the browser; tokens auto-refresh +and are stored under `oauth` without deleting a shared `apiKey`. ```json { @@ -53,14 +89,14 @@ The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns only } ``` -Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` +Per-command targeting (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars. **Not** persisted as CLI defaults. This is deliberate: every invocation is explicit about what it operates on. ### Runtime overrides -Workspace, peer, and session scoping are **per-command only** β€” pass flags or +Workspace, peer, and session targeting are **per-command only** β€” pass flags or `HONCHO_*` env vars on every invocation. ```bash @@ -89,6 +125,8 @@ Every command adapts its output to the context: - **Piped or redirected** β€” JSON automatically (detected via `isatty`). - **`--json` flag / `HONCHO_JSON=1`** β€” force JSON regardless of terminal. +Interactive sessions may print a one-line upgrade hint on stderr at most once a day when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). JSON/piped output skips it; set `HONCHO_NO_UPDATE_CHECK=1` to disable it. + Collection commands emit JSON arrays; single-resource commands emit JSON objects. Errors are always structured: ```json diff --git a/docs/v3/guides/community/pi-honcho-memory.mdx b/docs/v3/guides/community/pi-honcho-memory.mdx index 7974a94b..6ef1c5e0 100644 --- a/docs/v3/guides/community/pi-honcho-memory.mdx +++ b/docs/v3/guides/community/pi-honcho-memory.mdx @@ -23,7 +23,7 @@ The Honcho plugin is a community integration. See the [plugin README](https://gi ## How It Works -The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session scoping is configurable β€” memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally. +The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session mapping is configurable β€” memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally. ## Next Steps diff --git a/docs/v3/guides/integrations/hermes.mdx b/docs/v3/guides/integrations/hermes.mdx index 9fe46973..d06f650a 100644 --- a/docs/v3/guides/integrations/hermes.mdx +++ b/docs/v3/guides/integrations/hermes.mdx @@ -67,10 +67,6 @@ Or manually create/edit the config file (checked in order: `$HERMES_HOME/honcho. For the full list of config fields (`recallMode`, `writeFrequency`, `sessionStrategy`, `dialecticReasoningLevel`, etc.), see the [Hermes memory provider docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers#honcho). - -**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers and Hermes Agent integration. - - ## Verifying the integration ### 1. Check status diff --git a/docs/v3/guides/integrations/paperclip.mdx b/docs/v3/guides/integrations/paperclip.mdx index 3b2aa156..bbda60f3 100644 --- a/docs/v3/guides/integrations/paperclip.mdx +++ b/docs/v3/guides/integrations/paperclip.mdx @@ -61,11 +61,11 @@ In practice, that means agent peers can both be observed by Honcho and form repr ## How It Works -### Identity And Scope +### Identity And Mapping The integration breaks down into four parts: -- **Identity and scope** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions. +- **Identity and mapping** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions. - **What gets copied into Honcho** - issue comments and document revisions sync into Honcho, with document content sectioned and normalized message content capped before ingestion. - **What operators get** - operators get a plugin settings page, migration preview/status data, including a per-issue migration mapping preview, repair tools, and an issue-level `Memory` tab. - **What agents get** - agents get Honcho retrieval and peer-chat tools inside Paperclip. @@ -130,7 +130,7 @@ The plugin registers the following Honcho tools for Paperclip agents: Review how workspaces, peers, and sessions fit together.
- + Review how `observe_me` and `observe_others` change what peers can model.
diff --git a/docs/v3/guides/recipes/unified-memory-setup.mdx b/docs/v3/guides/recipes/unified-memory-setup.mdx index ea921cc3..14ad61a8 100644 --- a/docs/v3/guides/recipes/unified-memory-setup.mdx +++ b/docs/v3/guides/recipes/unified-memory-setup.mdx @@ -109,8 +109,9 @@ and `aiPeer` there. See the [Hermes guide](/v3/guides/integrations/hermes) for t A scheduled job feeds external data (emails, meeting notes, CRM records) into Honcho. Attribute the messages to the peer the data is *about* β€” not to an agent β€” and group -them into a session. **How you scope that session is the main decision here**, because -it controls when Honcho reasons over the data (more on that below). +them into a session. Match the session to how you want that import's local context +to accumulate: a per-run session like `email-import-{date}`, or one ongoing +per-source session like `email-import-gmail`. ```python from datetime import datetime, timezone @@ -131,18 +132,6 @@ for i in range(0, len(messages), 100): session.add_messages(messages[i:i + 100]) ``` -Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*, -with a default age-based flush for quiet tails -([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Scope the -session to the volume you ingest: - -- **High-volume runs** (a day of emails, a CRM export) clear the threshold easily β€” a - per-run session like `email-import-{date}` is fine. -- **Low-volume or trickle imports** (a few short records at a time) should append to - one **ongoing per-source session** (e.g. `email-import-gmail`), so content - accumulates across runs instead of fragmenting into thin sessions that each flush - later with little context. - The [Gmail](/v3/guides/gmail) and [Granola](/v3/guides/granola) guides are related import examples. diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index 56fafac4..da2bc22c 100644 --- a/docs/v3/openapi.json +++ b/docs/v3/openapi.json @@ -9,7 +9,7 @@ "url": "https://honcho.dev/", "email": "hello@plasticlabs.ai" }, - "version": "3.0.12" + "version": "3.1.0" }, "servers": [ { @@ -1574,7 +1574,7 @@ "get": { "tags": ["sessions"], "summary": "Get Peer Config", - "description": "Get the configuration for a Peer in a Session.\n\nMember-read lets a peer-scoped key reach this route, but a peer may only\nread its own per-session config β€” not a co-member's. Workspace/admin and\nsession-scoped tokens (which already span the whole session) are unaffected.", + "description": "Get the configuration for a Peer in a Session.\n\nMember-read lets a peer-scoped key reach this route, but a peer may only\nread its own per-session config \u2014 not a co-member's. Workspace/admin and\nsession-scoped tokens (which already span the whole session) are unaffected.", "operationId": "get_peer_config_v3_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_get", "security": [{ "HTTPBearer": [] }], "parameters": [ @@ -2234,6 +2234,343 @@ } } }, + "/v3/workspaces/{workspace_id}/scopes": { + "post": { + "tags": ["scopes"], + "summary": "Get Or Create Scope", + "description": "Get a Scope by ID or create a new Scope with the given ID.\n\nReturns 201 when the scope is created and 200 when it already exists.\nA pre-existing peer occupying the scope's reserved internal name is never\nadopted; that conflict returns 409.", + "operationId": "get_or_create_scope_v3_workspaces__workspace_id__scopes_post", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScopeCreate", + "description": "Scope creation parameters" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Scope" } + } + } + }, + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Scope" } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/list": { + "post": { + "tags": ["scopes"], + "summary": "Get Scopes", + "description": "Get all Scopes for a Workspace. Results are paginated.", + "operationId": "get_scopes_v3_workspaces__workspace_id__scopes_list_post", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "reverse", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Whether to reverse the order of results", + "default": false, + "title": "Reverse" + }, + "description": "Whether to reverse the order of results" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Page_Scope_" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}": { + "get": { + "tags": ["scopes"], + "summary": "Get Scope", + "description": "Get a single Scope by ID.", + "operationId": "get_scope_v3_workspaces__workspace_id__scopes__scope_id__get", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Scope" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions": { + "post": { + "tags": ["scopes"], + "summary": "Add Sessions To Scope", + "description": "Add Sessions to a Scope.\n\nAll named sessions must already exist (404 otherwise). Adding a session that\nis already a member is a no-op. List the resulting membership with\n`POST /scopes/{scope_id}/sessions/list`.\n\nNote: any added session that already has messages triggers an asynchronous\nbackfill-by-copy of its existing documents into the scope; track progress\nvia ``GET /scopes/{scope_id}/status``.", + "operationId": "add_sessions_to_scope_v3_workspaces__workspace_id__scopes__scope_id__sessions_post", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScopeSessionsAdd", + "description": "IDs of the sessions to add to the scope" + } + } + } + }, + "responses": { + "204": { "description": "Successful Response" }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}": { + "delete": { + "tags": ["scopes"], + "summary": "Remove Session From Scope", + "description": "Remove a Session from a Scope.\n\nNote: documents copied/derived while the session was a member are\nreconciled asynchronously \u2014 the session's explicit copies are soft-deleted\nfrom the scope, dependent derived documents follow (fail-closed), and the\nscope's card is rebuilt from the remaining evidence.", + "operationId": "remove_session_from_scope_v3_workspaces__workspace_id__scopes__scope_id__sessions__session_id__delete", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + }, + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Session Id" } + } + ], + "responses": { + "204": { "description": "Successful Response" }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list": { + "post": { + "tags": ["scopes"], + "summary": "Get Scope Sessions", + "description": "Get the Sessions that are members of a Scope, paginated.\n\nOrdered by how long each session has been a member: longest-standing member\nfirst, or most recently added first when `reverse` is true.", + "operationId": "get_scope_sessions_v3_workspaces__workspace_id__scopes__scope_id__sessions_list_post", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + }, + { + "name": "reverse", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Whether to reverse the order of results", + "default": false, + "title": "Reverse" + }, + "description": "Whether to reverse the order of results" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Page_Session_" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, + "/v3/workspaces/{workspace_id}/scopes/{scope_id}/status": { + "get": { + "tags": ["scopes"], + "summary": "Get Scope Status", + "description": "Get the backfill/reconciliation job status for a Scope.\n\nReturns a per-session map of the backfill job state (pending / completed /\nfailed) with the number of documents copied once complete. Empty when no\nbackfill has ever been enqueued for the scope.", + "operationId": "get_scope_status_v3_workspaces__workspace_id__scopes__scope_id__status_get", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Workspace Id" } + }, + { + "name": "scope_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "Scope Id" } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ScopeStatus" } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, "/v3/workspaces/{workspace_id}/conclusions": { "post": { "tags": ["conclusions"], @@ -2970,6 +3307,20 @@ "title": "Filters", "description": "Optional filters to scope recall. This endpoint supports only the 'session_id' key: a session id, a list of session ids, or {\"in\": [...]}. Recall (conclusions and messages) is restricted to the allowlist; unsupported keys are rejected. When session_id is also set, it must be included in the allowlist." }, + "scope": { + "anyOf": [ + { "type": "string" }, + { + "items": { "type": "string" }, + "type": "array", + "maxItems": 100, + "minItems": 1 + }, + { "type": "null" } + ], + "title": "Scope", + "description": "Optional (unprefixed) scope name(s) to confine recall. A single scope answers from the scope's own representation of the target peer: conclusion recall is confined to what the scope observed and message recall to the scope's member sessions. A list of scopes restricts recall to the union of the scopes' member sessions (explicit allowlist, fail-closed: an empty union recalls nothing). Mutually exclusive with `filters` and `session_id`. Requires a workspace- or admin-level key." + }, "target": { "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Target", @@ -3227,6 +3578,22 @@ "required": ["items", "total", "page", "size", "pages"], "title": "Page[Peer]" }, + "Page_Scope_": { + "properties": { + "items": { + "items": { "$ref": "#/components/schemas/Scope" }, + "type": "array", + "title": "Items" + }, + "total": { "type": "integer", "minimum": 0.0, "title": "Total" }, + "page": { "type": "integer", "minimum": 1.0, "title": "Page" }, + "size": { "type": "integer", "minimum": 1.0, "title": "Size" }, + "pages": { "type": "integer", "minimum": 0.0, "title": "Pages" } + }, + "type": "object", + "required": ["items", "total", "page", "size", "pages"], + "title": "Page[Scope]" + }, "Page_Session_": { "properties": { "items": { @@ -3409,6 +3776,14 @@ { "type": "null" } ], "title": "Filters" + }, + "kind": { + "anyOf": [ + { "type": "string", "enum": ["scope", "all"] }, + { "type": "null" } + ], + "title": "Kind", + "description": "Which kinds of peers to list. Omitted (default): regular peers only (scope peers are excluded). 'scope': scope peers only. 'all': every peer." } }, "type": "object", @@ -3429,6 +3804,20 @@ "title": "Filters", "description": "Optional filters to scope the representation. This endpoint supports only the 'session_id' key: a session id, a list of session ids, or {\"in\": [...]}. When session_id is also set, it must be included in the allowlist." }, + "scope": { + "anyOf": [ + { "type": "string" }, + { + "items": { "type": "string" }, + "type": "array", + "maxItems": 100, + "minItems": 1 + }, + { "type": "null" } + ], + "title": "Scope", + "description": "Optional (unprefixed) scope name(s) to confine the representation. A single scope reads the scope's own representation of the target peer, formed only from the scope's member sessions. A list of scopes restricts the representation to conclusions from the union of the scopes' member sessions (explicit allowlist, fail-closed: an empty union yields an empty representation). Mutually exclusive with `filters` and `session_id`. Requires a workspace- or admin-level key." + }, "target": { "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Target", @@ -3595,6 +3984,72 @@ "required": ["observer", "dream_type"], "title": "ScheduleDreamRequest" }, + "Scope": { + "properties": { + "id": { "type": "string", "title": "Id" }, + "metadata": { + "additionalProperties": true, + "type": "object", + "title": "Metadata" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": ["id", "created_at"], + "title": "Scope", + "description": "Scope response \u2014 external view of the peer backing a scope.\n\nThe ``id`` is the unprefixed scope name; the reserved peer-name prefix is\nan internal implementation detail and never surfaces here." + }, + "ScopeCreate": { + "properties": { + "id": { "type": "string", "minLength": 1, "title": "Id" }, + "metadata": { + "anyOf": [ + { "additionalProperties": true, "type": "object" }, + { "type": "null" } + ], + "title": "Metadata" + } + }, + "type": "object", + "required": ["id"], + "title": "ScopeCreate", + "description": "Schema for creating (or getting) a scope by its unprefixed name." + }, + "ScopeSessionsAdd": { + "properties": { + "session_ids": { + "items": { "type": "string" }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Session Ids", + "description": "IDs of existing sessions to add to the scope" + } + }, + "type": "object", + "required": ["session_ids"], + "title": "ScopeSessionsAdd", + "description": "Schema for adding sessions to a scope." + }, + "ScopeStatus": { + "properties": { + "backfill_status": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "type": "object", + "title": "Backfill Status" + } + }, + "type": "object", + "title": "ScopeStatus", + "description": "Per-session backfill/reconciliation job status for a scope.\n\n``backfill_status`` maps each session that has had a backfill enqueued to\nits current job state: ``{state, updated_at[, docs_copied]}`` where\n``state`` is ``pending``/``completed``/``failed`` and ``docs_copied`` is\npresent once a backfill completes." + }, "Session": { "properties": { "id": { "type": "string", "title": "Id" }, @@ -3722,6 +4177,18 @@ { "$ref": "#/components/schemas/SessionConfiguration" }, { "type": "null" } ] + }, + "scopes": { + "anyOf": [ + { + "items": { "type": "string" }, + "type": "array", + "maxItems": 100 + }, + { "type": "null" } + ], + "title": "Scopes", + "description": "Optional list of (unprefixed) scope names to add this session to. Each scope is created if it does not exist yet. If the session already has messages, its existing documents are backfilled into the scope asynchronously." } }, "type": "object", diff --git a/honcho-cli/CHANGELOG.md b/honcho-cli/CHANGELOG.md index 7973341a..80b7e5ef 100644 --- a/honcho-cli/CHANGELOG.md +++ b/honcho-cli/CHANGELOG.md @@ -7,13 +7,27 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [0.1.4] - 2026-08-26 + ### Added -- `honcho session view` β€” session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session +- A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK` ### Fixed -- `honcho message list --last N` no longer stops at the first page of 50 β€” it walks pages to fill the requested window +- `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` (#1068) +- `--setup` API key prompts echo `*` per character so a paste is visibly received instead of a blank getpass field + +## [0.1.3] - 2026-08-25 + +### Added + +- `honcho start`, `honcho stop`, and `honcho status` β€” run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029) +- `honcho session view` β€” session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006) + +### Fixed + +- `honcho message list --last N` no longer stops at the first page of 50 β€” it walks pages to fill the requested window (#1006) ## [0.1.2] - 2026-07-20 diff --git a/honcho-cli/README.md b/honcho-cli/README.md index b191bcdb..0a01e647 100644 --- a/honcho-cli/README.md +++ b/honcho-cli/README.md @@ -22,22 +22,50 @@ uv tool install honcho-cli ## Quick Start ```bash -honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json -honcho doctor # verify your config + connectivity -honcho # show banner + command list +honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json +honcho start --setup basic # local stack: LLM provider key + Docker +honcho doctor # verify config + connectivity +honcho # show banner + command list ``` -`honcho init` reads `apiKey` and `environmentUrl` from the top-level of `~/.honcho/config.json` (the same file other Honcho tools β€” plugins, host integrations β€” share). If both are present, it confirms them with you; if either is missing (or you decline), it prompts for the missing value(s) and writes them back. Host-specific entries under `hosts` are left untouched. +`honcho init` writes `apiKey` and `environmentUrl` to the top-level of `~/.honcho/config.json` (the same file other Honcho tools β€” plugins, host integrations β€” share) so the CLI can call a Honcho server. If both are present, it confirms them with you; if either is missing (or you decline), it prompts and writes them back. Host-specific entries under `hosts` are left untouched. It does **not** set the LLM provider key the local deriver needs β€” that is `honcho start --setup` (or `LLM_*_API_KEY` in the environment). Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars β€” not persisted as CLI defaults. +### Local stack + +`honcho start --setup basic` runs a personal Honcho server on your machine (API, deriver, Postgres, Redis) via Docker. The wizard writes the LLM provider key into the profile `.env` β€” `honcho init` cannot do this; its `apiKey` is for calling a Honcho server, not for deriver/dialectic inference. You can also pass `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY` in the environment and skip `--setup`. Stack files live under `~/.honcho/profiles/local/` and are not committed to a project. + +On first start, the CLI pulls `ghcr.io/plastic-labs/honcho:latest` and **pins that digest** in `profile.json`, then copies the image's `config.toml.example` to `config.toml` in the same directory. `honcho start` never overwrites `config.toml` after that β€” including when you re-pin the image. Delete the file yourself if you want a fresh copy from a new image. + +Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (env wins over `config.toml`). TTY only; re-runnable. `basic` asks provider + chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and snappy deriver flush. Everything else stays in `config.toml`. + +`honcho start` does **not** change `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). To talk to the local stack for one command: + +```bash +HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list +``` + +To make local the default, run `honcho init --base-url http://127.0.0.1:8000`. + +```bash +honcho start --setup basic +honcho start --setup advanced +honcho status +honcho stop # keep data +honcho stop --wipe # also delete volumes +``` + ## Commands ### Onboarding | Command | Description | |---------|-------------| -| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` | +| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json`. | +| `honcho start` | Start a local Honcho stack (API, deriver, Postgres, Redis). Requires Docker and a cloud LLM key. `--setup basic` / `--setup advanced` runs an interactive config wizard (TTY only). Does not change `environmentUrl`. | +| `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. | +| `honcho status` | Show every local stack (or `--profile` for one). | | `honcho doctor` | Health check: config, connectivity, workspace, peer, queue | ### Workspaces @@ -157,6 +185,9 @@ Precedence (highest first): **flag β†’ env var β†’ config file β†’ default**. | `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope | | `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope | | `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) | +| `HONCHO_NO_UPDATE_CHECK` | β€” | Disable the once-a-day upgrade notice (`1` / `true`) | +| `HONCHO_PROFILE` | `--profile` (start/stop/status) | Local stack profile (default: `local`) | +| `LLM_OPENAI_API_KEY` | β€” | Provider key for `honcho start` (also `LLM_ANTHROPIC_API_KEY`, `LLM_GEMINI_API_KEY`) | ```bash # Per-command flags diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index 5eb859fd..25c376cb 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-cli" -version = "0.1.2" +version = "0.1.4" description = "A terminal for Honcho β€” memory that reasons." readme = "README.md" requires-python = ">=3.11" @@ -38,6 +38,10 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/honcho_cli"] +[tool.hatch.build.targets.wheel.force-include] +"src/honcho_cli/local/templates/docker-compose.yml" = "honcho_cli/local/templates/docker-compose.yml" +"src/honcho_cli/local/templates/init.sql" = "honcho_cli/local/templates/init.sql" + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/honcho-cli/src/honcho_cli/__init__.py b/honcho-cli/src/honcho_cli/__init__.py index 81efe6f5..33860e37 100644 --- a/honcho-cli/src/honcho_cli/__init__.py +++ b/honcho-cli/src/honcho_cli/__init__.py @@ -1,3 +1,3 @@ """Honcho CLI β€” a terminal for Honcho.""" -__version__ = "0.1.2" +__version__ = "0.1.4" diff --git a/honcho-cli/src/honcho_cli/_help.py b/honcho-cli/src/honcho_cli/_help.py index d47d344d..dbb4f7cd 100644 --- a/honcho-cli/src/honcho_cli/_help.py +++ b/honcho-cli/src/honcho_cli/_help.py @@ -24,6 +24,7 @@ from typer.core import TyperGroup from honcho_cli import __version__ from honcho_cli.branding import BANNER, BRAND from honcho_cli.output import use_json +from honcho_cli.update_check import maybe_print_update_nag # Theme Typer's rich help renderer. Module-level side effect limited to @@ -59,15 +60,21 @@ def _welcome_panel(title: str, rows: list[tuple[str, str]]) -> Panel: def print_welcome(console: Console) -> None: - """Render the curated 3-panel welcome (banner + getting started / memory / commands).""" + """Render the curated welcome (banner + getting started / local stack / commands / memory).""" if use_json(): return console.print(f"[bold {BRAND}]{BANNER}[/bold {BRAND}]") console.print(f" [dim]v{__version__}[/dim]\n", highlight=False) start_rows = [ - ("honcho init", "configure API key and server URL"), - ("honcho doctor", "verify connection and workspace health"), + ("honcho init", "configure API key and server URL"), + ("honcho start [--setup basic]", "run a local Honcho stack (Docker)"), + ("honcho doctor", "verify connection and workspace health"), + ] + stack_rows = [ + ("honcho start / status / stop", "lifecycle for the local Docker stack"), + ("honcho start --setup basic", "interactive LLM + feature wizard"), + ("HONCHO_BASE_URL=http://127.0.0.1:8000", "prefix any command β€” CLI stays on api.honcho.dev until you set this"), ] cmd_rows = [ ("[dim]pattern[/dim]", r"[dim]honcho \[args] \[-w workspace] \[-p peer] \[-s session][/dim]"), @@ -84,14 +91,15 @@ def print_welcome(console: Console) -> None: ("config", "inspect current configuration"), ] memory_rows = [ - ("honcho peer chat \"...\" -p -w ","query the Dialectic about a peer"), - ("honcho peer inspect -p -w ","dashboard: peer card + recent conclusions + configuration"), + ("honcho peer chat \"...\" -p -w ", "query the Dialectic about a peer"), + ("honcho peer inspect -p -w ", "dashboard: peer card + recent conclusions + configuration"), ("honcho peer representation -p -w ", "global peer representation"), ("honcho peer representation -p -w -s ", "session-scoped peer representation"), ("honcho peer card -p -w ", "synthesized identity: traits, preferences, instructions"), - ("honcho conclusion list -p -w ", "browse peer conclusions"), + ("honcho conclusion list -p -w ", "browse peer conclusions"), + ("honcho session view / context -s ", "transcript, or what an agent would see"), + ("honcho workspace queue-status", "is the deriver processing?"), ] - option_rows = [ ("-w / --workspace", "scope to a workspace"), ("-p / --peer", "scope to a peer"), @@ -101,10 +109,12 @@ def print_welcome(console: Console) -> None: ] console.print(_welcome_panel("getting started", start_rows)) + console.print(_welcome_panel("local stack", stack_rows)) console.print(_welcome_panel("commands", cmd_rows)) console.print(_welcome_panel("memory", memory_rows)) console.print(_welcome_panel("options", option_rows)) console.print() + maybe_print_update_nag() class HonchoTyperGroup(TyperGroup): diff --git a/honcho-cli/src/honcho_cli/commands/stack.py b/honcho-cli/src/honcho_cli/commands/stack.py new file mode 100644 index 00000000..136a8ffe --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/stack.py @@ -0,0 +1,400 @@ +"""Local stack lifecycle: ``honcho start``, ``honcho stop``, ``honcho status``. + +Does not mutate ``~/.honcho/config.json``. The CLI stays pointed at whatever +``honcho init`` configured (typically api.honcho.dev). Print the local URL +and a one-shot ``HONCHO_BASE_URL=...`` hint instead. +""" + +from __future__ import annotations + +import typer +from rich.console import Console + +from honcho_cli.branding import BRAND, ICON_FAIL, ICON_OK +from honcho_cli.local import ( + DEFAULT_HEALTH_TIMEOUT, + DEFAULT_IMAGE, + DEFAULT_PROFILE, + STACK_SERVICES, +) +from honcho_cli.local.docker import ( + DockerError, + allocate_host_ports, + compose_down, + compose_ps, + compose_up, + pin_image, + seed_config_toml, + services_running, +) +from honcho_cli.local.env import has_provider_key, render_stack, settings_from_environ +from honcho_cli.local.health import stack_healthy, wait_for_health +from honcho_cli.local.profile import ( + LocalProfile, + list_profile_names, + load_profile, + resolve_profile_name, + save_profile, +) +from honcho_cli.local.setup import ( + SETUP_MODES, + answers_drop_keys, + answers_to_env, + run_setup, +) +from honcho_cli.output import ( + fail, + ok, + print_error, + print_json, + print_result, + set_json_mode, + step, + use_json, +) + +_console = Console(stderr=True) + +_MISSING_LLM_KEY = ( + "Set LLM_OPENAI_API_KEY, LLM_ANTHROPIC_API_KEY, or LLM_GEMINI_API_KEY, " + "or run honcho start --setup basic." +) + + +def _die(code: str, message: str, details: dict | None = None) -> None: + print_error(code, message, details) + raise typer.Exit(1) + + +def _validate_setup(setup: str | None) -> str | None: + if setup is None: + return None + mode = setup.strip().lower() + if mode not in SETUP_MODES: + _die( + "INVALID_SETUP", + f"Unknown setup mode {setup!r}. Use --setup basic or --setup advanced.", + {"setup": setup}, + ) + if use_json(): + _die( + "SETUP_REQUIRES_TTY", + "honcho start --setup is interactive. Run it in a terminal without --json.", + {"setup": mode}, + ) + return mode + + +def _payload( + profile: LocalProfile, status: str, services: dict[str, str] | None = None +) -> dict: + return { + "profile": profile.name, + "status": status, + "image": profile.image, + "endpoints": profile.endpoints(), + "services": services or {}, + "hint": f"HONCHO_BASE_URL={profile.base_url} honcho workspace list", + } + + +def _print_stack(payload: dict) -> None: + if use_json(): + print_json(payload) + return + endpoints = payload["endpoints"] + _console.print() + table_data = { + "API": endpoints["api"], + "Docs": endpoints["docs"], + "Postgres": endpoints["postgres"], + "Redis": endpoints["redis"], + } + print_result(table_data) + _console.print() + _console.print( + " [dim]CLI still points at your configured server (typically api.honcho.dev).[/dim]" + ) + _console.print(f" [dim]To talk to this stack:[/dim] {payload['hint']}") + _console.print() + + +def _print_running(profile: LocalProfile) -> None: + _print_stack(_payload(profile, "running", services_running(compose_ps(profile)))) + + +def _seed_config(profile: LocalProfile) -> None: + if seed_config_toml(profile): + ok("config.toml") + + +def _inspect(profile: LocalProfile) -> tuple[dict[str, str], bool]: + """Compose service states and whether the API is healthy.""" + return services_running(compose_ps(profile)), stack_healthy(profile) + + +def start( + profile_name: str = typer.Option( + DEFAULT_PROFILE, + "--profile", + envvar="HONCHO_PROFILE", + help="Local stack profile name", + ), + api_port: int | None = typer.Option( + None, "--api-port", min=1, max=65535, help="Host port for the API" + ), + db_port: int | None = typer.Option( + None, "--db-port", min=1, max=65535, help="Host port for Postgres" + ), + redis_port: int | None = typer.Option( + None, "--redis-port", min=1, max=65535, help="Host port for Redis" + ), + setup: str | None = typer.Option( + None, + "--setup", + help="Interactive config wizard: basic (provider/model) or advanced " + "(embeddings, deriver, dialectic, dreams, flush)", + ), + image: str | None = typer.Option( + None, + "--image", + help=f"Honcho image to pull and pin by digest (default: {DEFAULT_IMAGE})", + ), + timeout: int = typer.Option( + DEFAULT_HEALTH_TIMEOUT, + "--timeout", + min=1, + help="Seconds to wait for /health after compose up", + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Start a local Honcho stack (API, deriver, Postgres, Redis). + + Requires Docker. Uses cloud LLM providers. Does not change the CLI's + configured server URL β€” pass HONCHO_BASE_URL to talk to this stack. + ``--setup basic`` or ``--setup advanced`` runs an interactive config wizard. + """ + if json_output: + set_json_mode(True) + + setup = _validate_setup(setup) + name = resolve_profile_name(profile_name) + profile = load_profile(name).overlay( + api_port=api_port, + db_port=db_port, + redis_port=redis_port, + image=image, + ) + pinned_ports = frozenset( + name + for name, value in ( + ("api", api_port), + ("database", db_port), + ("redis", redis_port), + ) + if value is not None + ) + + if not use_json(): + _console.print(f"\n[bold {BRAND}]Honcho Start[/bold {BRAND}]\n") + + try: + already_running = stack_healthy(profile) + if already_running and not setup: + ok(f"Already running ({profile.base_url})") + _print_running(profile) + return + + if not already_running: + profile, remapped = allocate_host_ports(profile, pinned=pinned_ports) + for service, (old, new) in remapped.items(): + step(f"Port {old} in use; {service} on {new}") + + step(f"Pinning {profile.image}") + pinned_image = pin_image(profile.image) + profile = profile.overlay(image=pinned_image) + ok(pinned_image) + + extra = settings_from_environ() + drop: tuple[str, ...] = () + _seed_config(profile) + if setup: + answers = run_setup( + setup, + profile.env_file(), + config_path=profile.config_file(), + ) + extra.update(answers_to_env(answers)) + drop = answers_drop_keys(answers) + ok(f"Wrote overrides to {profile.env_file()}") + _console.print( + f" [dim]Other settings live in {profile.config_file()}[/dim]" + ) + elif not has_provider_key(profile, extra): + _die("MISSING_LLM_KEY", _MISSING_LLM_KEY) + + step(f"Writing stack config to {profile.dir()}") + save_profile(profile) + render_stack(profile, extra=extra, drop=drop) + ok(f"Profile '{profile.name}'") + + step("Starting containers" if not already_running else "Recreating api + deriver") + compose_up( + profile, + recreate=("api", "deriver") if already_running else (), + ) + + step(f"Waiting for API at {profile.base_url}/health") + if not wait_for_health(profile, timeout=float(timeout)): + fail("Timed out waiting for /health") + _die( + "HEALTH_TIMEOUT", + f"Stack started but {profile.base_url}/health did not become ready within {timeout}s. " + f"Check `docker compose -p {profile.project_name} logs`.", + { + "base_url": profile.base_url, + "timeout": timeout, + "project": profile.project_name, + }, + ) + + ok("Honcho is running") + _print_running(profile) + except DockerError as e: + e.exit() + + +def stop( + profile_name: str = typer.Option( + DEFAULT_PROFILE, + "--profile", + envvar="HONCHO_PROFILE", + help="Local stack profile name", + ), + wipe: bool = typer.Option( + False, "--wipe", help="Also delete volumes (Postgres data)" + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Stop the local stack started by `honcho start`. Keeps data unless --wipe.""" + if json_output: + set_json_mode(True) + + name = resolve_profile_name(profile_name) + profile = load_profile(name) + + try: + if not profile.compose_file().exists(): + payload = _payload(profile, "stopped") + if use_json(): + print_json(payload) + else: + _console.print( + f" [dim]No local stack for profile '{profile.name}'.[/dim]" + ) + return + + running = bool(compose_ps(profile)) + if not running and not wipe: + if use_json(): + print_json(_payload(profile, "stopped")) + else: + _console.print( + f" [dim]Profile '{profile.name}' is already stopped.[/dim]" + ) + return + + compose_down(profile, wipe=wipe) + except DockerError as e: + e.exit() + + state = "wiped" if wipe else "stopped" + ok(f"Stopped profile '{profile.name}'" + (" (volumes removed)" if wipe else "")) + if use_json(): + print_json(_payload(profile, state)) + + +def _status_one(profile: LocalProfile) -> bool: + """Print one profile's status. Return True when the API is healthy.""" + try: + services, running = _inspect(profile) + except DockerError as e: + e.exit() + data = _payload(profile, "running" if running else "stopped", services) + if not use_json(): + icon = ICON_OK if running else ICON_FAIL + _console.print(f"\n {icon} profile '{profile.name}' is {data['status']}\n") + if services: + for svc in STACK_SERVICES: + detail = services.get(svc, "missing") + _console.print(f" {svc:<10} [dim]{detail}[/dim]") + _print_stack(data) + return running + + +def status( + profile_name: str | None = typer.Option( + None, + "--profile", + envvar="HONCHO_PROFILE", + help="Limit to this profile. Omit to show every local stack.", + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Show local stack endpoints and container health. + + With no ``--profile``, lists every stack under ``~/.honcho/profiles/``. + """ + if json_output: + set_json_mode(True) + + if profile_name: + name = resolve_profile_name(profile_name) + profile = load_profile(name) + if not profile.compose_file().exists(): + _die( + "STACK_NOT_FOUND", + f"No local stack for profile '{profile.name}'. Run `honcho start` first.", + {"profile": profile.name}, + ) + if not _status_one(profile): + raise typer.Exit(1) + return + + names = list_profile_names() + if not names: + _die( + "STACK_NOT_FOUND", + "No local stacks. Run `honcho start` first.", + ) + + if len(names) == 1: + if not _status_one(load_profile(names[0])): + raise typer.Exit(1) + return + + rows: list[dict] = [] + try: + for name in names: + profile = load_profile(name) + services, running = _inspect(profile) + rows.append( + _payload(profile, "running" if running else "stopped", services) + ) + except DockerError as e: + e.exit() + if use_json(): + print_json({"profiles": rows}) + return + _console.print() + print_result( + [ + { + "profile": row["profile"], + "status": row["status"], + "api": row["endpoints"]["api"], + } + for row in rows + ], + columns=["profile", "status", "api"], + ) diff --git a/honcho-cli/src/honcho_cli/local/__init__.py b/honcho-cli/src/honcho_cli/local/__init__.py new file mode 100644 index 00000000..1fe4ac83 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/__init__.py @@ -0,0 +1,12 @@ +"""Local Honcho stack: profiles, Compose rendering, Docker, health checks.""" + +from __future__ import annotations + +DEFAULT_PROFILE = "local" +DEFAULT_API_PORT = 8000 +DEFAULT_DB_PORT = 5432 +DEFAULT_REDIS_PORT = 6379 +DEFAULT_IMAGE = "ghcr.io/plastic-labs/honcho:latest" +DEFAULT_HEALTH_TIMEOUT = 180 + +STACK_SERVICES = ("api", "deriver", "database", "redis") diff --git a/honcho-cli/src/honcho_cli/local/docker.py b/honcho-cli/src/honcho_cli/local/docker.py new file mode 100644 index 00000000..fbf1025f --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/docker.py @@ -0,0 +1,436 @@ +"""Docker daemon + Compose helpers for the local stack.""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from honcho_cli.local import STACK_SERVICES +from honcho_cli.local.profile import LocalProfile +from honcho_cli.output import print_error + +_DAEMON_DOWN_MARKERS = ( + "cannot connect to the docker daemon", + "is the docker daemon running", + "failed to connect to the docker api", + "error during connect", +) +_COMPOSE_MISSING_MARKERS = ( + "'compose' is not a docker command", + "unknown command: compose", + "docker: unknown command", +) +_CRED_HELPER_MARKERS = ("error getting credentials", "docker-credential-desktop") + + +class DockerError(Exception): + """Docker is missing, the daemon is down, or a Compose command failed.""" + + def __init__(self, code: str, message: str, details: dict | None = None): + super().__init__(message) + self.code = code + self.message = message + self.details = details or {} + + def exit(self) -> None: + print_error(self.code, self.message, self.details or None) + raise SystemExit(1) + + +def port_available(port: int, host: str = "127.0.0.1") -> bool: + """True when nothing is accepting connections on ``host:port``.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.2) + return sock.connect_ex((host, port)) != 0 + + +def allocate_host_ports( + profile: LocalProfile, + *, + pinned: frozenset[str] = frozenset(), +) -> tuple[LocalProfile, dict[str, tuple[int, int]]]: + """Move api/db/redis host ports that are already bound. + + Names in ``pinned`` (``api`` / ``database`` / ``redis``) were set by a + flag and fail instead of moving. + """ + taken: set[int] = set() + remapped: dict[str, tuple[int, int]] = {} + chosen: dict[str, int] = {} + for name, field, flag in ( + ("api", "api_port", "--api-port"), + ("database", "db_port", "--db-port"), + ("redis", "redis_port", "--redis-port"), + ): + preferred = getattr(profile, field) + port = preferred + if name in pinned: + if preferred in taken or not port_available(preferred): + raise DockerError( + "PORT_IN_USE", + f"Host port {preferred} for {name} is already in use. " + f"Pass {flag} with a free port, or stop the other process.", + {"port": preferred, "service": name, "flag": flag}, + ) + else: + while port in taken or not port_available(port): + port += 1 + if port > preferred + 100: + raise DockerError( + "PORT_IN_USE", + f"Could not find a free host port near {preferred}.", + {"preferred": preferred}, + ) + if port != preferred: + remapped[name] = (preferred, port) + taken.add(port) + chosen[field] = port + return profile.overlay(**chosen), remapped + + +def compose_argv(profile: LocalProfile) -> list[str]: + return [ + "docker", + "compose", + "-f", + str(profile.compose_file()), + "--project-directory", + str(profile.dir()), + "-p", + profile.project_name, + ] + + +_CONFIG_PATHS = ("/app/config.toml.example", "/app/config.toml") +_CONFIG_HEADER = ( + "# Copied from {image} by honcho start. This file is not overwritten on later starts.\n" + "# Secrets belong in .env (environment variables win over this file).\n\n" +) + + +def image_is_digest(ref: str) -> bool: + """True when ``ref`` is already pinned to a content digest.""" + return "@sha256:" in ref.lower() + + +def image_repository(ref: str) -> str: + """Strip a tag or digest from a Docker image reference.""" + if "@" in ref: + return ref.split("@", 1)[0] + last_slash = ref.rfind("/") + last_colon = ref.rfind(":") + if last_colon > last_slash: + return ref[:last_colon] + return ref + + +def pin_image(image: str) -> str: + """Pull ``image`` if needed and return a digest-pinned reference. + + ``ghcr.io/plastic-labs/honcho:latest`` becomes + ``ghcr.io/plastic-labs/honcho@sha256:...`` so the profile does not + float when ``:latest`` moves. Already-pinned refs are left alone. + """ + if image_is_digest(image): + if not _image_exists(image): + _pull(image) + return image + _pull(image) + digest = _repo_digest(image) + if not digest: + raise DockerError( + "IMAGE_PIN_FAILED", + f"Pulled {image} but could not resolve a registry digest to pin.", + {"image": image}, + ) + return digest + + +def seed_config_toml(profile: LocalProfile) -> bool: + """Copy the image's ``config.toml.example`` into the profile if missing. + + Returns True when a file was written. Never overwrites an existing + ``config.toml``. + """ + dest = profile.config_file() + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists(): + return False + copied = _copy_from_image(profile.image, _CONFIG_PATHS) + if copied is None: + raise DockerError( + "CONFIG_MISSING", + f"Could not copy config.toml from {profile.image}.", + {"image": profile.image}, + ) + dest.write_text(_CONFIG_HEADER.format(image=profile.image) + copied) + return True + + +def compose_up( + profile: LocalProfile, + *, + recreate: tuple[str, ...] = (), +) -> None: + """``docker compose up -d``. Compose output goes to stderr. + + ``recreate`` names services to ``--force-recreate`` (used after ``--setup`` + on an already-running stack so new ``.env`` values take effect). + """ + args = ["up", "-d"] + if recreate: + args.extend(["--force-recreate", *recreate]) + _run_compose(profile, args) + + +def compose_down(profile: LocalProfile, *, wipe: bool = False) -> None: + args = ["down"] + if wipe: + args.append("-v") + _run_compose(profile, args, capture=False) + + +def compose_ps(profile: LocalProfile) -> list[dict]: + """Parsed ``docker compose ps --format json`` (array or NDJSON).""" + proc = _run_compose(profile, ["ps", "--format", "json"], capture=True, check=False) + if proc.returncode != 0: + return [] + return _parse_ps(proc.stdout or "") + + +def services_running(ps: list[dict]) -> dict[str, str]: + """Map service name β†’ state for the four stack services. + + State is ``running``, ``healthy``, ``exited``, etc. Prefer Docker's + Health field when present. + """ + out: dict[str, str] = {} + for row in ps: + service = str(row.get("Service") or row.get("Name") or "") + # "honcho-local-api-1" β†’ try Service first; fall back to suffix match + if service not in STACK_SERVICES: + for name in STACK_SERVICES: + if ( + service == name + or service.endswith(f"-{name}-1") + or f"_{name}_" in service + ): + service = name + break + else: + continue + health = str(row.get("Health") or "").lower() + state = str(row.get("State") or row.get("Status") or "").lower() + if health: + out[service] = health + elif "health" in state: + # e.g. "running (healthy)" + out[service] = state + else: + out[service] = state or "unknown" + return out + + +def stack_containers_up(ps: list[dict]) -> bool: + """True when all four services are running (deriver has no healthcheck).""" + states = services_running(ps) + if any(name not in states for name in STACK_SERVICES): + return False + for state in states.values(): + if "exit" in state or state in {"dead", "paused"}: + return False + if "running" not in state and "healthy" not in state: + return False + return True + + +def _unavailable(proc: subprocess.CompletedProcess[str]) -> DockerError | None: + """Map a failed docker/compose process to a user-facing error, if obvious.""" + text = f"{proc.stderr or ''}{proc.stdout or ''}" + lower = text.lower() + if any(marker in lower for marker in _DAEMON_DOWN_MARKERS): + return DockerError( + "DOCKER_NOT_RUNNING", + "Docker is installed but the daemon is not running. Start it and retry.", + ) + if any(marker in lower for marker in _COMPOSE_MISSING_MARKERS): + return DockerError( + "DOCKER_COMPOSE_MISSING", + "Honcho start requires Docker Compose v2 (the `docker compose` plugin).", + ) + if any(marker in text for marker in _CRED_HELPER_MARKERS): + return DockerError( + "DOCKER_CREDENTIALS", + "Docker could not read registry credentials " + "(docker-credential-desktop is not on PATH). " + "Quit and reopen your terminal, or add Docker Desktop's bin " + "directory to PATH, then retry.", + {"exit_code": proc.returncode}, + ) + return None + + +def _run_compose( + profile: LocalProfile, + args: list[str], + *, + capture: bool = False, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + cmd = compose_argv(profile) + args + cwd: Path = profile.dir() + try: + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + except FileNotFoundError as e: + raise DockerError( + "DOCKER_NOT_INSTALLED", + "Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.", + ) from e + except OSError as e: + raise DockerError("COMPOSE_FAILED", str(e), {"command": cmd}) from e + if not capture: + if proc.stdout: + sys.stderr.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + classified = _unavailable(proc) + if classified is not None: + raise classified + if check and proc.returncode != 0: + raise DockerError( + "COMPOSE_FAILED", + "docker compose failed. See output above, or run `docker compose -p " + f"{profile.project_name} logs`.", + {"project": profile.project_name, "exit_code": proc.returncode}, + ) + return proc + + +def _run_docker( + args: list[str], + *, + check: bool = False, +) -> subprocess.CompletedProcess[str]: + try: + proc = subprocess.run( + ["docker", *args], + capture_output=True, + text=True, + ) + except FileNotFoundError as e: + raise DockerError( + "DOCKER_NOT_INSTALLED", + "Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.", + ) from e + except OSError as e: + raise DockerError("DOCKER_FAILED", str(e), {"command": args}) from e + if proc.returncode == 0: + return proc + classified = _unavailable(proc) + if classified is not None: + raise classified + if check: + raise DockerError( + "DOCKER_FAILED", + f"docker {' '.join(args)} failed.", + { + "exit_code": proc.returncode, + "stderr": (proc.stderr or "")[-500:], + }, + ) + return proc + + +def _pull(image: str) -> None: + proc = _run_docker(["pull", image], check=False) + if proc.stdout: + sys.stderr.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + raise DockerError( + "IMAGE_PULL_FAILED", + f"Failed to pull {image}.", + {"image": image, "exit_code": proc.returncode}, + ) + + +def _image_exists(image: str) -> bool: + return _run_docker(["image", "inspect", image], check=False).returncode == 0 + + +def _repo_digest(image: str) -> str | None: + proc = _run_docker( + ["image", "inspect", "--format", "{{json .RepoDigests}}", image], + check=False, + ) + if proc.returncode != 0: + return None + try: + digests = json.loads((proc.stdout or "").strip() or "[]") + except json.JSONDecodeError: + return None + if not isinstance(digests, list): + return None + repo = image_repository(image) + for item in digests: + if isinstance(item, str) and item.startswith(repo + "@"): + return item + for item in digests: + if isinstance(item, str) and "@sha256:" in item: + return item + return None + + +def _copy_from_image(image: str, paths: tuple[str, ...]) -> str | None: + """Create a stopped container and copy the first path that exists.""" + name = f"honcho-seed-{os.getpid()}-{time.time_ns()}" + created = _run_docker(["create", "--name", name, image], check=False) + if created.returncode != 0: + cid = (created.stdout or "").strip() or name + _run_docker(["rm", "-f", cid], check=False) + return None + cid = (created.stdout or "").strip() or name + try: + with tempfile.TemporaryDirectory(prefix="honcho-cfg-") as tmp: + dest = Path(tmp) / "config.toml" + for path in paths: + if dest.exists(): + dest.unlink() + copied = _run_docker(["cp", f"{cid}:{path}", str(dest)], check=False) + if copied.returncode == 0 and dest.exists(): + return dest.read_text(encoding="utf-8") + finally: + _run_docker(["rm", "-f", cid], check=False) + return None + + +def _parse_ps(stdout: str) -> list[dict]: + text = stdout.strip() + if not text: + return [] + if text.startswith("["): + try: + data = json.loads(text) + except json.JSONDecodeError: + return [] + return data if isinstance(data, list) else [] + rows: list[dict] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict): + rows.append(row) + return rows diff --git a/honcho-cli/src/honcho_cli/local/env.py b/honcho-cli/src/honcho_cli/local/env.py new file mode 100644 index 00000000..ecf9c2c1 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/env.py @@ -0,0 +1,183 @@ +"""Render Compose + ``.env`` for a local stack profile.""" + +from __future__ import annotations + +import os +from contextlib import suppress +from importlib.resources import files +from pathlib import Path + +from honcho_cli.local.profile import LocalProfile + +# Keys honcho start owns. Unknown lines in an existing .env are preserved. +MANAGED_KEYS = ( + "AUTH_USE_AUTH", + "LOG_LEVEL", + "HONCHO_IMAGE", + "API_PORT", + "DB_PORT", + "REDIS_PORT", +) + +# Host env forwarded into the profile .env (overrides config.toml). +_SETTINGS_PREFIXES = ( + "LLM_", + "EMBEDDING_", + "DERIVER_", + "DIALECTIC_", + "DREAM_", + "SUMMARY_", +) +_LLM_KEYS = ( + "LLM_OPENAI_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_GEMINI_API_KEY", +) + +_HEADER = ( + "# Generated by honcho start. Extra keys below the managed block are preserved." +) + +_PLACEHOLDERS = frozenset( + { + "", + "your-api-key-here", + "changeme", + "sk-...", + } +) + + +def is_placeholder_key(value: str | None) -> bool: + """True when ``value`` is missing or a known template placeholder.""" + if value is None: + return True + return value.strip() in _PLACEHOLDERS + + +def settings_from_environ() -> dict[str, str]: + """Host env vars that map to Honcho settings. Empty/placeholder values skipped.""" + return { + k: v + for k, v in os.environ.items() + if k.startswith(_SETTINGS_PREFIXES) and not is_placeholder_key(v) + } + + +def read_env_file(path: Path) -> dict[str, str]: + """Parse a dotenv file into a dict. Last assignment of a key wins.""" + if not path.exists(): + return {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return {} + out: dict[str, str] = {} + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + k, _, v = stripped.partition("=") + out[k.strip()] = _unquote(v.strip()) + return out + + +def read_env_value(path: Path, key: str) -> str | None: + """Return the raw value for ``key`` in a dotenv file, or None.""" + return read_env_file(path).get(key) + + +def has_provider_key(profile: LocalProfile, extra: dict[str, str]) -> bool: + """True when host extra or profile ``.env`` has a real LLM API key.""" + stored = {**read_env_file(profile.env_file()), **extra} + return any(not is_placeholder_key(stored.get(k)) for k in _LLM_KEYS) + + +def managed_env(profile: LocalProfile) -> dict[str, str]: + """Values written into the managed block of ``.env``.""" + return { + "AUTH_USE_AUTH": "false", + "LOG_LEVEL": "INFO", + "HONCHO_IMAGE": profile.image, + "API_PORT": str(profile.api_port), + "DB_PORT": str(profile.db_port), + "REDIS_PORT": str(profile.redis_port), + } + + +def upsert_env( + path: Path, + updates: dict[str, str], + *, + drop: tuple[str, ...] = (), +) -> None: + """Write ``updates``, preserving unrelated user lines. + + Managed keys are written first (stable order), then any other keys in + ``updates``. Keys in ``drop`` are removed and not rewritten. Drops a + previous generated header so it is not duplicated. + """ + drop_set = frozenset(drop) + extras: list[str] = [] + if path.exists(): + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped == _HEADER or stripped.startswith( + "# Generated by honcho start" + ): + continue + if not stripped or stripped.startswith("#"): + extras.append(line) + continue + if "=" in stripped: + k, _, _ = stripped.partition("=") + name = k.strip() + if name in updates or name in MANAGED_KEYS or name in drop_set: + continue + extras.append(line) + + managed = [f"{k}={updates[k]}" for k in MANAGED_KEYS if k in updates] + extra_updates = [ + f"{k}={v}" for k, v in updates.items() if k not in MANAGED_KEYS + ] + body = [_HEADER, *managed] + if extra_updates: + if not body[-1].startswith("#"): + body.append("") + body.extend(extra_updates) + if extras: + # Keep a blank line between generated and user keys when there are extras. + if extras[0].strip(): + body.append("") + body.extend(extras) + path.write_text("\n".join(body) + "\n") + with suppress(OSError): + os.chmod(path, 0o600) + + +def render_stack( + profile: LocalProfile, + extra: dict[str, str] | None = None, + drop: tuple[str, ...] = (), +) -> None: + """Write compose, init.sql, and .env into the profile directory.""" + directory = profile.dir() + directory.mkdir(parents=True, exist_ok=True) + with suppress(OSError): + os.chmod(directory, 0o700) + + templates = files("honcho_cli.local.templates") + compose = templates.joinpath("docker-compose.yml").read_text(encoding="utf-8") + init_sql = templates.joinpath("init.sql").read_text(encoding="utf-8") + profile.compose_file().write_text(compose) + (directory / "init.sql").write_text(init_sql) + updates = managed_env(profile) + if extra: + updates.update(extra) + upsert_env(profile.env_file(), updates, drop=drop) + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + return value[1:-1] + return value diff --git a/honcho-cli/src/honcho_cli/local/health.py b/honcho-cli/src/honcho_cli/local/health.py new file mode 100644 index 00000000..05593b99 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/health.py @@ -0,0 +1,53 @@ +"""Poll the local API health endpoint.""" + +from __future__ import annotations + +import time + +import httpx + +from honcho_cli.local.docker import compose_ps, services_running, stack_containers_up +from honcho_cli.local.profile import LocalProfile + + +def api_healthy(base_url: str, *, timeout: float = 2.0) -> bool: + """True when ``GET /health`` returns HTTP 200.""" + try: + with httpx.Client(timeout=timeout) as client: + response = client.get(base_url.rstrip("/") + "/health") + return response.status_code == 200 + except httpx.HTTPError: + return False + + +def stack_healthy(profile: LocalProfile) -> bool: + """True when Compose services are up and the API answers /health.""" + if not profile.compose_file().exists(): + return False + ps = compose_ps(profile) + if not stack_containers_up(ps): + return False + return api_healthy(profile.base_url) + + +def wait_for_health( + profile: LocalProfile, + *, + timeout: float, + interval: float = 1.0, +) -> bool: + """Poll until the API is healthy or ``timeout`` seconds elapse. + + Returns False on timeout. Fails fast if a required container has exited. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + ps = compose_ps(profile) + states = services_running(ps) + for _name, state in states.items(): + if "exit" in state or state in {"dead"}: + return False + if api_healthy(profile.base_url) and stack_containers_up(ps): + return True + time.sleep(interval) + return api_healthy(profile.base_url) diff --git a/honcho-cli/src/honcho_cli/local/profile.py b/honcho-cli/src/honcho_cli/local/profile.py new file mode 100644 index 00000000..69565071 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/profile.py @@ -0,0 +1,157 @@ +"""Named local-stack profiles under ``$HONCHO_CONFIG_DIR/profiles``. + +A profile is a Compose project directory, not an auth identity. +Resolution: ``--profile`` > ``HONCHO_PROFILE`` > ``local``. +""" + +from __future__ import annotations + +import json +import os +import re +from contextlib import suppress +from dataclasses import dataclass, replace + +from honcho_cli.local import ( + DEFAULT_API_PORT, + DEFAULT_DB_PORT, + DEFAULT_IMAGE, + DEFAULT_PROFILE, + DEFAULT_REDIS_PORT, +) +from honcho_cli.output import print_error + +_PROFILE_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,62}$") + + +def profiles_dir(): + from honcho_cli import config as cfg + + return cfg.CONFIG_DIR / "profiles" + + +def validate_profile_name(name: str) -> str: + if name and _PROFILE_NAME.match(name): + return name + print_error( + "INVALID_PROFILE", + "Profile name must be lowercase alphanumeric, starting with a letter " + "(hyphens and underscores allowed).", + {"profile": name}, + ) + raise SystemExit(1) + + +def resolve_profile_name(flag: str | None) -> str: + raw = ( + (flag or "").strip() + or (os.environ.get("HONCHO_PROFILE") or "").strip() + or DEFAULT_PROFILE + ) + return validate_profile_name(raw) + + +def list_profile_names() -> list[str]: + """Profile directories that already have a Compose file.""" + root = profiles_dir() + if not root.is_dir(): + return [] + names: list[str] = [] + for path in sorted(root.iterdir()): + if ( + path.is_dir() + and _PROFILE_NAME.match(path.name) + and (path / "docker-compose.yml").exists() + ): + names.append(path.name) + return names + + +@dataclass +class LocalProfile: + """Ports and image for one local stack.""" + + name: str + api_port: int = DEFAULT_API_PORT + db_port: int = DEFAULT_DB_PORT + redis_port: int = DEFAULT_REDIS_PORT + image: str = DEFAULT_IMAGE + + @property + def project_name(self) -> str: + return f"honcho-{self.name}" + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.api_port}" + + def dir(self): + return profiles_dir() / self.name + + def compose_file(self): + return self.dir() / "docker-compose.yml" + + def env_file(self): + return self.dir() / ".env" + + def profile_file(self): + return self.dir() / "profile.json" + + def config_file(self): + return self.dir() / "config.toml" + + def endpoints(self) -> dict[str, str]: + return { + "api": self.base_url, + "docs": f"{self.base_url}/docs", + "postgres": f"postgresql://postgres:postgres@127.0.0.1:{self.db_port}/postgres", + "redis": f"redis://127.0.0.1:{self.redis_port}/0", + } + + def overlay(self, **fields) -> LocalProfile: + return replace(self, **{k: v for k, v in fields.items() if v is not None}) + + +def load_profile(name: str) -> LocalProfile: + profile = LocalProfile(name=validate_profile_name(name)) + path = profile.profile_file() + if not path.exists(): + return profile + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return profile + if not isinstance(data, dict): + return profile + image = data.get("image") + return replace( + profile, + api_port=_port(data.get("apiPort"), profile.api_port), + db_port=_port(data.get("dbPort"), profile.db_port), + redis_port=_port(data.get("redisPort"), profile.redis_port), + image=image if isinstance(image, str) and image else profile.image, + ) + + +def save_profile(profile: LocalProfile) -> None: + directory = profile.dir() + directory.mkdir(parents=True, exist_ok=True) + with suppress(OSError): + os.chmod(directory, 0o700) + payload = { + "apiPort": profile.api_port, + "dbPort": profile.db_port, + "redisPort": profile.redis_port, + "image": profile.image, + } + profile.profile_file().write_text(json.dumps(payload, indent=2) + "\n") + + +def _port(value: object, default: int) -> int: + if isinstance(value, bool): + return default + try: + parsed = int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + return parsed if 1 <= parsed <= 65535 else default diff --git a/honcho-cli/src/honcho_cli/local/setup.py b/honcho-cli/src/honcho_cli/local/setup.py new file mode 100644 index 00000000..5922dfd4 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/setup.py @@ -0,0 +1,531 @@ +"""Interactive ``honcho start --setup`` wizard. + +Writes curated LLM/feature overrides for the local stack. Secrets and knobs +go to the profile ``.env`` (env wins over ``config.toml``). Prompts are TTY +only β€” the start command rejects ``--setup`` in JSON / non-TTY mode. +""" + +from __future__ import annotations + +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +import typer +from rich.console import Console + +from honcho_cli.local.env import is_placeholder_key, read_env_file, settings_from_environ +from honcho_cli.output import print_error + +SETUP_MODES = ("basic", "advanced") +DIALECTIC_LEVELS = ("minimal", "low", "medium", "high", "max") +PROVIDERS = ("openai", "anthropic", "gemini", "openai-compatible") +EMBEDDING_TRANSPORTS = ("openai", "gemini") + +_CHAT_PREFIXES = ( + "DERIVER_MODEL_CONFIG", + "SUMMARY_MODEL_CONFIG", + "DREAM_DEDUCTION_MODEL_CONFIG", + "DREAM_INDUCTION_MODEL_CONFIG", + *(f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG" for level in DIALECTIC_LEVELS), +) + +_PROVIDER_KEY_ENV = { + "openai": "LLM_OPENAI_API_KEY", + "openai-compatible": "LLM_OPENAI_API_KEY", + "anthropic": "LLM_ANTHROPIC_API_KEY", + "gemini": "LLM_GEMINI_API_KEY", +} + +_console = Console(stderr=True) + + +@dataclass(frozen=True) +class TomlSetupDefaults: + """Model/feature defaults copied from the image ``config.toml``. + + Honcho only ships OpenAI chat/embedding defaults. Other providers have + no suggested model in that file β€” the wizard does not invent one. + """ + + chat_transport: str | None = None + chat_model: str | None = None + embed_transport: str | None = None + embed_model: str | None = None + embed_dims: int | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + +def load_toml_setup_defaults(path: Path | None) -> TomlSetupDefaults: + """Read prompt defaults from the profile ``config.toml`` (image-aligned).""" + if path is None or not path.is_file(): + return TomlSetupDefaults() + try: + with path.open("rb") as fh: + data = tomllib.load(fh) + deriver = data.get("deriver") or {} + chat = deriver.get("model_config") or {} + embedding = data.get("embedding") or {} + embed = embedding.get("model_config") or {} + dream = data.get("dream") or {} + dims = embedding.get("VECTOR_DIMENSIONS") + return TomlSetupDefaults( + chat_transport=chat.get("transport"), + chat_model=chat.get("model"), + embed_transport=embed.get("transport"), + embed_model=embed.get("model"), + embed_dims=dims if isinstance(dims, int) and dims > 0 else None, + dreams_enabled=dream.get("ENABLED"), + flush_enabled=deriver.get("FLUSH_ENABLED"), + ) + except (OSError, tomllib.TOMLDecodeError, TypeError, AttributeError): + return TomlSetupDefaults() + + +def chat_model_default( + provider: str, + env: dict[str, str], + toml: TomlSetupDefaults, + *, + inferred: str | None = None, +) -> str: + """Prefer a previous wizard choice, else the image toml when transports match.""" + if inferred is None: + inferred = infer_provider(env) + if inferred == provider: + current = env.get("DERIVER_MODEL_CONFIG__MODEL") + if current: + return current + if toml.chat_model and _provider_matches_transport(provider, toml.chat_transport): + return toml.chat_model + return "" + + +def _provider_matches_transport(provider: str, transport: str | None) -> bool: + if not transport: + return False + return transport_of(provider) == transport + + +@dataclass(frozen=True) +class SetupAnswers: + """Curated knobs collected by the wizard (or tests).""" + + mode: str + provider: str + api_key: str + chat_model: str + base_url: str | None = None + embedding_api_key: str | None = None + embedding_key_transport: str | None = None + embedding_transport: str | None = None + embedding_model: str | None = None + embedding_dimensions: int | None = None + deriver_model: str | None = None + dialectic_model: str | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + +def transport_of(provider: str) -> str: + """Honcho ``MODEL_CONFIG.transport`` for a wizard provider id.""" + return "openai" if provider == "openai-compatible" else provider + + +def answers_to_env(answers: SetupAnswers) -> dict[str, str]: + """Map wizard answers to Honcho env overrides.""" + transport = transport_of(answers.provider) + env: dict[str, str] = {} + + env[_PROVIDER_KEY_ENV[answers.provider]] = answers.api_key + if answers.base_url: + env["LLM_OPENAI_BASE_URL"] = answers.base_url + # Embeddings do not inherit this URL; write it so OpenRouter/vLLM + # keys are not sent to api.openai.com. + if (answers.embedding_transport or "openai") == "openai": + env["EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL"] = answers.base_url + + if answers.embedding_api_key and answers.embedding_key_transport: + embed_key = ( + "LLM_OPENAI_API_KEY" + if answers.embedding_key_transport == "openai" + else "LLM_GEMINI_API_KEY" + ) + env[embed_key] = answers.embedding_api_key + + for prefix in _CHAT_PREFIXES: + env[f"{prefix}__TRANSPORT"] = transport + env[f"{prefix}__MODEL"] = answers.chat_model + + if answers.deriver_model: + env["DERIVER_MODEL_CONFIG__TRANSPORT"] = transport + env["DERIVER_MODEL_CONFIG__MODEL"] = answers.deriver_model + + if answers.dialectic_model: + for level in DIALECTIC_LEVELS: + env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__TRANSPORT"] = transport + env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] = ( + answers.dialectic_model + ) + + if answers.embedding_transport: + env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = answers.embedding_transport + if answers.embedding_model: + env["EMBEDDING_MODEL_CONFIG__MODEL"] = answers.embedding_model + if answers.embedding_dimensions is not None: + env["EMBEDDING_VECTOR_DIMENSIONS"] = str(answers.embedding_dimensions) + elif answers.embedding_key_transport == "gemini": + # Basic + Anthropic chat: a Gemini key is unused unless embeddings switch. + env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = "gemini" + + if answers.dreams_enabled is not None: + env["DREAM_ENABLED"] = "true" if answers.dreams_enabled else "false" + if answers.flush_enabled is not None: + env["DERIVER_FLUSH_ENABLED"] = "true" if answers.flush_enabled else "false" + return env + + +def answers_drop_keys(answers: SetupAnswers) -> tuple[str, ...]: + """Keys to remove so a previous wizard run cannot leak into this one.""" + drop: list[str] = [] + if answers.provider != "openai-compatible": + drop.append("LLM_OPENAI_BASE_URL") + embed_openai = (answers.embedding_transport or "openai") == "openai" + if answers.provider != "openai-compatible" or not embed_openai: + drop.append("EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL") + return tuple(drop) + + +def run_setup( + mode: str, + env_path: Path, + *, + config_path: Path | None = None, +) -> SetupAnswers: + """Prompt for ``basic`` or ``advanced`` knobs. Enter keeps the default.""" + env = read_env_file(env_path) + env.update(settings_from_environ()) + defaults = load_toml_setup_defaults(config_path) + _console.print() + _console.print( + " [dim]Configure the local stack. Press Enter to keep the default.[/dim]" + ) + _console.print( + " [dim]These values go in .env (they override config.toml).[/dim]" + ) + _console.print() + + inferred = infer_provider(env) + provider = _choose( + "LLM provider", + [ + ("openai", "OpenAI"), + ("anthropic", "Anthropic"), + ("gemini", "Gemini"), + ("openai-compatible", "OpenAI-compatible (OpenRouter, vLLM, Ollama, …)"), + ], + inferred if inferred in PROVIDERS else "openai", + ) + + base_url: str | None = None + if provider == "openai-compatible": + base_url = _prompt_text( + "OpenAI-compatible base URL", + env.get("LLM_OPENAI_BASE_URL") or "https://openrouter.ai/api/v1", + ) + + key_env = _PROVIDER_KEY_ENV[provider] + api_key = _prompt_secret("API key", env.get(key_env)) + + chat_default = chat_model_default( + provider, env, defaults, inferred=inferred + ) + chat_model = _prompt_text( + "Chat model (deriver, dialectic, summary, dream)", + chat_default, + required=True, + ) + + embedding_api_key: str | None = None + embedding_key_transport: str | None = None + embedding_transport: str | None = None + embedding_model: str | None = None + embedding_dimensions: int | None = None + deriver_model: str | None = None + dialectic_model: str | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + if mode == "advanced": + embedding_transport = _choose( + "Embedding provider", + [("openai", "OpenAI"), ("gemini", "Gemini")], + _default_embedding_transport(provider, env, defaults), + ) + same_embed = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") == embedding_transport + current_embed = env.get("EMBEDDING_MODEL_CONFIG__MODEL") if same_embed else None + embed_from_toml = ( + defaults.embed_model + if defaults.embed_transport == embedding_transport + else None + ) + embedding_model = ( + _prompt_text("Embedding model", current_embed or embed_from_toml or "") + or None + ) + dim_default = ( + int(env["EMBEDDING_VECTOR_DIMENSIONS"]) + if env.get("EMBEDDING_VECTOR_DIMENSIONS", "").isdigit() + else (defaults.embed_dims or 1536) + ) + embedding_dimensions = _prompt_int("Embedding dimensions", dim_default) + embedding_key_transport, embedding_api_key = _embedding_key_if_needed( + provider, embedding_transport, env + ) + deriver_model = _prompt_text("Deriver model", chat_model) + dialectic_model = _prompt_text("Dialectic model (all reasoning levels)", chat_model) + dreams_enabled = _choose_bool( + "Dreams (periodic deeper reasoning)", + _env_bool( + env.get("DREAM_ENABLED"), + default=True if defaults.dreams_enabled is None else defaults.dreams_enabled, + ), + ) + flush_enabled = _choose_bool( + "Snappy local deriver (flush work immediately, skip batching)", + _env_bool( + env.get("DERIVER_FLUSH_ENABLED"), + default=False if defaults.flush_enabled is None else defaults.flush_enabled, + ), + ) + elif provider == "anthropic": + embedding_key_transport = _choose( + "Embeddings (Anthropic has none β€” pick a provider)", + [("openai", "OpenAI"), ("gemini", "Gemini")], + "openai", + ) + embed_key_env = _PROVIDER_KEY_ENV[ + "openai" if embedding_key_transport == "openai" else "gemini" + ] + embedding_api_key = _prompt_secret("Embedding API key", env.get(embed_key_env)) + + _console.print() + return SetupAnswers( + mode=mode, + provider=provider, + api_key=api_key, + chat_model=chat_model, + base_url=base_url, + embedding_api_key=embedding_api_key, + embedding_key_transport=embedding_key_transport, + embedding_transport=embedding_transport, + embedding_model=embedding_model, + embedding_dimensions=embedding_dimensions, + deriver_model=deriver_model, + dialectic_model=dialectic_model, + dreams_enabled=dreams_enabled, + flush_enabled=flush_enabled, + ) + + +def infer_provider(env: dict[str, str]) -> str: + """Best-effort provider from an existing profile ``.env``.""" + if env.get("LLM_OPENAI_BASE_URL"): + return "openai-compatible" + transport = env.get("DERIVER_MODEL_CONFIG__TRANSPORT") + if transport in ("anthropic", "gemini", "openai"): + return transport + if env.get("LLM_ANTHROPIC_API_KEY") and not env.get("LLM_OPENAI_API_KEY"): + return "anthropic" + if env.get("LLM_GEMINI_API_KEY") and not env.get("LLM_OPENAI_API_KEY"): + return "gemini" + return "openai" + + +def _default_embedding_transport( + provider: str, env: dict[str, str], defaults: TomlSetupDefaults +) -> str: + current = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") + if current in EMBEDDING_TRANSPORTS: + return current + if defaults.embed_transport in EMBEDDING_TRANSPORTS: + return defaults.embed_transport + if provider == "gemini": + return "gemini" + return "openai" + + +def _embedding_key_if_needed( + chat_provider: str, + embed_transport: str, + env: dict[str, str], +) -> tuple[str | None, str | None]: + """Prompt for an embedding key when the chat provider cannot supply it.""" + chat_transport = transport_of(chat_provider) + if embed_transport == chat_transport or ( + chat_provider == "openai-compatible" and embed_transport == "openai" + ): + return None, None + key_env = _PROVIDER_KEY_ENV[embed_transport] + key = _prompt_secret(f"{embed_transport} embedding API key", env.get(key_env)) + return embed_transport, key + + +def _choose(label: str, options: list[tuple[str, str]], default: str) -> str: + ids = [item[0] for item in options] + default_idx = ids.index(default) + 1 if default in ids else 1 + _console.print(f" [dim]{label}[/dim]") + for i, (_oid, desc) in enumerate(options, 1): + _console.print(f" [dim]({i})[/dim] {desc}") + raw = typer.prompt( + " Choice", + default=str(default_idx), + show_default=True, + prompt_suffix=": ", + ).strip() + try: + idx = int(raw) + except ValueError: + if raw in ids: + return raw + return options[default_idx - 1][0] + if 1 <= idx <= len(options): + return options[idx - 1][0] + return options[default_idx - 1][0] + + +def _choose_bool(label: str, default: bool) -> bool: + return ( + _choose(label, [("true", "On"), ("false", "Off")], "true" if default else "false") + == "true" + ) + + +def _prompt_text(label: str, default: str, *, required: bool = False) -> str: + while True: + raw = typer.prompt( + f" {label}", + default=default, + show_default=bool(default), + prompt_suffix=": ", + ).strip() + value = raw or default + if value or not required: + return value + _console.print(" [red]A model name is required[/red]") + + +def _prompt_int(label: str, default: int) -> int: + while True: + raw = typer.prompt( + f" {label}", + default=str(default), + show_default=True, + prompt_suffix=": ", + ).strip() + try: + value = int(raw) + except ValueError: + _console.print(" [red]Enter an integer[/red]") + continue + if value > 0: + return value + _console.print(" [red]Must be a positive integer[/red]") + + +def _prompt_secret(label: str, current: str | None) -> str: + if current and not is_placeholder_key(current): + _console.print(f" [dim]Current {label}: {_redact(current)}[/dim]") + _console.print(" [dim](1)[/dim] Keep current key") + _console.print(" [dim](2)[/dim] Enter a new key") + choice = typer.prompt( + " Choice", default="1", show_default=True, prompt_suffix=": " + ).strip() + if choice != "2": + return current + _console.print(f" [dim]{label}[/dim]") + raw = _prompt_masked(f" {label}: ").strip() + if not raw or is_placeholder_key(raw): + print_error( + "MISSING_LLM_KEY", + f"{label} is required.", + ) + raise typer.Exit(1) + return raw + + +def _prompt_masked(prompt: str) -> str: + """Read a secret, echoing ``*`` per character so paste is visibly received.""" + stream = sys.stderr + stream.write(prompt) + stream.flush() + chars: list[str] = [] + + def _write(text: str) -> None: + stream.write(text) + stream.flush() + + def _feed(ch: str) -> bool: + """Return True when input is complete.""" + if not ch or ch in ("\n", "\r", "\x04"): + _write("\n") + return True + if ch in ("\x7f", "\x08"): + if chars: + chars.pop() + _write("\b \b") + return False + if ch == "\x1b": + return False + if ch.isprintable(): + chars.append(ch) + _write("*") + return False + + if sys.platform == "win32": + import msvcrt + + while True: + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): + msvcrt.getwch() + continue + if _feed(ch): + return "".join(chars) + + import termios + import tty + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + while True: + ch = sys.stdin.read(1) + if ch == "\x1b": + nxt = sys.stdin.read(1) + if nxt == "[": + while True: + seq = sys.stdin.read(1) + if not seq or "@" <= seq <= "~": + break + continue + if _feed(ch): + return "".join(chars) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + return "".join(chars) + + +def _redact(key: str) -> str: + if len(key) <= 4: + return "***" + return "***" + key[-4:] + + +def _env_bool(value: str | None, *, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in ("1", "true", "yes", "on") diff --git a/honcho-cli/src/honcho_cli/local/templates/__init__.py b/honcho-cli/src/honcho_cli/local/templates/__init__.py new file mode 100644 index 00000000..e7802aa6 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/__init__.py @@ -0,0 +1 @@ +"""Package data for the local stack (Compose template + Postgres init).""" diff --git a/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml b/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml new file mode 100644 index 00000000..4a19af67 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml @@ -0,0 +1,100 @@ +# Managed by `honcho start`. Re-rendered on every start β€” edit .env and config.toml, not this file. +# +# Images: ghcr.io/plastic-labs/honcho (API + deriver), pgvector/pgvector:pg15, redis:8.2 +# Ports bind to 127.0.0.1. Auth is off (AUTH_USE_AUTH=false in .env). + +services: + api: + image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest} + entrypoint: ["sh", "docker/entrypoint.sh"] + depends_on: + database: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "127.0.0.1:${API_PORT:-8000}:8000" + healthcheck: + test: + [ + "CMD", + "/app/.venv/bin/python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()", + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + volumes: + - lancedb-data:/app/lancedb_data + - ./config.toml:/app/config.toml:ro + environment: + - DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres + - CACHE_URL=redis://redis:6379/0?suppress=true + - CACHE_ENABLED=true + env_file: + - path: .env + required: false + restart: unless-stopped + + deriver: + image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest} + entrypoint: ["/app/.venv/bin/python", "-m", "src.deriver"] + depends_on: + api: + condition: service_healthy + database: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - lancedb-data:/app/lancedb_data + - ./config.toml:/app/config.toml:ro + environment: + - DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres + - CACHE_URL=redis://redis:6379/0?suppress=true + - CACHE_ENABLED=true + env_file: + - path: .env + required: false + restart: unless-stopped + + database: + image: pgvector/pgvector:pg15 + restart: unless-stopped + ports: + - "127.0.0.1:${DB_PORT:-5432}:5432" + command: ["postgres", "-c", "max_connections=200"] + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_HOST_AUTH_METHOD=trust + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + - pgdata:/var/lib/postgresql/data/ + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:8.2 + restart: unless-stopped + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: + redis-data: + lancedb-data: diff --git a/honcho-cli/src/honcho_cli/local/templates/init.sql b/honcho-cli/src/honcho_cli/local/templates/init.sql new file mode 100644 index 00000000..0aa0fc22 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/init.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS vector; diff --git a/honcho-cli/src/honcho_cli/main.py b/honcho-cli/src/honcho_cli/main.py index 7ed8aa9b..d2282c81 100644 --- a/honcho-cli/src/honcho_cli/main.py +++ b/honcho-cli/src/honcho_cli/main.py @@ -15,6 +15,7 @@ from honcho_cli import __version__ from honcho_cli._help import HonchoTyperGroup, print_welcome from honcho_cli.branding import BANNER from honcho_cli.output import set_json_mode +from honcho_cli.update_check import maybe_print_update_nag app = typer.Typer( @@ -60,13 +61,18 @@ def main( if ctx.invoked_subcommand is None: print_welcome(Console()) raise typer.Exit() + maybe_print_update_nag() # Register top-level commands from honcho_cli.commands.setup import doctor, init +from honcho_cli.commands.stack import start, status, stop app.command()(init) app.command()(doctor) +app.command()(start) +app.command()(stop) +app.command()(status) @app.command("help", hidden=True) diff --git a/honcho-cli/src/honcho_cli/output.py b/honcho-cli/src/honcho_cli/output.py index 2f0ec5e9..e95f17e6 100644 --- a/honcho-cli/src/honcho_cli/output.py +++ b/honcho-cli/src/honcho_cli/output.py @@ -15,6 +15,8 @@ from rich.console import Console from rich.table import Table from rich.text import Text +from honcho_cli.branding import ICON_FAIL, ICON_OK, ICON_RUN + console = Console(stderr=True) stdout_console = Console() @@ -106,6 +108,24 @@ def status(msg: str) -> None: console.print(f"[dim]{msg}[/dim]") +def step(msg: str) -> None: + """Print a progress step. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_RUN} {msg}") + + +def ok(msg: str) -> None: + """Print a success line. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_OK} {msg}") + + +def fail(msg: str) -> None: + """Print a failure line. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_FAIL} {msg}") + + # Stable peer-color palette for transcript rendering. Brand blue first so the # primary peer lands on brand when there's only one speaker. _PEER_COLORS = ( diff --git a/honcho-cli/src/honcho_cli/update_check.py b/honcho-cli/src/honcho_cli/update_check.py new file mode 100644 index 00000000..97b433d1 --- /dev/null +++ b/honcho-cli/src/honcho_cli/update_check.py @@ -0,0 +1,67 @@ +"""Once-a-day stderr notice when a newer honcho-cli is on PyPI. + +Fail-open: any error is swallowed. Cache is ``update-check.json`` beside +config, not ``config.json``. +""" + +from __future__ import annotations + +import json +import os +import sys +import time + +import httpx + +from honcho_cli import __version__ +from honcho_cli.branding import ICON_RUN +from honcho_cli.config import _config_dir +from honcho_cli.output import console, use_json + +_INTERVAL_S = 24 * 60 * 60 +_PYPI_URL = "https://pypi.org/pypi/honcho-cli/json" + + +def maybe_print_update_nag() -> None: + if use_json() or "--json" in sys.argv: + return + if os.environ.get("HONCHO_NO_UPDATE_CHECK", "").lower() in ("1", "true"): + return + try: + path = _config_dir() / "update-check.json" + now = time.time() + try: + cache = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + cache = {} + if isinstance(cache, dict) and now - float(cache.get("t") or 0) < _INTERVAL_S: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"t": now}), encoding="utf-8") + latest = httpx.get(_PYPI_URL, timeout=1.0).json()["info"]["version"] + if not isinstance(latest, str) or not _is_newer(latest, __version__): + return + console.print(f" {ICON_RUN} honcho-cli {latest} is available (you have {__version__})") + console.print(" [dim]uv tool upgrade honcho-cli[/dim]") + except Exception: + return + + +def _is_newer(latest: str, current: str) -> bool: + def parts(version: str) -> tuple[int, ...]: + out: list[int] = [] + for segment in version.lstrip("v").split("."): + num = "" + for ch in segment: + if ch.isdigit(): + num += ch + else: + break + if not num: + break + out.append(int(num)) + return tuple(out) or (0,) + + a, b = parts(latest), parts(current) + n = max(len(a), len(b)) + return a + (0,) * (n - len(a)) > b + (0,) * (n - len(b)) diff --git a/honcho-cli/tests/test_local.py b/honcho-cli/tests/test_local.py new file mode 100644 index 00000000..02e5cf2f --- /dev/null +++ b/honcho-cli/tests/test_local.py @@ -0,0 +1,127 @@ +"""Local-stack contracts: profile files, env merge, image pin, port remap.""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest +from honcho_cli.local.docker import ( + DockerError, + allocate_host_ports, + pin_image, + seed_config_toml, +) +from honcho_cli.local.env import managed_env, read_env_value, render_stack, upsert_env +from honcho_cli.local.profile import LocalProfile, load_profile, save_profile + + +@pytest.fixture +def cfg_dir(tmp_path, monkeypatch): + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", tmp_path / "config.json") + for k in [k for k in os.environ if k.startswith("HONCHO_")]: + monkeypatch.delenv(k) + return tmp_path + + +def test_profile_roundtrip_has_no_secrets(cfg_dir): + profile = LocalProfile( + name="local", + api_port=8001, + image="ghcr.io/plastic-labs/honcho@sha256:abc", + ) + save_profile(profile) + loaded = load_profile("local") + assert loaded.api_port == 8001 + assert loaded.image.endswith("@sha256:abc") + on_disk = json.loads(profile.profile_file().read_text()) + assert "LLM" not in json.dumps(on_disk) + assert set(on_disk) == {"apiPort", "dbPort", "redisPort", "image"} + + +def test_upsert_preserves_extra_env_keys(tmp_path): + path = tmp_path / ".env" + path.write_text("CUSTOM_FLAG=keep-me\n# user comment\n") + upsert_env(path, managed_env(LocalProfile(name="local"))) + text = path.read_text() + assert "CUSTOM_FLAG=keep-me" in text + assert "user comment" in text + assert text.count("Generated by honcho start") == 1 + + +def test_upsert_writes_non_managed_and_preserves_later(tmp_path): + path = tmp_path / ".env" + first = managed_env(LocalProfile(name="local")) + first["DERIVER_MODEL_CONFIG__MODEL"] = "gpt-test" + upsert_env(path, first) + upsert_env(path, managed_env(LocalProfile(name="local"))) + later = path.read_text() + assert "DERIVER_MODEL_CONFIG__MODEL=gpt-test" in later + + +def test_render_stack_uses_published_image(cfg_dir): + profile = LocalProfile(name="local") + render_stack(profile) + compose = profile.compose_file().read_text() + assert "ghcr.io/plastic-labs/honcho" in compose + assert "build:" not in compose + assert compose.count("./config.toml:/app/config.toml:ro") == 2 + assert read_env_value(profile.env_file(), "AUTH_USE_AUTH") == "false" + assert oct(profile.env_file().stat().st_mode)[-3:] == "600" + + +def test_pin_latest_to_matching_digest(monkeypatch): + pulls: list[str] = [] + + def fake_run(args, *, check=False): + if args[:1] == ["pull"]: + pulls.append(args[1]) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + if args[:2] == ["image", "inspect"]: + body = json.dumps( + [ + "ghcr.io/plastic-labs/honcho@sha256:deadbeef", + "ghcr.io/other/honcho@sha256:nope", + ] + ) + return subprocess.CompletedProcess(args, 0, stdout=body, stderr="") + raise AssertionError(args) + + monkeypatch.setattr("honcho_cli.local.docker._run_docker", fake_run) + assert pin_image("ghcr.io/plastic-labs/honcho:latest") == ( + "ghcr.io/plastic-labs/honcho@sha256:deadbeef" + ) + assert pulls == ["ghcr.io/plastic-labs/honcho:latest"] + + +def test_seed_config_toml_writes_once(cfg_dir, monkeypatch): + profile = LocalProfile( + name="local", image="ghcr.io/plastic-labs/honcho@sha256:abc" + ) + monkeypatch.setattr( + "honcho_cli.local.docker._copy_from_image", + lambda image, paths: "[deriver]\nWORKERS = 2\n", + ) + assert seed_config_toml(profile) is True + profile.config_file().write_text( + profile.config_file().read_text() + "# user edit\n" + ) + assert seed_config_toml(profile) is False + assert "# user edit" in profile.config_file().read_text() + + +def test_busy_port_remaps_unless_pinned(monkeypatch): + monkeypatch.setattr( + "honcho_cli.local.docker.port_available", + lambda port, host="127.0.0.1": port != 6379, + ) + profile, remapped = allocate_host_ports(LocalProfile(name="local")) + assert profile.redis_port == 6380 + assert remapped["redis"] == (6379, 6380) + + with pytest.raises(DockerError) as exc: + allocate_host_ports(LocalProfile(name="local"), pinned=frozenset({"redis"})) + assert exc.value.code == "PORT_IN_USE" + assert exc.value.details["flag"] == "--redis-port" diff --git a/honcho-cli/tests/test_setup.py b/honcho-cli/tests/test_setup.py new file mode 100644 index 00000000..6fa6ddad --- /dev/null +++ b/honcho-cli/tests/test_setup.py @@ -0,0 +1,94 @@ +"""Wizard mapping: ``answers_to_env`` and image-toml defaults.""" + +from __future__ import annotations + +from honcho_cli.local.setup import ( + DIALECTIC_LEVELS, + SetupAnswers, + answers_drop_keys, + answers_to_env, + chat_model_default, + load_toml_setup_defaults, +) + + +def test_basic_openai_applies_chat_model_everywhere(): + env = answers_to_env( + SetupAnswers( + mode="basic", + provider="openai", + api_key="sk-test", + chat_model="gpt-test", + ) + ) + assert env["LLM_OPENAI_API_KEY"] == "sk-test" + assert env["DERIVER_MODEL_CONFIG__MODEL"] == "gpt-test" + assert env["SUMMARY_MODEL_CONFIG__MODEL"] == "gpt-test" + for level in DIALECTIC_LEVELS: + assert env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] == "gpt-test" + assert "DREAM_ENABLED" not in env + assert "EMBEDDING_MODEL_CONFIG__MODEL" not in env + + +def test_basic_anthropic_keeps_openai_embeddings_default(): + env = answers_to_env( + SetupAnswers( + mode="basic", + provider="anthropic", + api_key="sk-ant", + chat_model="claude-haiku-4-5", + embedding_api_key="sk-embed", + embedding_key_transport="openai", + ) + ) + assert env["LLM_ANTHROPIC_API_KEY"] == "sk-ant" + assert env["LLM_OPENAI_API_KEY"] == "sk-embed" + assert env["DERIVER_MODEL_CONFIG__TRANSPORT"] == "anthropic" + assert "EMBEDDING_MODEL_CONFIG__TRANSPORT" not in env + + +def test_openai_compatible_copies_base_url_to_embeddings(): + env = answers_to_env( + SetupAnswers( + mode="basic", + provider="openai-compatible", + api_key="sk-or-test", + chat_model="gpt-test", + base_url="https://openrouter.ai/api/v1", + ) + ) + assert env["LLM_OPENAI_BASE_URL"] == "https://openrouter.ai/api/v1" + assert ( + env["EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL"] + == "https://openrouter.ai/api/v1" + ) + + +def test_leaving_openai_compatible_drops_proxy_urls(): + dropped = answers_drop_keys( + SetupAnswers( + mode="basic", provider="openai", api_key="sk", chat_model="gpt-test" + ) + ) + assert "LLM_OPENAI_BASE_URL" in dropped + assert "EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL" in dropped + + +def test_chat_default_comes_from_image_toml(tmp_path): + path = tmp_path / "config.toml" + path.write_text( + "[deriver.model_config]\n" + 'transport = "openai"\n' + 'model = "gpt-from-image"\n' + ) + defaults = load_toml_setup_defaults(path) + assert defaults.chat_model == "gpt-from-image" + assert chat_model_default("openai", {}, defaults) == "gpt-from-image" + assert chat_model_default("openai-compatible", {}, defaults) == "gpt-from-image" + assert chat_model_default("anthropic", {}, defaults) == "" + assert chat_model_default( + "openai", + {"DERIVER_MODEL_CONFIG__MODEL": "gpt-from-env"}, + defaults, + inferred="openai", + ) == "gpt-from-env" diff --git a/honcho-cli/tests/test_start.py b/honcho-cli/tests/test_start.py new file mode 100644 index 00000000..2b589813 --- /dev/null +++ b/honcho-cli/tests/test_start.py @@ -0,0 +1,159 @@ +"""CLI contracts for `honcho start` / `stop` / `status`.""" + +from __future__ import annotations + +import json +import os + +import pytest +from honcho_cli.local.docker import image_is_digest, image_repository +from honcho_cli.main import app +from typer.testing import CliRunner + + +@pytest.fixture +def cfg(tmp_path, monkeypatch): + f = tmp_path / "config.json" + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f) + monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f) + for k in [k for k in os.environ if k.startswith(("HONCHO_", "LLM_"))]: + monkeypatch.delenv(k) + return f + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture(autouse=True) +def _host_ports_free(monkeypatch): + monkeypatch.setattr("honcho_cli.local.docker.port_available", lambda *a, **k: True) + + +@pytest.fixture(autouse=True) +def _stub_image_pin(monkeypatch): + def fake_pin(image: str) -> str: + if image_is_digest(image): + return image + return f"{image_repository(image)}@sha256:cafedeadbeef" + + monkeypatch.setattr("honcho_cli.commands.stack.pin_image", fake_pin) + monkeypatch.setattr("honcho_cli.commands.stack.seed_config_toml", lambda profile: False) + + +_PS = [ + {"Service": "api", "State": "running", "Health": "healthy"}, + {"Service": "deriver", "State": "running"}, + {"Service": "database", "State": "running", "Health": "healthy"}, + {"Service": "redis", "State": "running", "Health": "healthy"}, +] + + +def test_start_does_not_rewrite_environment_url(cfg, runner, monkeypatch): + cfg.write_text( + json.dumps({"apiKey": "k", "environmentUrl": "https://api.honcho.dev"}) + ) + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False) + monkeypatch.setattr("honcho_cli.commands.stack.compose_up", lambda profile, **k: None) + monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True) + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS) + monkeypatch.setenv("LLM_OPENAI_API_KEY", "sk-test") + result = runner.invoke(app, ["start", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["endpoints"]["api"] == "http://127.0.0.1:8000" + assert payload["image"].endswith("@sha256:cafedeadbeef") + on_disk = json.loads(cfg.read_text()) + assert on_disk["environmentUrl"] == "https://api.honcho.dev" + + +def test_start_requires_llm_key(cfg, runner, monkeypatch): + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False) + result = runner.invoke(app, ["start"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "MISSING_LLM_KEY" + + +def test_stop_already_stopped_skips_down(cfg, runner, tmp_path, monkeypatch): + compose = tmp_path / "profiles" / "local" / "docker-compose.yml" + compose.parent.mkdir(parents=True) + compose.write_text("services: {}\n") + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: []) + down = [] + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_down", + lambda profile, wipe=False: down.append(wipe), + ) + result = runner.invoke(app, ["stop"]) + assert result.exit_code == 0, result.stderr + assert down == [] + assert json.loads(result.stdout)["status"] == "stopped" + + +def test_status_lists_profiles_or_one(cfg, runner, tmp_path, monkeypatch): + for name, port in (("demo", 8001), ("local", 8000)): + d = tmp_path / "profiles" / name + d.mkdir(parents=True) + (d / "docker-compose.yml").write_text("services: {}\n") + (d / "profile.json").write_text(json.dumps({"apiPort": port}) + "\n") + + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_ps", + lambda profile: _PS if profile.name == "local" else [], + ) + monkeypatch.setattr( + "honcho_cli.commands.stack.stack_healthy", + lambda profile: profile.name == "local", + ) + listed = runner.invoke(app, ["status"]) + assert listed.exit_code == 0, listed.stderr + rows = json.loads(listed.stdout)["profiles"] + by_name = {row["profile"]: row for row in rows} + assert by_name["local"]["status"] == "running" + assert by_name["demo"]["endpoints"]["api"] == "http://127.0.0.1:8001" + + one = runner.invoke(app, ["status", "--profile", "local"]) + assert one.exit_code == 0, one.stderr + payload = json.loads(one.stdout) + assert payload["profile"] == "local" + assert "profiles" not in payload + + +def test_start_setup_requires_tty(cfg, runner): + result = runner.invoke(app, ["start", "--setup", "basic", "--json"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "SETUP_REQUIRES_TTY" + + +def test_start_setup_recreates_when_already_running(cfg, runner, monkeypatch): + from honcho_cli.local.setup import SetupAnswers + + ups: list[tuple[str, ...]] = [] + monkeypatch.setattr("honcho_cli.commands.stack.use_json", lambda: False) + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: True) + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_up", + lambda profile, **k: ups.append(k.get("recreate", ())), + ) + monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True) + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS) + monkeypatch.setattr( + "honcho_cli.commands.stack.run_setup", + lambda mode, path, config_path=None: SetupAnswers( + mode="basic", + provider="openai", + api_key="sk-wiz", + chat_model="gpt-test", + ), + ) + pins: list[str] = [] + monkeypatch.setattr( + "honcho_cli.commands.stack.pin_image", + lambda image: pins.append(image) or image, + ) + result = runner.invoke(app, ["start", "--setup", "basic"]) + assert result.exit_code == 0, result.stderr + assert pins == [] + assert ups == [("api", "deriver")] diff --git a/pyproject.toml b/pyproject.toml index 1a6bbec8..a6681ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "honcho" -version = "3.0.12" +version = "3.1.0" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, ] readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.13" dependencies = [ "fastapi[standard-no-fastapi-cloud-cli]>=0.131.0", "python-dotenv>=1.0.0", diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 751d9005..7fe5d8f7 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.4.0] - 2026-08-25 + +### Added + +- Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). +- `scope` option on `Peer.chat()` / `chat_stream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`. +- Workspace-level chat: `Honcho.chat()` / `HonchoAio.chat()` and `chat_stream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoning_level`, and `response_format` options as `Peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + +### Changed + +- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair. + ## [2.3.0] - 2026-08-10 ### Added diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index f2d30457..bd4d1f1b 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-ai" -version = "2.3.0" +version = "2.4.0" description = "Official DX Optimized Python SDK for Honcho" dynamic = ["readme"] license = "Apache-2.0" diff --git a/sdks/python/src/honcho/__init__.py b/sdks/python/src/honcho/__init__.py index 031567a4..a227a9dc 100644 --- a/sdks/python/src/honcho/__init__.py +++ b/sdks/python/src/honcho/__init__.py @@ -42,16 +42,22 @@ from pathlib import Path import re from .aio import ( - ConclusionScopeAio, + ConclusionsViewAio, HonchoAio, PeerAio, + ScopeAio, SessionAio, WorkspaceConclusionsAio, ) -from .api_types import MessageCreateParams -from .base import PeerBase, SessionBase +from .api_types import ( + MessageCreateParams, + ScopeBackfillJob, + ScopeResponse, + ScopeStatusResponse, +) +from .base import PeerBase, ScopeBase, SessionBase from .client import Honcho -from .conclusions import Conclusion, ConclusionScope, WorkspaceConclusions +from .conclusions import Conclusion, ConclusionsView, WorkspaceConclusions from .http.exceptions import ( APIError, AuthenticationError, @@ -69,6 +75,7 @@ from .http.exceptions import ( from .message import Message from .pagination import AsyncPage, SyncPage from .peer import Peer +from .scope import Scope from .session import Session from .session_context import SessionContext, SessionSummaries, Summary from .types import ( @@ -76,6 +83,12 @@ from .types import ( DialecticStreamResponse, ) +# Deprecated aliases. "Scope" now means a named set of sessions (see `Scope`), +# which these are not β€” they are views over one observer/observed pair. Kept for +# one more minor version. +ConclusionScope = ConclusionsView +ConclusionScopeAio = ConclusionsViewAio + def _detect_version() -> str: try: @@ -101,25 +114,34 @@ __all__ = [ "Honcho", # Domain classes "Conclusion", - "ConclusionScope", + "ConclusionsView", "WorkspaceConclusions", "Message", "MessageCreateParams", "Peer", + "Scope", "Session", # Aio views (for type hints) - "ConclusionScopeAio", + "ConclusionsViewAio", "WorkspaceConclusionsAio", "HonchoAio", "PeerAio", + "ScopeAio", "SessionAio", # Base classes "PeerBase", + "ScopeBase", "SessionBase", # Response types + "ScopeBackfillJob", + "ScopeResponse", + "ScopeStatusResponse", "SessionContext", "SessionSummaries", "Summary", + # Deprecated aliases + "ConclusionScope", + "ConclusionScopeAio", # Pagination "AsyncPage", "SyncPage", diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index ad88bd6c..84a921aa 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -3,7 +3,7 @@ This module provides async accessor classes that wrap the main SDK classes and provide async versions of all operations. Access via the `.aio` property -on Honcho, Peer, Session, and ConclusionScope instances. +on Honcho, Peer, Session, and ConclusionsView instances. Example: ```python @@ -24,7 +24,7 @@ from __future__ import annotations import json import logging import warnings -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Sequence from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload @@ -40,19 +40,22 @@ from .api_types import ( PeerResponse, QueueStatusResponse, RepresentationResponse, + ScopeBackfillJob, + ScopeResponse, + ScopeStatusResponse, SessionConfiguration, SessionPeerConfig, SessionResponse, WorkspaceConfiguration, WorkspaceResponse, ) -from .base import PeerBase, SessionBase +from .base import PeerBase, ScopeBase, SessionBase from .conclusions import ( _LIST_PAGE_CAP, - _SCOPE_RESERVED, + _VIEW_RESERVED, Conclusion, _reject_reserved_filter_keys, - _require_scope, + _require_view, ) from .http import routes from .message import Message @@ -66,14 +69,20 @@ from .utils import ( parse_sse_astream, prepare_file_for_upload, resolve_id, + resolve_scope_membership, + resolve_scope_session, + scope_context_fields, + scope_recall_fields, + validate_scope_id, ) if TYPE_CHECKING: from .client import Honcho - from .conclusions import ConclusionScope, WorkspaceConclusions + from .conclusions import ConclusionsView, WorkspaceConclusions from .conclusions import ConclusionCreateParams from .peer import Peer, TResponseFormat, serialize_response_format +from .scope import Scope from .session import Session logger = logging.getLogger(__name__) @@ -81,8 +90,9 @@ logger = logging.getLogger(__name__) __all__ = [ "HonchoAio", "PeerAio", + "ScopeAio", "SessionAio", - "ConclusionScopeAio", + "ConclusionsViewAio", "WorkspaceConclusionsAio", ] @@ -270,6 +280,7 @@ class HonchoAio(AsyncMetadataConfigMixin): | list[tuple[PeerBase | str, SessionPeerConfig]] | list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] | None = None, + scopes: Sequence[str | ScopeBase] | None = None, ) -> Session: """ Get or create a session with the given ID asynchronously. @@ -281,6 +292,11 @@ class HonchoAio(AsyncMetadataConfigMixin): peers: Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers (peer ID string, Peer object, list of either, or tuples with SessionPeerConfig). + scopes: Optional scopes this session should join, as IDs or Scope + objects. Each scope is created if it does not exist yet. Attaching + at creation avoids the asynchronous backfill a later + ``scope.add_sessions()`` triggers, since there is no history to + copy. Returns: A Session object with cached values from the API response. @@ -293,6 +309,8 @@ class HonchoAio(AsyncMetadataConfigMixin): body["configuration"] = configuration.model_dump(exclude_none=True) if peers is not None: body["peers"] = normalize_peers_to_dict(peers) + if scopes is not None: + body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes] data = await self._honcho._async_http_client.post( routes.sessions(self._honcho.workspace_id), body=body @@ -361,6 +379,88 @@ class HonchoAio(AsyncMetadataConfigMixin): return AsyncPage(data, SessionResponse, transform, fetch_next) + async def scope( + self, + id: str, # noqa: A002 + *, + metadata: dict[str, object] | None = None, + ) -> Scope: + """ + Get or create a scope with the given ID asynchronously. + + A scope is a named set of sessions that acts as a visibility boundary: + recall performed through the scope sees only what happened in its sessions, + while the underlying peer keeps its single unified representation of + everything. + + Args: + id: Unprefixed scope name, unique within the workspace. + metadata: Optional metadata dictionary to associate with this scope. + + Returns: + A Scope object for managing membership. + + Raises: + ValueError: If the scope ID is invalid. + """ + validate_scope_id(id) + await self._honcho._ensure_workspace_async() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + + data = await self._honcho._async_http_client.post( + routes.scopes(self._honcho.workspace_id), body=body + ) + scope_data = ScopeResponse.model_validate(data) + return Scope( + id, + self._honcho, + metadata=scope_data.metadata, + created_at=scope_data.created_at, + ) + + async def scopes( + self, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, + ) -> AsyncPage[ScopeResponse, Scope]: + """ + Get all scopes in the current workspace asynchronously. + + Args: + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. + """ + await self._honcho._ensure_workspace_async() + + async def fetch(next_page: int) -> dict[str, Any]: + query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + query["reverse"] = "true" + return await self._honcho._async_http_client.post( + routes.scopes_list(self._honcho.workspace_id), query=query + ) + + def transform(scope: ScopeResponse) -> Scope: + """Convert a scope API response into a Scope SDK object.""" + return Scope( + scope.id, + self._honcho, + metadata=scope.metadata, + created_at=scope.created_at, + ) + + async def fetch_next(next_page: int) -> AsyncPage[ScopeResponse, Scope]: + return AsyncPage( + await fetch(next_page), ScopeResponse, transform, fetch_next + ) + + return AsyncPage(await fetch(page), ScopeResponse, transform, fetch_next) + async def workspaces( self, filters: dict[str, object] | None = None, @@ -402,6 +502,79 @@ class HonchoAio(AsyncMetadataConfigMixin): """Delete a workspace asynchronously.""" await self._honcho._async_http_client.delete(routes.workspace(workspace_id)) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def chat( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + scope: str | list[str] | None = None, + ) -> BaseModel | str | None: + """Query the entire workspace asynchronously (see Honcho.chat).""" + await self._honcho._ensure_workspace_async() + resolved_session_id = resolve_id(session) + body: dict[str, Any] = {"query": query, "stream": False} + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + if scope is not None: + body["scope"] = scope + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema + + data = await self._honcho._async_http_client.post( + routes.workspace_chat(self._honcho.workspace_id), + body=body, + ) + content = data.get("content") + if not content: + return None + if isinstance(response_format, type): + return response_format.model_validate_json(content) + return content + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def chat_stream( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + scope: str | list[str] | None = None, + ) -> AsyncDialecticStreamResponse: + """Streaming variant of :meth:`chat` (async).""" + await self._honcho._ensure_workspace_async() + resolved_session_id = resolve_id(session) + body: dict[str, Any] = {"query": query, "stream": True} + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + if scope is not None: + body["scope"] = scope + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema + + async def stream_response() -> AsyncGenerator[str, None]: + async for chunk in parse_sse_astream( + self._honcho._async_http_client.stream( + "POST", + routes.workspace_chat(self._honcho.workspace_id), + body=body, + ) + ): + yield chunk + + return AsyncDialecticStreamResponse(stream_response()) + @validate_call async def search( self, @@ -412,12 +585,27 @@ class HonchoAio(AsyncMetadataConfigMixin): limit: int = Field( default=10, ge=1, le=100, description="Number of results to return" ), + *, + scope: str | ScopeBase | None = None, ) -> list[Message]: - """Search for messages in the current workspace asynchronously.""" + """Search for messages in the current workspace asynchronously. + + Args: + query: The search query to use + filters: Filters to scope the search. + limit: Number of results to return (1-100, default: 10) + scope: Optional scope (ID or Scope object) restricting the search to + that scope's member sessions. Mutually exclusive with a + ``session_id`` filter. A scope with no member sessions matches + nothing rather than everything. + """ await self._honcho._ensure_workspace_async() + body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit} + if scope is not None: + body["scope"] = validate_scope_id(resolve_id(scope)) data = await self._honcho._async_http_client.post( routes.workspace_search(self._honcho.workspace_id), - body={"query": query, "filters": filters, "limit": limit}, + body=body, ) return [ Message.from_api_response(MessageResponse.model_validate(item)) @@ -592,6 +780,8 @@ class PeerAio(AsyncMetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[TResponseFormat], @@ -604,6 +794,8 @@ class PeerAio(AsyncMetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: dict[str, Any] | None = None, @@ -616,6 +808,8 @@ class PeerAio(AsyncMetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, @@ -631,6 +825,11 @@ class PeerAio(AsyncMetadataConfigMixin): resolved_session_id = resolve_id(session) body: dict[str, Any] = {"query": query, "stream": False} + body.update( + scope_recall_fields( + scope=scope, sessions=sessions, session_id=resolved_session_id + ) + ) if target_id: body["target"] = target_id if resolved_session_id: @@ -659,6 +858,8 @@ class PeerAio(AsyncMetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, @@ -674,6 +875,11 @@ class PeerAio(AsyncMetadataConfigMixin): resolved_session_id = resolve_id(session) body: dict[str, Any] = {"query": query, "stream": True} + body.update( + scope_recall_fields( + scope=scope, sessions=sessions, session_id=resolved_session_id + ) + ) if target_id: body["target"] = target_id if resolved_session_id: @@ -834,13 +1040,22 @@ class PeerAio(AsyncMetadataConfigMixin): search_max_distance: float | None = Field(None, ge=0.0, le=1.0), include_most_frequent: bool | None = None, max_conclusions: int | None = Field(None, ge=1, le=100), + *, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, ) -> str: - """Get a subset of the representation of the peer asynchronously.""" + """Get a subset of the representation of the peer asynchronously. + + See Peer.representation for parameter details, including the depth caveat + on ``sessions``. + """ await self._peer._honcho._ensure_workspace_async() session_id = resolve_id(session) target_id = resolve_id(target) - body: dict[str, Any] = {} + body: dict[str, Any] = scope_recall_fields( + scope=scope, sessions=sessions, session_id=session_id + ) if session_id: body["session_id"] = session_id if target_id: @@ -1200,6 +1415,14 @@ class SessionAio(AsyncMetadataConfigMixin): None, description="A peer ID to get context from the perspective of.", ), + scope: str | ScopeBase | None = Field( + None, + description="A scope to use as the perspective source instead of a peer.", + ), + sessions: Sequence[str | SessionBase] | None = Field( + None, + description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them.", + ), limit_to_session: bool = Field( False, description="Whether to limit the representation to this session only.", @@ -1227,7 +1450,11 @@ class SessionAio(AsyncMetadataConfigMixin): description="Maximum number of conclusions to include in the representation.", ), ) -> SessionContext: - """Get optimized context for this session asynchronously.""" + """Get optimized context for this session asynchronously. + + See Session.context for parameter details, including the depth caveat on + ``sessions``. + """ await self._session._honcho._ensure_workspace_async() if peer_target is None and peer_perspective is not None: raise ValueError( @@ -1246,6 +1473,13 @@ class SessionAio(AsyncMetadataConfigMixin): query: dict[str, Any] = { "summary": summary, "limit_to_session": limit_to_session, + **scope_context_fields( + scope=scope, + sessions=sessions, + peer_target=peer_target, + peer_perspective=peer_perspective, + limit_to_session=limit_to_session, + ), } if tokens is not None: query["tokens"] = tokens @@ -1597,19 +1831,19 @@ class WorkspaceConclusionsAio: return await _aget_many_conclusions(self._workspace._honcho, conclusion_ids) -class ConclusionScopeAio: +class ConclusionsViewAio: """ - Async view of a ConclusionScope. + Async view of a ConclusionsView. - Access via `scope.aio`. Provides async versions of all ConclusionScope methods. - Shares state with the parent ConclusionScope instance. + Access via `view.aio`. Provides async versions of all ConclusionsView methods. + Shares state with the parent ConclusionsView instance. """ - __slots__: ClassVar[tuple[str, ...]] = ("_scope",) - _scope: "ConclusionScope" + __slots__: ClassVar[tuple[str, ...]] = ("_view",) + _view: "ConclusionsView" - def __init__(self, scope: "ConclusionScope") -> None: - self._scope = scope + def __init__(self, view: "ConclusionsView") -> None: + self._view = view async def list( self, @@ -1629,17 +1863,17 @@ class ConclusionScopeAio: https://honcho.dev/docs/v3/documentation/features/advanced/using-filters """ _reject_reserved_filter_keys( - filters, _SCOPE_RESERVED + ("session", "session_id") + filters, _VIEW_RESERVED + ("session", "session_id") ) resolved_session_id = resolve_id(session) filters = { - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, + "observer_id": self._view.observer, + "observed_id": self._view.observed, **({"session_id": resolved_session_id} if resolved_session_id else {}), **(filters or {}), } return await _alist_conclusions( - self._scope._honcho, filters, page=page, size=size, reverse=reverse + self._view._honcho, filters, page=page, size=size, reverse=reverse ) async def query( @@ -1659,11 +1893,11 @@ class ConclusionScopeAio: filters: Optional dictionary of additional filter criteria, merged with this scope's observer/observed (e.g. ``{"level": "deductive"}``). """ - _reject_reserved_filter_keys(filters, _SCOPE_RESERVED) - await self._scope._honcho._ensure_workspace_async() + _reject_reserved_filter_keys(filters, _VIEW_RESERVED) + await self._view._honcho._ensure_workspace_async() filters = { - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, + "observer_id": self._view.observer, + "observed_id": self._view.observed, **(filters or {}), } @@ -1675,8 +1909,8 @@ class ConclusionScopeAio: if distance is not None: body["distance"] = distance - data = await self._scope._honcho._async_http_client.post( - routes.conclusions_query(self._scope.workspace_id), + data = await self._view._honcho._async_http_client.post( + routes.conclusions_query(self._view.workspace_id), body=body, ) return [ @@ -1696,10 +1930,10 @@ class ConclusionScopeAio: observer/observed pair. Use ``honcho.aio.conclusions.get`` for a workspace-wide lookup. """ - return _require_scope( - await _aget_conclusion(self._scope._honcho, conclusion_id), - self._scope.observer, - self._scope.observed, + return _require_view( + await _aget_conclusion(self._view._honcho, conclusion_id), + self._view.observer, + self._view.observed, ) async def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: @@ -1715,11 +1949,11 @@ class ConclusionScopeAio: is not guaranteed to match the input either). """ return await _aget_many_conclusions( - self._scope._honcho, + self._view._honcho, conclusion_ids, extra_filters={ - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, + "observer_id": self._view.observer, + "observed_id": self._view.observed, }, ) @@ -1752,11 +1986,11 @@ class ConclusionScopeAio: Paginated response containing Conclusion objects """ return await _alist_conclusions( - self._scope._honcho, + self._view._honcho, { "source_ids": {"contains": conclusion_id}, - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, + "observer_id": self._view.observer, + "observed_id": self._view.observed, }, page=page, size=size, @@ -1765,9 +1999,9 @@ class ConclusionScopeAio: async def delete(self, conclusion_id: str) -> None: """Delete a conclusion by ID asynchronously.""" - await self._scope._honcho._ensure_workspace_async() - await self._scope._honcho._async_http_client.delete( - routes.conclusion(self._scope.workspace_id, conclusion_id) + await self._view._honcho._ensure_workspace_async() + await self._view._honcho._async_http_client.delete( + routes.conclusion(self._view.workspace_id, conclusion_id) ) async def create( @@ -1775,15 +2009,15 @@ class ConclusionScopeAio: conclusions: list[ConclusionCreateParams | dict[str, Any]], ) -> list[Conclusion]: """Create conclusions in this scope asynchronously.""" - await self._scope._honcho._ensure_workspace_async() + await self._view._honcho._ensure_workspace_async() def build_conclusion_payload( item: ConclusionCreateParams | dict[str, Any], ) -> dict[str, Any]: """Build a single conclusion create payload.""" payload: dict[str, Any] = { - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, + "observer_id": self._view.observer, + "observed_id": self._view.observed, } if isinstance(item, ConclusionCreateParams): payload["content"] = item.content @@ -1799,8 +2033,8 @@ class ConclusionScopeAio: conclusion_params = [build_conclusion_payload(c) for c in conclusions] - data = await self._scope._honcho._async_http_client.post( - routes.conclusions(self._scope.workspace_id), + data = await self._view._honcho._async_http_client.post( + routes.conclusions(self._view.workspace_id), body={"conclusions": conclusion_params}, ) return [ @@ -1817,8 +2051,8 @@ class ConclusionScopeAio: max_conclusions: int | None = None, ) -> str: """Get the computed representation for this scope asynchronously.""" - await self._scope._honcho._ensure_workspace_async() - body: dict[str, Any] = {"target": self._scope.observed} + await self._view._honcho._ensure_workspace_async() + body: dict[str, Any] = {"target": self._view.observed} if search_query is not None: body["search_query"] = search_query if search_top_k is not None: @@ -1830,9 +2064,99 @@ class ConclusionScopeAio: if max_conclusions is not None: body["max_conclusions"] = max_conclusions - data = await self._scope._honcho._async_http_client.post( - routes.peer_representation(self._scope.workspace_id, self._scope.observer), + data = await self._view._honcho._async_http_client.post( + routes.peer_representation(self._view.workspace_id, self._view.observer), body=body, ) response = RepresentationResponse.model_validate(data) return response.representation + + +class ScopeAio: + """ + Async view of a Scope. + + Access via `scope.aio`. Provides async versions of all Scope methods. + Shares state with the parent Scope instance. + """ + + __slots__: ClassVar[tuple[str, ...]] = ("_scope",) + _scope: "Scope" + + def __init__(self, scope: "Scope") -> None: + """Create an async view backed by a sync Scope.""" + self._scope = scope + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None: + """Add sessions to this scope asynchronously. + + See Scope.add_sessions for details, including the asynchronous backfill + that sessions with existing messages trigger. + """ + session_ids = resolve_scope_membership(sessions) + await self._scope._honcho._ensure_workspace_async() + await self._scope._honcho._async_http_client.post( + routes.scope_sessions(self._scope.workspace_id, self._scope.id), + body={"session_ids": session_ids}, + ) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + async def remove_session(self, session: str | SessionBase) -> None: + """Remove a session from this scope asynchronously. + + See Scope.remove_session for details on the asynchronous reconciliation. + """ + await self._scope._honcho._ensure_workspace_async() + await self._scope._honcho._async_http_client.delete( + routes.scope_session( + self._scope.workspace_id, self._scope.id, resolve_scope_session(session) + ) + ) + + async def sessions( + self, + page: int = 1, + size: int = 50, + *, + reverse: bool = False, + ) -> AsyncPage[SessionResponse, Session]: + """Get the sessions that are members of this scope asynchronously.""" + await self._scope._honcho._ensure_workspace_async() + + async def fetch(next_page: int) -> dict[str, Any]: + query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + query["reverse"] = "true" + return await self._scope._honcho._async_http_client.post( + routes.scope_sessions_list(self._scope.workspace_id, self._scope.id), + query=query, + ) + + def transform(response: SessionResponse) -> Session: + return Session( + response.id, + self._scope._honcho, + metadata=response.metadata, + configuration=response.configuration, + created_at=response.created_at, + is_active=response.is_active, + ) + + async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]: + return AsyncPage( + await fetch(next_page), SessionResponse, transform, fetch_next + ) + + return AsyncPage(await fetch(page), SessionResponse, transform, fetch_next) + + async def status(self) -> dict[str, ScopeBackfillJob]: + """Get the backfill/reconciliation progress for this scope asynchronously. + + See Scope.status for details. + """ + await self._scope._honcho._ensure_workspace_async() + data = await self._scope._honcho._async_http_client.get( + routes.scope_status(self._scope.workspace_id, self._scope.id) + ) + return ScopeStatusResponse.model_validate(data).backfill_status diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py index 46998118..25c3f097 100644 --- a/sdks/python/src/honcho/api_types.py +++ b/sdks/python/src/honcho/api_types.py @@ -276,6 +276,7 @@ class SessionCreateParams(BaseModel): metadata: dict[str, Any] | None = None peers: dict[str, SessionPeerConfig] | None = None configuration: SessionConfiguration | None = None + scopes: list[str] | None = None class SessionUpdateParams(BaseModel): @@ -295,6 +296,44 @@ class SessionListParams(BaseModel): filters: dict[str, Any] | None = None +# ============================================================================== +# Scope Types +# ============================================================================== + + +class ScopeResponse(BaseModel): + """Scope API response.""" + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute] + + id: str + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime.datetime + + +class ScopeBackfillJob(BaseModel): + """Backfill job state for one session in a scope. + + ``docs_copied`` is present only once the backfill for that session completes. + """ + + model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute] + + state: Literal["pending", "completed", "failed"] + updated_at: datetime.datetime + docs_copied: int | None = None + + +class ScopeStatusResponse(BaseModel): + """Scope backfill/reconciliation status API response. + + ``backfill_status`` is keyed by session ID and only contains sessions that + have had a backfill enqueued. + """ + + backfill_status: dict[str, ScopeBackfillJob] = Field(default_factory=dict) + + # ============================================================================== # Summary Types # ============================================================================== diff --git a/sdks/python/src/honcho/base.py b/sdks/python/src/honcho/base.py index a27a64ad..fb80b121 100644 --- a/sdks/python/src/honcho/base.py +++ b/sdks/python/src/honcho/base.py @@ -43,3 +43,20 @@ class SessionBase(BaseModel): workspace_id: str = Field( ..., min_length=1, description="Workspace ID for scoping operations" ) + + +class ScopeBase(BaseModel): + """Base class for Scope objects (sync and async variants). + + Use this type in method signatures to accept either a scope ID string or any + Scope object. + + Attributes: + id: Unprefixed scope name, unique within the workspace + workspace_id: Workspace ID for scoping operations + """ + + id: str = Field(..., min_length=1, description="Unprefixed name of this scope") + workspace_id: str = Field( + ..., min_length=1, description="Workspace ID for scoping operations" + ) diff --git a/sdks/python/src/honcho/client.py b/sdks/python/src/honcho/client.py index 8b241b60..d625fa8c 100644 --- a/sdks/python/src/honcho/client.py +++ b/sdks/python/src/honcho/client.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import os -from collections.abc import Mapping +from collections.abc import Generator, Mapping, Sequence from typing import Any, Literal import httpx @@ -16,21 +16,29 @@ from .api_types import ( PeerConfig, PeerResponse, QueueStatusResponse, + ScopeResponse, SessionConfiguration, SessionPeerConfig, SessionResponse, WorkspaceConfiguration, WorkspaceResponse, ) -from .base import PeerBase, SessionBase +from .base import PeerBase, ScopeBase, SessionBase from .conclusions import WorkspaceConclusions from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes from .message import Message from .mixins import MetadataConfigMixin from .pagination import SyncPage -from .peer import Peer +from .peer import Peer, serialize_response_format +from .scope import Scope from .session import Session -from .utils import normalize_peers_to_dict, resolve_id +from .types import DialecticStreamResponse +from .utils import ( + normalize_peers_to_dict, + parse_sse_stream, + resolve_id, + validate_scope_id, +) logger = logging.getLogger(__name__) @@ -420,6 +428,10 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul None, description="Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers.", ), + scopes: Sequence[str | ScopeBase] | None = Field( + None, + description="Optional scopes this session should join. Each scope is created if it does not exist yet.", + ), ) -> Session: """ Get or create a session with the given ID. @@ -434,6 +446,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul peers: Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers (peer ID string, Peer object, list of either, or tuples with SessionPeerConfig). + scopes: Optional scopes this session should join, as IDs or Scope + objects. Each scope is created if it does not exist yet. Attaching + at creation avoids the asynchronous backfill a later + ``scope.add_sessions()`` triggers, since there is no history to + copy. Returns: A Session object with cached metadata, configuration, created_at, and is_active. @@ -446,6 +463,8 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul body["configuration"] = configuration.model_dump(exclude_none=True) if peers is not None: body["peers"] = normalize_peers_to_dict(peers) + if scopes is not None: + body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes] data = self._http.post(routes.sessions(self.workspace_id), body=body) session_data = SessionResponse.model_validate(data) @@ -515,6 +534,97 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul return SyncPage(data, SessionResponse, transform, fetch_next) + @validate_call + def scope( + self, + id: str = Field( # noqa: A002 + ..., min_length=1, description="Unprefixed name for the scope" + ), + *, + metadata: dict[str, object] | None = Field( + None, + description="Optional metadata dictionary to associate with this scope.", + ), + ) -> Scope: + """ + Get or create a scope with the given ID. + + A scope is a named set of sessions that acts as a visibility boundary: + recall performed through the scope sees only what happened in its sessions, + while the underlying peer keeps its single unified representation of + everything. + + Args: + id: Unprefixed scope name, unique within the workspace. + metadata: Optional metadata dictionary to associate with this scope. + + Returns: + A Scope object for managing membership. + + Raises: + ValueError: If the scope ID is invalid. + + Example: + ```python + therapy = honcho.scope("therapy") + therapy.add_sessions([session_1, session_2]) + ``` + """ + validate_scope_id(id) + self._ensure_workspace() + body: dict[str, Any] = {"id": id} + if metadata is not None: + body["metadata"] = metadata + + data = self._http.post(routes.scopes(self.workspace_id), body=body) + scope_data = ScopeResponse.model_validate(data) + return Scope( + id, + self, + metadata=scope_data.metadata, + created_at=scope_data.created_at, + ) + + def scopes( + self, + *, + page: int = 1, + size: int = 50, + reverse: bool = False, + ) -> SyncPage[ScopeResponse, Scope]: + """ + Get all scopes in the current workspace. + + Args: + page: Page number (1-indexed). Default: 1. + size: Number of items per page. Default: 50. + reverse: If True, reverses the default ordering. Default: False. + + Returns: + A SyncPage of Scope objects representing all scopes in the workspace. + """ + self._ensure_workspace() + + def fetch(next_page: int) -> dict[str, Any]: + query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + query["reverse"] = "true" + return self._http.post(routes.scopes_list(self.workspace_id), query=query) + + def transform(scope: ScopeResponse) -> Scope: + """Convert a scope API response into a Scope SDK object.""" + return Scope( + scope.id, + self, + metadata=scope.metadata, + created_at=scope.created_at, + ) + + def fetch_next(next_page: int) -> SyncPage[ScopeResponse, Scope]: + return SyncPage(fetch(next_page), ScopeResponse, transform, fetch_next) + + return SyncPage(fetch(page), ScopeResponse, transform, fetch_next) + def workspaces( self, filters: dict[str, object] | None = None, @@ -583,6 +693,98 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul """ self._http.delete(routes.workspace(workspace_id)) + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def chat( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + scope: str | list[str] | None = None, + ) -> BaseModel | str | None: + """ + Query the entire workspace with a natural language question. + + Unlike peer.chat(), which queries a single peer's representation, this + searches across ALL peers and observations in the workspace β€” use it + for cross-peer analysis, common themes, or workspace-wide questions. + + Args: + query: The natural language question to ask. + session: Optional session to scope message retrieval to. + reasoning_level: Optional reasoning level: "minimal", "low", + "medium", "high", or "max" (default "low"). + response_format: Optional structure for the answer: a Pydantic + model class (returns a parsed instance) or a raw + JSON Schema dict (returns a JSON string). + scope: Optional scope name(s) restricting recall to those scopes' + member sessions. Mutually exclusive with `session`. + + Returns: + The synthesized answer, or None if no relevant information. + """ + self._ensure_workspace() + resolved_session_id = resolve_id(session) + body: dict[str, Any] = {"query": query, "stream": False} + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + if scope is not None: + body["scope"] = scope + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema + + data = self._http.post( + routes.workspace_chat(self.workspace_id), + body=body, + ) + content = data.get("content") + if not content: + return None + if isinstance(response_format, type): + return response_format.model_validate_json(content) + return content + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def chat_stream( + self, + query: str = Field(..., min_length=1, description="The natural language query"), + *, + session: str | SessionBase | None = None, + reasoning_level: Literal["minimal", "low", "medium", "high", "max"] + | None = None, + response_format: type[BaseModel] | dict[str, Any] | None = None, + scope: str | list[str] | None = None, + ) -> DialecticStreamResponse: + """Streaming variant of :meth:`chat`. See chat() for argument docs.""" + self._ensure_workspace() + resolved_session_id = resolve_id(session) + body: dict[str, Any] = {"query": query, "stream": True} + if resolved_session_id: + body["session_id"] = resolved_session_id + if reasoning_level: + body["reasoning_level"] = reasoning_level + if scope is not None: + body["scope"] = scope + response_format_schema = serialize_response_format(response_format) + if response_format_schema is not None: + body["response_format"] = response_format_schema + + def stream_response() -> Generator[str, None, None]: + yield from parse_sse_stream( + self._http.stream( + "POST", + routes.workspace_chat(self.workspace_id), + body=body, + ) + ) + + return DialecticStreamResponse(stream_response()) + @validate_call def search( self, @@ -593,6 +795,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul limit: int = Field( default=10, ge=1, le=100, description="Number of results to return" ), + *, + scope: str | ScopeBase | None = Field( + None, + description="Optional scope restricting the search to its member sessions", + ), ) -> list[Message]: """ Search for messages in the current workspace. @@ -603,15 +810,22 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul query: The search query to use filters: Filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters). limit: Number of results to return (1-100, default: 10) + scope: Optional scope (ID or Scope object) restricting the search to + that scope's member sessions. Mutually exclusive with a + ``session_id`` filter. A scope with no member sessions matches + nothing rather than everything. Returns: A list of Message objects representing the search results. Returns an empty list if no messages are found. """ self._ensure_workspace() + body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit} + if scope is not None: + body["scope"] = validate_scope_id(resolve_id(scope)) data = self._http.post( routes.workspace_search(self.workspace_id), - body={"query": query, "filters": filters, "limit": limit}, + body=body, ) return [ Message.from_api_response(MessageResponse.model_validate(item)) diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index f9c37b28..5dadbebd 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -15,31 +15,31 @@ from .pagination import SyncPage from .utils import resolve_id if TYPE_CHECKING: - from .aio import ConclusionScopeAio, WorkspaceConclusionsAio + from .aio import ConclusionsViewAio, WorkspaceConclusionsAio from .client import Honcho __all__ = [ "Conclusion", - "ConclusionScope", + "ConclusionsView", "ConclusionCreateParams", "WorkspaceConclusions", ] _LIST_PAGE_CAP = 100 -# Filter keys that define a conclusion scope (the observer/observed peer pair). -# They are set from the scope itself, so a caller must not pass them in `filters`. -_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id") +# Filter keys that define a conclusions view (the observer/observed peer pair). +# They are set from the view itself, so a caller must not pass them in `filters`. +_VIEW_RESERVED = ("observer", "observed", "observer_id", "observed_id") def _reject_reserved_filter_keys( filters: dict[str, Any] | None, reserved: tuple[str, ...] ) -> None: - """Raise if ``filters`` contains keys managed by the conclusion scope. + """Raise if ``filters`` contains keys managed by the conclusions view. The observer/observed peer pair (and, on ``list``, the session) is fixed by - the scope, so letting a user filter override it would silently return data - from a different scope than requested. Fail loud instead. + the view, so letting a user filter override it would silently return data + from a different pair than requested. Fail loud instead. """ if not filters: return @@ -51,7 +51,7 @@ def _reject_reserved_filter_keys( if "session" in reserved or "session_id" in reserved: guidance += "; use the session= parameter to filter by session" raise ValueError( - f"Filter key(s) {clash} are managed by this conclusion scope and " + f"Filter key(s) {clash} are managed by this conclusions view and " + f"cannot be passed in filters. {guidance}." ) @@ -73,7 +73,7 @@ def _get_conclusion(honcho: "Honcho", conclusion_id: str) -> Conclusion: return _conclusion_from_item(data) -def _require_scope( +def _require_view( conclusion: Conclusion, observer_id: str, observed_id: str ) -> Conclusion: if conclusion.observer_id != observer_id or conclusion.observed_id != observed_id: @@ -282,7 +282,7 @@ class WorkspaceConclusions: return f"WorkspaceConclusions(workspace_id={self.workspace_id!r})" -class ConclusionScope: +class ConclusionsView: """ Scoped access to conclusions for a specific observer/observed relationship. @@ -321,7 +321,7 @@ class ConclusionScope: observed: str, ): """ - Initialize a ConclusionScope. + Initialize a ConclusionsView. Args: honcho: The Honcho client instance @@ -335,12 +335,12 @@ class ConclusionScope: self.observed = observed @property - def aio(self) -> "ConclusionScopeAio": + def aio(self) -> "ConclusionsViewAio": """ - Access async versions of all ConclusionScope methods. + Access async versions of all ConclusionsView methods. - Returns a ConclusionScopeAio view that provides async versions of all methods - while sharing state with this ConclusionScope instance. + Returns a ConclusionsViewAio view that provides async versions of all methods + while sharing state with this ConclusionsView instance. Example: ```python @@ -350,9 +350,9 @@ class ConclusionScope: ``` """ # Import here to avoid circular import (aio.py imports from this module) - from .aio import ConclusionScopeAio + from .aio import ConclusionsViewAio - return ConclusionScopeAio(self) + return ConclusionsViewAio(self) def list( self, @@ -384,7 +384,7 @@ class ConclusionScope: Paginated response containing Conclusion objects """ _reject_reserved_filter_keys( - filters, _SCOPE_RESERVED + ("session", "session_id") + filters, _VIEW_RESERVED + ("session", "session_id") ) resolved_session_id = resolve_id(session) filters = { @@ -421,7 +421,7 @@ class ConclusionScope: Returns: List of matching Conclusion objects """ - _reject_reserved_filter_keys(filters, _SCOPE_RESERVED) + _reject_reserved_filter_keys(filters, _VIEW_RESERVED) self._honcho._ensure_workspace() filters = { "observer_id": self.observer, @@ -462,7 +462,7 @@ class ConclusionScope: observer/observed pair. Use ``honcho.conclusions.get`` for a workspace-wide lookup. """ - return _require_scope( + return _require_view( _get_conclusion(self._honcho, conclusion_id), self.observer, self.observed, @@ -662,6 +662,6 @@ class ConclusionScope: def __repr__(self) -> str: return ( - f"ConclusionScope(workspace_id={self.workspace_id!r}, " + f"ConclusionsView(workspace_id={self.workspace_id!r}, " f"observer={self.observer!r}, observed={self.observed!r})" ) diff --git a/sdks/python/src/honcho/http/routes.py b/sdks/python/src/honcho/http/routes.py index 3fdbd677..8295b994 100644 --- a/sdks/python/src/honcho/http/routes.py +++ b/sdks/python/src/honcho/http/routes.py @@ -16,6 +16,10 @@ def workspace(workspace_id: str) -> str: return f"/{API_VERSION}/workspaces/{workspace_id}" +def workspace_chat(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/chat" + + def workspace_search(workspace_id: str) -> str: return f"/{API_VERSION}/workspaces/{workspace_id}/search" @@ -102,6 +106,31 @@ def session_peer_config(workspace_id: str, session_id: str, peer_id: str) -> str return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config" +# Scope routes +def scopes(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes" + + +def scopes_list(workspace_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/list" + + +def scope_sessions(workspace_id: str, scope_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions" + + +def scope_sessions_list(workspace_id: str, scope_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list" + + +def scope_session(workspace_id: str, scope_id: str, session_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}" + + +def scope_status(workspace_id: str, scope_id: str) -> str: + return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/status" + + # Message routes def messages(workspace_id: str, session_id: str) -> str: return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages" diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index e004db6a..38edf269 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -6,7 +6,7 @@ from __future__ import annotations import datetime import logging import warnings -from collections.abc import Generator +from collections.abc import Generator, Sequence from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call @@ -22,14 +22,14 @@ from .api_types import ( SessionConfiguration, SessionResponse, ) -from .base import PeerBase, SessionBase -from .conclusions import ConclusionScope +from .base import PeerBase, ScopeBase, SessionBase +from .conclusions import ConclusionsView from .http import routes from .message import Message from .mixins import MetadataConfigMixin from .pagination import SyncPage from .types import DialecticStreamResponse -from .utils import parse_datetime, parse_sse_stream, resolve_id +from .utils import parse_datetime, parse_sse_stream, resolve_id, scope_recall_fields if TYPE_CHECKING: from .aio import PeerAio @@ -241,6 +241,8 @@ class Peer(PeerBase, MetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[TResponseFormat], @@ -253,6 +255,8 @@ class Peer(PeerBase, MetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: dict[str, Any] | None = None, @@ -265,6 +269,8 @@ class Peer(PeerBase, MetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, @@ -285,6 +291,19 @@ class Peer(PeerBase, MetadataConfigMixin): session: Optional session to scope the query to. If provided, only information from that session is considered. Can be a session ID string or a Session object. + scope: Optional scope(s) to confine the query to. A single scope answers + from that scope's own view of the target, including the + higher-order conclusions reasoned within it. A sequence of scopes + restricts recall to the union of their member sessions, which β€” + like ``sessions`` β€” yields only directly-stated conclusions. + Mutually exclusive with ``session`` and ``sessions``, and requires + a workspace-level key. + sessions: Optional allowlist of sessions to confine the query to, for + one-off questions spanning a handful of sessions. Recall is + limited to conclusions stated directly in those sessions: + conclusions produced by reasoning across sessions are excluded, + because their provenance cannot be proven to sit inside the + allowlist. Reach for a named ``scope`` when you need that depth. reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium", "high", or "max". Defaults to "low" if not provided. response_format: Optional structure for the answer. Pass a Pydantic @@ -296,12 +315,20 @@ class Peer(PeerBase, MetadataConfigMixin): Response string containing the answer (a JSON string when a schema dict was given), a parsed model instance when a Pydantic model class was given, or None if no relevant information. + + Raises: + ValueError: If ``scope`` is combined with ``session`` or ``sessions``. """ self._honcho._ensure_workspace() target_id = resolve_id(target) resolved_session_id = resolve_id(session) body: dict[str, Any] = {"query": query, "stream": False} + body.update( + scope_recall_fields( + scope=scope, sessions=sessions, session_id=resolved_session_id + ) + ) if target_id: body["target"] = target_id if resolved_session_id: @@ -330,6 +357,8 @@ class Peer(PeerBase, MetadataConfigMixin): *, target: str | PeerBase | None = None, session: str | SessionBase | None = None, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, reasoning_level: Literal["minimal", "low", "medium", "high", "max"] | None = None, response_format: type[BaseModel] | dict[str, Any] | None = None, @@ -350,6 +379,9 @@ class Peer(PeerBase, MetadataConfigMixin): session: Optional session to scope the query to. If provided, only information from that session is considered. Can be a session ID string or a Session object. + scope: Optional scope(s) to confine the query to. See :meth:`chat`. + sessions: Optional allowlist of sessions to confine the query to. See + :meth:`chat` for the depth caveat. reasoning_level: Optional reasoning level for the query: "minimal", "low", "medium", "high", or "max". Defaults to "low" if not provided. response_format: Optional structure for the answer: a Pydantic model @@ -360,12 +392,20 @@ class Peer(PeerBase, MetadataConfigMixin): Returns: DialecticStreamResponse object that can be iterated over and provides final response + + Raises: + ValueError: If ``scope`` is combined with ``session`` or ``sessions``. """ self._honcho._ensure_workspace() target_id = resolve_id(target) resolved_session_id = resolve_id(session) body: dict[str, Any] = {"query": query, "stream": True} + body.update( + scope_recall_fields( + scope=scope, sessions=sessions, session_id=resolved_session_id + ) + ) if target_id: body["target"] = target_id if resolved_session_id: @@ -633,6 +673,9 @@ class Peer(PeerBase, MetadataConfigMixin): search_max_distance: float | None = Field(None, ge=0.0, le=1.0), include_most_frequent: bool | None = None, max_conclusions: int | None = Field(None, ge=1, le=100), + *, + scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None, + sessions: Sequence[str | SessionBase] | None = None, ) -> str: """ Get a subset of the representation of the peer. @@ -646,10 +689,18 @@ class Peer(PeerBase, MetadataConfigMixin): search_max_distance: Maximum semantic distance for search results (0.0-1.0) include_most_frequent: Whether to include the most frequent conclusions max_conclusions: Maximum number of conclusions to include + scope: Optional scope(s) confining the representation. See + :meth:`chat`. Mutually exclusive with ``session`` and ``sessions``. + sessions: Optional allowlist of sessions confining the representation to + directly-stated conclusions from those sessions. See + :meth:`chat` for the depth caveat. Returns: A Representation string + Raises: + ValueError: If ``scope`` is combined with ``session`` or ``sessions``. + Example: ```python # Get global representation @@ -671,7 +722,9 @@ class Peer(PeerBase, MetadataConfigMixin): session_id = resolve_id(session) target_id = resolve_id(target) - body: dict[str, Any] = {} + body: dict[str, Any] = scope_recall_fields( + scope=scope, sessions=sessions, session_id=session_id + ) if session_id: body["session_id"] = session_id if target_id: @@ -764,7 +817,7 @@ class Peer(PeerBase, MetadataConfigMixin): return PeerContextResponse.model_validate(data) @property - def conclusions(self) -> ConclusionScope: + def conclusions(self) -> ConclusionsView: """ Access this peer's self-conclusions (where observer == observed == self). @@ -772,7 +825,7 @@ class Peer(PeerBase, MetadataConfigMixin): has made about themselves. Use this for self-conclusion scenarios. Returns: - A ConclusionScope scoped to this peer's self-conclusions + A ConclusionsView scoped to this peer's self-conclusions Example: ```python @@ -786,9 +839,9 @@ class Peer(PeerBase, MetadataConfigMixin): peer.conclusions.delete("obs-123") ``` """ - return ConclusionScope(self._honcho, self.workspace_id, self.id, self.id) + return ConclusionsView(self._honcho, self.workspace_id, self.id, self.id) - def conclusions_of(self, target: str | PeerBase) -> ConclusionScope: + def conclusions_of(self, target: str | PeerBase) -> ConclusionsView: """ Access conclusions this peer has made about another peer. @@ -799,7 +852,7 @@ class Peer(PeerBase, MetadataConfigMixin): target: The target peer (either a Peer object or peer ID string) Returns: - A ConclusionScope scoped to this peer's conclusions of the target + A ConclusionsView scoped to this peer's conclusions of the target Example: ```python @@ -817,7 +870,7 @@ class Peer(PeerBase, MetadataConfigMixin): ``` """ target_id = target.id if isinstance(target, PeerBase) else target - return ConclusionScope(self._honcho, self.workspace_id, self.id, target_id) + return ConclusionsView(self._honcho, self.workspace_id, self.id, target_id) def __repr__(self) -> str: """ diff --git a/sdks/python/src/honcho/scope.py b/sdks/python/src/honcho/scope.py new file mode 100644 index 00000000..14a5752b --- /dev/null +++ b/sdks/python/src/honcho/scope.py @@ -0,0 +1,233 @@ +# pyright: reportPrivateUsage=false +"""Sync Scope class for Honcho SDK.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, PrivateAttr, validate_call + +from .api_types import ScopeBackfillJob, ScopeStatusResponse, SessionResponse +from .base import ScopeBase, SessionBase +from .http import routes +from .pagination import SyncPage +from .session import Session +from .utils import resolve_scope_membership, resolve_scope_session + +if TYPE_CHECKING: + from .aio import ScopeAio + from .client import Honcho + +logger = logging.getLogger(__name__) + +__all__ = ["Scope"] + + +class Scope(ScopeBase): + """ + Represents a scope in Honcho. + + A scope is a named set of sessions that acts as a visibility boundary. Recall + performed through a scope sees only what happened in that scope's sessions, + while the underlying peer keeps its single unified representation across + everything it has ever participated in. + + Membership changes are applied asynchronously: adding a session that already + has messages copies its existing conclusions into the scope, and removing one + reconciles them back out. Poll :meth:`status` to watch that settle. + + Attributes: + id: Unprefixed scope name, unique within the workspace + workspace_id: Workspace ID for scoping operations + metadata: Cached metadata for this scope. May be stale if not recently + fetched. + created_at: When this scope was created, if known + + Example: + ```python + therapy = honcho.scope("therapy") + therapy.add_sessions([session_1, session_2]) + + # Ask a question answered only from the therapy sessions + answer = user.chat("What is stressing them out?", scope="therapy") + ``` + """ + + _metadata: dict[str, Any] | None = PrivateAttr(default=None) + _created_at: datetime | None = PrivateAttr(default=None) + _honcho: "Honcho" = PrivateAttr() + + @property + def metadata(self) -> dict[str, Any] | None: + """Cached metadata for this scope. May be stale if not recently fetched.""" + return self._metadata + + @property + def created_at(self) -> datetime | None: + """When this scope was created. Only available if fetched from the API.""" + return self._created_at + + def __init__( + self, + scope_id: str, + honcho: "Honcho", + *, + metadata: dict[str, Any] | None = None, + created_at: datetime | None = None, + ) -> None: + """ + Initialize a new Scope. + + **Do not call this directly β€” use** ``honcho.scope()``. + + Args: + scope_id: Unprefixed scope name, unique within the workspace + honcho: Honcho client instance + metadata: Cached metadata, if already fetched + created_at: Creation timestamp, if already fetched + """ + super().__init__( + id=scope_id, + workspace_id=honcho.workspace_id, + ) + self._honcho = honcho + self._metadata = metadata + self._created_at = created_at + + @property + def aio(self) -> "ScopeAio": + """ + Access async versions of all Scope methods. + + Returns a ScopeAio view that provides async versions of all methods while + sharing state with this Scope instance. + + Example: + ```python + await scope.aio.add_sessions(["session-1"]) + status = await scope.aio.status() + ``` + """ + # Import here to avoid circular import (aio.py imports this module) + from .aio import ScopeAio + + return ScopeAio(self) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None: + """ + Add sessions to this scope. + + Every named session must already exist. Adding a session that is already + a member is a no-op. + + Sessions that already hold messages are backfilled into the scope + asynchronously, so recall through this scope may not reflect their history + immediately β€” poll :meth:`status` to watch that complete. + + Args: + sessions: Sessions to add, as ID strings or Session objects. At most + 100 per call, matching the server's limit; split larger membership + changes into separate calls so a failure names the batch that + failed. + + Raises: + ValueError: If no sessions are given, or more than 100. + """ + session_ids = resolve_scope_membership(sessions) + self._honcho._ensure_workspace() + self._honcho._http.post( + routes.scope_sessions(self.workspace_id, self.id), + body={"session_ids": session_ids}, + ) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) + def remove_session(self, session: str | SessionBase) -> None: + """ + Remove a session from this scope. + + Conclusions copied or derived while the session was a member are + reconciled out asynchronously, and the scope's peer card is rebuilt from + whatever evidence remains. Poll :meth:`status` to watch that settle. + + Args: + session: Session to remove, as an ID string or a Session object + """ + self._honcho._ensure_workspace() + self._honcho._http.delete( + routes.scope_session( + self.workspace_id, self.id, resolve_scope_session(session) + ) + ) + + def sessions( + self, + page: int = 1, + size: int = 50, + *, + reverse: bool = False, + ) -> SyncPage[SessionResponse, Session]: + """ + Get the sessions that are members of this scope. + + Ordered by how long each session has been a member β€” longest-standing + first, or most recently added first when ``reverse`` is True. + + Args: + page: Page number (1-indexed) + size: Number of results per page + reverse: If True, reverses the default ordering. Default: False. + + Returns: + Paginated response containing Session objects + """ + self._honcho._ensure_workspace() + + def fetch(next_page: int) -> dict[str, Any]: + query: dict[str, Any] = {"page": next_page, "size": size} + if reverse: + query["reverse"] = "true" + return self._honcho._http.post( + routes.scope_sessions_list(self.workspace_id, self.id), + query=query, + ) + + def transform(response: SessionResponse) -> Session: + return Session( + response.id, + self._honcho, + metadata=response.metadata, + configuration=response.configuration, + created_at=response.created_at, + is_active=response.is_active, + ) + + def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]: + return SyncPage(fetch(next_page), SessionResponse, transform, fetch_next) + + return SyncPage(fetch(page), SessionResponse, transform, fetch_next) + + def status(self) -> dict[str, ScopeBackfillJob]: + """ + Get the backfill/reconciliation progress for this scope. + + Use this after a membership change to tell "the scope knows nothing about + that session yet" apart from "the scope has caught up and there is + genuinely nothing to recall". + + Returns: + Per-session backfill state, keyed by session ID. Only sessions that + have had a backfill enqueued appear; an empty dict means none have. + """ + self._honcho._ensure_workspace() + data = self._honcho._http.get(routes.scope_status(self.workspace_id, self.id)) + return ScopeStatusResponse.model_validate(data).backfill_status + + def __repr__(self) -> str: + return f"Scope(id={self.id!r}, workspace_id={self.workspace_id!r})" + + def __str__(self) -> str: + return self.id diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py index 3b1b0f14..2f1838c9 100644 --- a/sdks/python/src/honcho/session.py +++ b/sdks/python/src/honcho/session.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging +from collections.abc import Sequence from datetime import datetime from typing import TYPE_CHECKING, Any @@ -20,7 +21,7 @@ from .api_types import ( SessionPeerConfig, SessionResponse, ) -from .base import PeerBase, SessionBase +from .base import PeerBase, ScopeBase, SessionBase from .http import routes from .message import Message from .mixins import MetadataConfigMixin @@ -32,6 +33,7 @@ from .utils import ( normalize_peers_to_dict, prepare_file_for_upload, resolve_id, + scope_context_fields, ) if TYPE_CHECKING: @@ -574,6 +576,14 @@ class Session(SessionBase, MetadataConfigMixin): None, description="A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.", ), + scope: str | ScopeBase | None = Field( + None, + description="A scope to use as the perspective source instead of a peer: `peer_target`'s representation and card are read from what that scope observed. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace-level key.", + ), + sessions: Sequence[str | SessionBase] | None = Field( + None, + description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them. Mutually exclusive with `scope` and `limit_to_session`.", + ), limit_to_session: bool = Field( False, description="Whether to limit the representation to this session only. If True, only conclusions from this session will be included.", @@ -616,6 +626,11 @@ class Session(SessionBase, MetadataConfigMixin): peer_target: A peer ID to get context for. search_query: A query string for semantic search. peer_perspective: A peer ID to get context from the perspective of. + scope: A scope to read `peer_target`'s representation and card from. + sessions: An allowlist of sessions confining `peer_target`'s + representation. Recall is limited to conclusions stated directly in + those sessions, and the peer card is omitted, since neither derived + conclusions nor cards carry provable per-session provenance. limit_to_session: Whether to limit the representation to this session only. search_top_k: Number of semantically relevant facts to return. search_max_distance: Maximum semantic distance for search results. @@ -627,6 +642,11 @@ class Session(SessionBase, MetadataConfigMixin): summary, if available, that maximizes conversational context while respecting the token limit + Raises: + ValueError: If `peer_target` is missing when required, or if `scope`, + `sessions`, `peer_perspective`, and `limit_to_session` are combined + in ways the server rejects. + Note: Token counting is performed using tiktoken. For models using different tokenizers, you may need to adjust the token limit accordingly. @@ -650,6 +670,13 @@ class Session(SessionBase, MetadataConfigMixin): query: dict[str, Any] = { "summary": summary, "limit_to_session": limit_to_session, + **scope_context_fields( + scope=scope, + sessions=sessions, + peer_target=peer_target, + peer_perspective=peer_perspective, + limit_to_session=limit_to_session, + ), } if tokens is not None: query["tokens"] = tokens diff --git a/sdks/python/src/honcho/utils/__init__.py b/sdks/python/src/honcho/utils/__init__.py index aac9221b..90810763 100644 --- a/sdks/python/src/honcho/utils/__init__.py +++ b/sdks/python/src/honcho/utils/__init__.py @@ -6,6 +6,15 @@ from .datetime import datetime_to_iso, parse_datetime from .file_upload import normalize_file_input, prepare_file_for_upload from .peers import normalize_peers_to_dict from .resolve import resolve_id +from .scopes import ( + resolve_scope_membership, + resolve_scope_option, + resolve_scope_session, + resolve_session_allowlist, + scope_context_fields, + scope_recall_fields, + validate_scope_id, +) from .sse import SSEStreamParser, parse_sse_astream, parse_sse_chunk, parse_sse_stream __all__ = [ @@ -19,4 +28,11 @@ __all__ = [ "parse_sse_stream", "prepare_file_for_upload", "resolve_id", + "resolve_scope_membership", + "resolve_scope_option", + "resolve_scope_session", + "resolve_session_allowlist", + "scope_context_fields", + "scope_recall_fields", + "validate_scope_id", ] diff --git a/sdks/python/src/honcho/utils/resolve.py b/sdks/python/src/honcho/utils/resolve.py index db70fc01..30eaf811 100644 --- a/sdks/python/src/honcho/utils/resolve.py +++ b/sdks/python/src/honcho/utils/resolve.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, overload if TYPE_CHECKING: - from ..base import PeerBase, SessionBase + from ..base import PeerBase, ScopeBase, SessionBase @overload @@ -17,12 +17,12 @@ def resolve_id(obj: str) -> str: ... @overload -def resolve_id(obj: "PeerBase | SessionBase") -> str: ... +def resolve_id(obj: "PeerBase | SessionBase | ScopeBase") -> str: ... -def resolve_id(obj: "str | PeerBase | SessionBase | None") -> str | None: +def resolve_id(obj: "str | PeerBase | SessionBase | ScopeBase | None") -> str | None: """ - Resolve an ID from a string, PeerBase, SessionBase, or None. + Resolve an ID from a string, PeerBase, SessionBase, ScopeBase, or None. This utility function extracts the ID from an object that may be: - A string (returned as-is) diff --git a/sdks/python/src/honcho/utils/scopes.py b/sdks/python/src/honcho/utils/scopes.py new file mode 100644 index 00000000..4a0f6ef8 --- /dev/null +++ b/sdks/python/src/honcho/utils/scopes.py @@ -0,0 +1,296 @@ +"""Scope and session-allowlist option handling for the Honcho Python SDK. + +The ``scope`` and ``sessions`` options appear on several read surfaces (chat, +representation, session context, search). Their validation and their wire +translation live here so those surfaces cannot drift apart β€” the server enforces +the same exclusions with a 422, and this raises before the round trip. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from .resolve import resolve_id + +if TYPE_CHECKING: + from ..base import ScopeBase, SessionBase + +__all__ = [ + "MAX_SCOPES_PER_OPTION", + "MAX_SESSIONS_PER_ADD", + "MAX_SESSION_ALLOWLIST_ENTRIES", + "resolve_scope_membership", + "resolve_scope_option", + "resolve_scope_session", + "resolve_session_allowlist", + "scope_context_fields", + "scope_recall_fields", + "validate_scope_id", +] + +# Scope IDs are stored server-side as peer names with this prefix prepended, so +# they must leave room for it within the 512-character peer name limit. +_SCOPE_PEER_PREFIX = "scope." +MAX_SCOPE_ID_LENGTH = 512 - len(_SCOPE_PEER_PREFIX) + +MAX_SCOPES_PER_OPTION = 100 +MAX_SESSION_ALLOWLIST_ENTRIES = 1000 +# The server accepts at most this many sessions per membership call. +MAX_SESSIONS_PER_ADD = 100 + +_RESOURCE_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$" + + +def validate_scope_id(value: str) -> str: + """Validate an unprefixed scope ID. + + Args: + value: The scope ID as the caller supplied it. + + Returns: + The validated scope ID, unchanged. + + Raises: + ValueError: If the ID is empty, too long, carries the reserved prefix, + or contains characters outside the resource-name charset. + """ + if not 1 <= len(value) <= MAX_SCOPE_ID_LENGTH: + raise ValueError( + f"Scope ID must be between 1 and {MAX_SCOPE_ID_LENGTH} characters" + ) + # Checked before the charset: the reserved prefix contains '.', which is + # itself outside the charset, so a charset-first check would report the + # charset instead of the real mistake for a double-prefixed ID. + if value.startswith(_SCOPE_PEER_PREFIX): + raise ValueError( + f"Scope ID must not start with the reserved prefix '{_SCOPE_PEER_PREFIX}' (scope IDs are unprefixed)" + ) + if not re.fullmatch(_RESOURCE_NAME_PATTERN, value): + raise ValueError(f"Scope ID must match pattern {_RESOURCE_NAME_PATTERN}") + return value + + +def resolve_scope_option( + scope: "str | ScopeBase | Sequence[str | ScopeBase]", +) -> str | list[str]: + """Resolve the ``scope`` read option to its wire value. + + A single scope stays a string; a sequence becomes a list of IDs. The two + shapes mean different things to the server β€” one scope reads that scope's own + view, a list restricts recall to the union of their member sessions β€” so the + distinction is preserved rather than normalized away. + + Args: + scope: One scope (ID or ``Scope``) or a sequence of them. + + Returns: + A single validated scope ID, or a list of them. + + Raises: + ValueError: On an empty sequence, an over-cap sequence, or an invalid ID. + """ + # ``str`` is itself a Sequence, so both single-scope forms β€” an ID and a + # ``Scope`` β€” are taken first; whatever remains is the list form. + if isinstance(scope, str) or not isinstance(scope, Sequence): + return validate_scope_id(resolve_id(scope)) + + ids = [validate_scope_id(resolve_id(entry)) for entry in scope] + if not ids: + # An empty list would resolve to an empty allowlist server-side and + # silently recall nothing, which is never the intent. + raise ValueError("scope must name at least one scope") + if len(ids) > MAX_SCOPES_PER_OPTION: + raise ValueError(f"scope can name at most {MAX_SCOPES_PER_OPTION} scopes") + return ids + + +def resolve_session_allowlist( + sessions: "Sequence[str | SessionBase]", +) -> list[str]: + """Resolve the ``sessions`` allowlist option to a list of session IDs. + + Args: + sessions: Sessions to allow, as IDs or ``Session`` objects. + + Returns: + The session IDs, in the order given. + + Raises: + ValueError: On an empty list or one over the server's cap. + """ + ids = [resolve_id(entry) for entry in sessions] + if not ids: + # The server treats an empty allowlist as fail-closed (recalls nothing), + # so an empty list here is a caller mistake rather than a query. + raise ValueError("sessions must name at least one session") + if len(ids) > MAX_SESSION_ALLOWLIST_ENTRIES: + raise ValueError( + f"sessions can name at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions" + ) + return ids + + +def _validate_session_id(value: str) -> str: + """Validate a session ID against the charset the server accepts. + + Args: + value: The session ID as the caller supplied it. + + Returns: + The validated session ID, unchanged. + + Raises: + ValueError: If the ID is empty or contains characters outside the + resource-name charset. + """ + if not value: + raise ValueError("Session ID must be a non-empty string") + if not re.fullmatch(_RESOURCE_NAME_PATTERN, value): + raise ValueError(f"Session ID must match pattern {_RESOURCE_NAME_PATTERN}") + return value + + +def resolve_scope_session(session: "str | SessionBase") -> str: + """Resolve and validate a single session ID for a scope membership change. + + Validated rather than passed through because this ID is interpolated into a + request *path*: an unvalidated value silently changes which resource the + request addresses. ``valid-session?typo`` would target ``valid-session`` + with a stray query string, removing the wrong session from the scope and + triggering reconciliation against it. + + Args: + session: The session, as an ID or a ``Session`` object. + + Returns: + The validated session ID. + + Raises: + ValueError: If the ID is empty or malformed. + """ + return _validate_session_id(resolve_id(session)) + + +def resolve_scope_membership( + sessions: "Sequence[str | SessionBase]", +) -> list[str]: + """Resolve a scope membership change to a list of session IDs. + + Capped at the server's per-call limit rather than silently chunking, so a + rejected batch is the batch the caller passed. + + Args: + sessions: Sessions to add, as IDs or ``Session`` objects. + + Returns: + The session IDs, in the order given. + + Raises: + ValueError: On an empty list, one over the server's per-call cap, or a + malformed session ID. + """ + ids = [_validate_session_id(resolve_id(session)) for session in sessions] + if not ids: + raise ValueError("At least one session must be given") + if len(ids) > MAX_SESSIONS_PER_ADD: + raise ValueError( + f"At most {MAX_SESSIONS_PER_ADD} sessions can be added per call" + ) + return ids + + +def scope_context_fields( + *, + scope: "str | ScopeBase | None", + sessions: "Sequence[str | SessionBase] | None", + peer_target: str | None, + peer_perspective: str | None, + limit_to_session: bool, +) -> dict[str, Any]: + """Build the query fields for ``scope``/``sessions`` on the context route. + + Unlike the recall endpoints, session context takes these as query parameters + β€” ``sessions`` is sent as a repeated parameter, not as a ``filters`` body. + + Only a single scope is accepted: a scope is the *perspective source* for the + target's representation and card, which is one observer, so a list has no + meaning here. + + Args: + scope: The ``scope`` option, if given. + sessions: The ``sessions`` allowlist option, if given. + peer_target: The observed peer. Required by either option, since both only + reach the representation and there is none without a target. + peer_perspective: The observing peer, if given β€” a scope replaces it. + limit_to_session: Whether recall is already pinned to this session alone. + + Returns: + The fields to merge into the query. Empty when neither option is set. + + Raises: + ValueError: If either option is combined with something it contradicts, or + used without ``peer_target``. + """ + # A scope already determines what the context can see, and limit_to_session + # already pins recall to this session alone, so combining them with a + # perspective or an allowlist is a contradiction rather than a narrowing. + # Raised here so the caller does not pay a round trip for a 422. + if sessions is not None: + if scope is not None: + raise ValueError("`sessions` and `scope` are mutually exclusive") + if limit_to_session: + raise ValueError("`sessions` and `limit_to_session` are mutually exclusive") + if peer_target is None: + raise ValueError( + "You must provide a `peer_target` when `sessions` is provided" + ) + return {"sessions": resolve_session_allowlist(sessions)} + + if scope is None: + return {} + + if peer_perspective is not None: + raise ValueError("`scope` and `peer_perspective` are mutually exclusive") + if peer_target is None: + raise ValueError("You must provide a `peer_target` when `scope` is provided") + return {"scope": validate_scope_id(resolve_id(scope))} + + +def scope_recall_fields( + *, + scope: "str | ScopeBase | Sequence[str | ScopeBase] | None", + sessions: "Sequence[str | SessionBase] | None", + session_id: str | None = None, +) -> dict[str, Any]: + """Build the request-body fields for the ``scope``/``sessions`` options. + + ``sessions`` is sugar: it goes on the wire as the constrained + ``filters: {"session_id": [...]}`` body the recall endpoints accept, never as + a field of its own, which the server would reject as an unknown key. + + Args: + scope: The ``scope`` option, if given. + sessions: The ``sessions`` allowlist option, if given. + session_id: A single session already set on the request, if any β€” a scope + already determines what can be seen, so the two conflict. + + Returns: + The fields to merge into the request body. Empty when neither option is + set. + + Raises: + ValueError: If ``scope`` is combined with ``sessions`` or ``session_id``, + or if either option is itself invalid. + """ + if scope is None: + if sessions is None: + return {} + return {"filters": {"session_id": resolve_session_allowlist(sessions)}} + + if sessions is not None: + raise ValueError("`scope` and `sessions` are mutually exclusive") + if session_id is not None: + raise ValueError("`scope` and `session` are mutually exclusive") + return {"scope": resolve_scope_option(scope)} diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index 8d0e5ec1..1043d547 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.4.0] - 2026-08-25 + +### Added + +- Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+). +- `scope` option on `peer.chat()` / `chatStream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`. +- Workspace-level chat: `honcho.chat()` / `honcho.chatStream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoningLevel`, and `responseFormat` options as `peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+). + +### Changed + +- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair. + ## [2.3.0] - 2026-08-10 ### Added diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts index dcb9819f..5632aa0c 100644 --- a/sdks/typescript/__tests__/conclusions.test.ts +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -1,7 +1,7 @@ /** * Conclusions Tests * - * Tests for Conclusion operations via ConclusionScope. + * Tests for Conclusion operations via ConclusionsView. * * Endpoints covered: * - POST /v3/workspaces/:workspaceId/conclusions (create conclusions) @@ -11,7 +11,7 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test' -import { Honcho, Conclusion, ConclusionScope, WorkspaceConclusions } from '../src' +import { Honcho, Conclusion, ConclusionsView, WorkspaceConclusions } from '../src' import { NotFoundError } from '../src/http/errors' import { createTestClient, requireServer } from './setup' import { assertConclusionShape } from './helpers' @@ -32,16 +32,16 @@ describe('Conclusions', () => { }) // =========================================================================== - // ConclusionScope Access + // ConclusionsView Access // =========================================================================== - describe('ConclusionScope access', () => { + describe('ConclusionsView access', () => { test('peer.conclusions returns self-scope', async () => { const peer = await client.peer('self-scope-peer') const scope = peer.conclusions - expect(scope).toBeInstanceOf(ConclusionScope) + expect(scope).toBeInstanceOf(ConclusionsView) expect(scope.observer).toBe(peer.id) expect(scope.observed).toBe(peer.id) expect(scope.workspaceId).toBe(client.workspaceId) @@ -290,7 +290,7 @@ describe('Conclusions', () => { for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) { await expect( peer.conclusions.list({ filters: { [key]: 'someone-else' } }) - ).rejects.toThrow(/managed by this conclusion scope/) + ).rejects.toThrow(/managed by this conclusions view/) } }) @@ -299,10 +299,10 @@ describe('Conclusions', () => { await expect( peer.conclusions.list({ filters: { session_id: 'sess' } }) - ).rejects.toThrow(/managed by this conclusion scope/) + ).rejects.toThrow(/managed by this conclusions view/) await expect( peer.conclusions.list({ filters: { session: 'sess' } }) - ).rejects.toThrow(/managed by this conclusion scope/) + ).rejects.toThrow(/managed by this conclusions view/) }) test('query rejects observer/observed scope keys in filters', async () => { @@ -311,7 +311,7 @@ describe('Conclusions', () => { for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) { await expect( peer.conclusions.query('q', 10, undefined, { [key]: 'someone-else' }) - ).rejects.toThrow(/managed by this conclusion scope/) + ).rejects.toThrow(/managed by this conclusions view/) } }) @@ -628,16 +628,16 @@ describe('Conclusions', () => { }) // =========================================================================== - // ConclusionScope toString + // ConclusionsView toString // =========================================================================== - describe('ConclusionScope toString', () => { + describe('ConclusionsView toString', () => { test('returns readable format', async () => { const peer = await client.peer('scope-tostring-peer') const str = peer.conclusions.toString() - expect(str).toContain('ConclusionScope') + expect(str).toContain('ConclusionsView') expect(str).toContain(peer.id) expect(str).toContain(client.workspaceId) }) diff --git a/sdks/typescript/__tests__/http-client.test.ts b/sdks/typescript/__tests__/http-client.test.ts index 06d80230..878869b9 100644 --- a/sdks/typescript/__tests__/http-client.test.ts +++ b/sdks/typescript/__tests__/http-client.test.ts @@ -237,6 +237,39 @@ describe('URL building', () => { expect(url.searchParams.get('present')).toBe('value') expect(url.searchParams.has('missing')).toBe(false) }) + + test('sends array query parameters as repeated params, not comma-joined', async () => { + let capturedURL = '' + globalThis.fetch = async (url) => { + capturedURL = url.toString() + return mockResponse({ ok: true }) + } + + await client.get('/v1/test', { + query: { sessions: ['session-a', 'session-b'] }, + }) + + const url = new URL(capturedURL) + // The API reads list-valued params as ?k=a&k=b. A comma-joined single value + // would arrive as one malformed entry. + expect(url.searchParams.getAll('sessions')).toEqual([ + 'session-a', + 'session-b', + ]) + }) + + test('an empty array query parameter contributes nothing', async () => { + let capturedURL = '' + globalThis.fetch = async (url) => { + capturedURL = url.toString() + return mockResponse({ ok: true }) + } + + await client.get('/v1/test', { query: { sessions: [] } }) + + const url = new URL(capturedURL) + expect(url.searchParams.has('sessions')).toBe(false) + }) }) // ============================================================================= diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts index fca85910..cde6a0ad 100644 --- a/sdks/typescript/__tests__/peer.test.ts +++ b/sdks/typescript/__tests__/peer.test.ts @@ -671,7 +671,7 @@ describe('Peer', () => { // =========================================================================== describe('Conclusion scope access', () => { - test('conclusions property returns ConclusionScope for self', async () => { + test('conclusions property returns ConclusionsView for self', async () => { const peer = await client.peer('self-conclusions-peer') const scope = peer.conclusions @@ -681,7 +681,7 @@ describe('Peer', () => { expect(scope.workspaceId).toBe(client.workspaceId) }) - test('conclusionsOf returns ConclusionScope for target', async () => { + test('conclusionsOf returns ConclusionsView for target', async () => { const observer = await client.peer('obs-conclusions-peer') const target = await client.peer('target-conclusions-peer') diff --git a/sdks/typescript/__tests__/scope.unit.test.ts b/sdks/typescript/__tests__/scope.unit.test.ts new file mode 100644 index 00000000..b4b9ebab --- /dev/null +++ b/sdks/typescript/__tests__/scope.unit.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, test } from 'bun:test' +import { ZodError } from 'zod' +import type { HonchoHTTPClient } from '../src/http/client' +import { Peer } from '../src/peer' +import { Scope } from '../src/scope' +import { Session } from '../src/session' +import type { ScopeStatusResponse } from '../src/types/api' + +/** + * Capture the body of the single request a call makes, so the wire shape the + * server actually receives is asserted rather than the SDK's own options. + */ +function capturingHttp(response: unknown): { + http: HonchoHTTPClient + body: () => Record | undefined + query: () => Record | undefined + path: () => string | undefined +} { + let capturedBody: Record | undefined + let capturedQuery: Record | undefined + let capturedPath: string | undefined + const http = { + post: async ( + path: string, + options?: { body?: Record } + ) => { + capturedPath = path + capturedBody = options?.body + return response + }, + get: async ( + path: string, + options?: { query?: Record } + ) => { + capturedPath = path + capturedQuery = options?.query + return response + }, + delete: async (path: string) => { + capturedPath = path + return undefined + }, + } as unknown as HonchoHTTPClient + return { + http, + body: () => capturedBody, + query: () => capturedQuery, + path: () => capturedPath, + } +} + +describe('sessions allowlist sugar', () => { + test('chat sends `sessions` as a session_id filter, not a bare field', async () => { + const { http, body } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.chat('what happened?', { + sessions: ['session-a', new Session('session-b', 'workspace-1', http)], + }) + + expect(body()).toMatchObject({ + filters: { session_id: ['session-a', 'session-b'] }, + }) + // The sugar must not leak through as its own wire field β€” the server would + // reject an unknown key. + expect(body()).not.toHaveProperty('sessions') + }) + + test('representation sends `sessions` as a session_id filter', async () => { + const { http, body } = capturingHttp({ representation: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.representation({ sessions: ['session-a'] }) + + expect(body()).toMatchObject({ filters: { session_id: ['session-a'] } }) + }) + + test('an empty allowlist is rejected rather than silently recalling nothing', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await expect(peer.chat('q', { sessions: [] })).rejects.toBeInstanceOf( + ZodError + ) + }) +}) + +describe('scope read option', () => { + test('a single scope passes through as `scope`', async () => { + const { http, body } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.chat('q', { scope: 'therapy' }) + + expect(body()).toMatchObject({ scope: 'therapy' }) + }) + + test('a Scope object resolves to its id', async () => { + const { http, body } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.chat('q', { scope: new Scope('therapy', 'workspace-1', http) }) + + expect(body()).toMatchObject({ scope: 'therapy' }) + }) + + test('a list of scopes passes through as a list', async () => { + const { http, body } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await peer.chat('q', { scope: ['therapy', 'work'] }) + + expect(body()).toMatchObject({ scope: ['therapy', 'work'] }) + }) + + test('scope and sessions are rejected together', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await expect( + peer.chat('q', { scope: 'therapy', sessions: ['session-a'] }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('scope and a single session are rejected together', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await expect( + peer.chat('q', { scope: 'therapy', session: 'session-a' }) + ).rejects.toBeInstanceOf(ZodError) + }) +}) + +describe('scope id validation', () => { + test('a prefixed name reports the reserved prefix, not the charset', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + // 'scope.therapy' fails both rules; the prefix message is the useful one, + // so it must be the only one raised. + const error = await peer + .chat('q', { scope: 'scope.therapy' }) + .then(() => undefined) + .catch((err: unknown) => err as ZodError) + + expect(error).toBeInstanceOf(ZodError) + const messages = (error as ZodError).issues.map((issue) => issue.message) + expect(messages.some((m) => m.includes('reserved prefix'))).toBe(true) + expect(messages.some((m) => m.includes('may only contain'))).toBe(false) + }) + + test('a name with illegal characters is rejected', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + await expect(peer.chat('q', { scope: 'my scope' })).rejects.toBeInstanceOf( + ZodError + ) + }) + + test('the specific message survives the scope option union', async () => { + // ScopeOptionSchema is a union. Zod collapses a failing union into a single + // `invalid_union` / "Invalid input" issue and buries the branch errors, so + // the rules are applied after the union resolves. Without that, every bad + // scope reports "Invalid input" and the caller learns nothing. + for (const [input, expected] of [ + ['scope.therapy', 'reserved prefix'], + ['my scope', 'may only contain'], + ['', 'non-empty'], + ['a'.repeat(507), 'at most 506'], + ] as const) { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + const error = (await peer + .chat('q', { scope: input }) + .then(() => undefined) + .catch((err: unknown) => err)) as ZodError + + expect(error).toBeInstanceOf(ZodError) + const messages = error.issues.map((i) => i.message).join(' | ') + expect(messages).toContain(expected) + expect(messages).not.toContain('Invalid input') + } + }) + + test('list-form messages also survive the union', async () => { + const { http } = capturingHttp({ content: 'ok' }) + const peer = new Peer('alice', 'workspace-1', http) + + for (const [input, expected] of [ + [[], 'at least one scope'], + [['ok', 'scope.bad'], 'reserved prefix'], + [Array.from({ length: 101 }, (_, i) => `s${i}`), 'at most 100 scopes'], + ] as const) { + const error = (await peer + .chat('q', { scope: input as string[] }) + .then(() => undefined) + .catch((err: unknown) => err)) as ZodError + + expect(error.issues.map((i) => i.message).join(' | ')).toContain(expected) + } + }) +}) + +describe('empty-string options fail closed', () => { + test("session.context rejects scope: '' instead of returning unscoped context", async () => { + const { http, query } = capturingHttp({ + id: 'session-a', + messages: [], + summary: null, + peer_representation: null, + peer_card: null, + }) + const session = new Session('session-a', 'workspace-1', http) + + // A truthiness check here would drop the option and silently return the + // unscoped context β€” the opposite of what an invalid scope should do. + await expect( + session.context({ peerTarget: 'user', scope: '' }) + ).rejects.toBeInstanceOf(ZodError) + expect(query()).toBeUndefined() + }) +}) + +describe('scope membership ids are validated before reaching a URL', () => { + test('removeSession rejects an id that would alter the request path', async () => { + const { http, path } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + // `valid-session?typo` would address `valid-session` with a stray query + // string, removing the wrong session and reconciling against it. + await expect( + scope.removeSession('valid-session?typo') + ).rejects.toBeInstanceOf(ZodError) + expect(path()).toBeUndefined() + }) + + test('addSessions rejects the same shape', async () => { + const { http, body } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + await expect( + scope.addSessions(['ok-session', 'valid-session?typo']) + ).rejects.toBeInstanceOf(ZodError) + expect(body()).toBeUndefined() + }) +}) + +describe('session.context scoping', () => { + const contextResponse = { + id: 'session-a', + messages: [], + summary: null, + peer_representation: null, + peer_card: null, + } + + test('scope and sessions are sent as their own query params', async () => { + const { http, query } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await session.context({ + peerTarget: 'user', + sessions: ['session-a', 'session-b'], + }) + + // An array here relies on the HTTP client emitting repeated params; see + // http-client.test.ts. + expect(query()).toMatchObject({ sessions: ['session-a', 'session-b'] }) + }) + + test('sessions without peerTarget is rejected, not silently ignored', async () => { + const { http } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await expect( + session.context({ sessions: ['session-a'] }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('sessions and scope are rejected together', async () => { + const { http } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await expect( + session.context({ + peerTarget: 'user', + scope: 'therapy', + sessions: ['session-a'], + }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('sessions and limitToSession are rejected together', async () => { + const { http } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await expect( + session.context({ + peerTarget: 'user', + limitToSession: true, + sessions: ['session-a'], + }) + ).rejects.toBeInstanceOf(ZodError) + }) + + test('scope and peerPerspective are rejected together', async () => { + const { http } = capturingHttp(contextResponse) + const session = new Session('session-a', 'workspace-1', http) + + await expect( + session.context({ + peerTarget: 'user', + peerPerspective: 'assistant', + scope: 'therapy', + }) + ).rejects.toBeInstanceOf(ZodError) + }) +}) + +describe('Scope membership and status', () => { + test('addSessions posts session_ids and resolves Session objects', async () => { + const { http, body, path } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + await scope.addSessions([ + 'session-a', + new Session('session-b', 'workspace-1', http), + ]) + + expect(path()).toBe('/v3/workspaces/workspace-1/scopes/therapy/sessions') + expect(body()).toEqual({ session_ids: ['session-a', 'session-b'] }) + }) + + test('addSessions rejects a batch over the server limit instead of chunking', async () => { + const { http } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + const tooMany = Array.from({ length: 101 }, (_, i) => `session-${i}`) + + await expect(scope.addSessions(tooMany)).rejects.toBeInstanceOf(ZodError) + }) + + test('removeSession targets the session subpath', async () => { + const { http, path } = capturingHttp(undefined) + const scope = new Scope('therapy', 'workspace-1', http) + + await scope.removeSession(new Session('session-b', 'workspace-1', http)) + + expect(path()).toBe( + '/v3/workspaces/workspace-1/scopes/therapy/sessions/session-b' + ) + }) + + test('status maps snake_case job fields to camelCase', async () => { + const response: ScopeStatusResponse = { + backfill_status: { + 'session-a': { + state: 'completed', + updated_at: '2024-01-01T00:00:00Z', + docs_copied: 12, + }, + 'session-b': { state: 'pending', updated_at: '2024-01-02T00:00:00Z' }, + }, + } + const { http } = capturingHttp(response) + const scope = new Scope('therapy', 'workspace-1', http) + + const status = await scope.status() + + expect(status.backfillStatus['session-a']).toEqual({ + state: 'completed', + updatedAt: '2024-01-01T00:00:00Z', + docsCopied: 12, + }) + expect(status.backfillStatus['session-b']?.docsCopied).toBeUndefined() + }) + + test('status on a scope with no backfill is an empty map, not a throw', async () => { + // The server omits the key entirely when nothing was ever enqueued. + const { http } = capturingHttp({} as ScopeStatusResponse) + const scope = new Scope('therapy', 'workspace-1', http) + + const status = await scope.status() + + expect(status.backfillStatus).toEqual({}) + }) +}) diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 7db0696c..9aca55f8 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@honcho-ai/sdk", - "version": "2.3.0", + "version": "2.4.0", "description": "Official DX Optimized TypeScript SDK for Honcho", "author": "Plastic Labs ", "license": "Apache-2.0", diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 194b429e..5796d079 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1,9 +1,14 @@ import { API_VERSION } from './api-version' import { WorkspaceConclusions } from './conclusions' import { HonchoHTTPClient } from './http/client' +import { + createDialecticStream, + type DialecticStreamResponse, +} from './http/streaming' import { Message } from './message' import { Page } from './pagination' import { Peer } from './peer' +import { Scope } from './scope' import { Session } from './session' import type { MessageResponse, @@ -12,7 +17,10 @@ import type { QueueStatus, QueueStatusParams, QueueStatusResponse, + ScopeResponse, SessionResponse, + WorkspaceChatParams, + WorkspaceChatResponse, WorkspaceResponse, } from './types/api' import { resolveId, transformQueueStatus } from './utils' @@ -33,12 +41,14 @@ import { peerConfigFromApi, peerConfigToApi, type QueueStatusOptions, + ScopeIdSchema, SearchQuerySchema, type SessionConfig, SessionConfigSchema, SessionIdSchema, type SessionMetadata, SessionMetadataSchema, + SessionScopesSchema, sessionConfigFromApi, sessionConfigToApi, type WorkspaceConfig, @@ -50,6 +60,7 @@ import { } from './validation' const DEFAULT_BASE_URL = 'https://api.honcho.dev' +type ReasoningLevel = 'minimal' | 'low' | 'medium' | 'high' | 'max' /** * Main client for the Honcho TypeScript SDK. @@ -267,6 +278,7 @@ export class Honcho { params: { query: string filters?: Record + scope?: string limit?: number } ): Promise { @@ -327,6 +339,39 @@ export class Honcho { ) } + private async _getOrCreateScope( + workspaceId: string, + params: { + id: string + metadata?: Record + } + ): Promise { + return this._http.post( + `/${API_VERSION}/workspaces/${workspaceId}/scopes`, + { body: params } + ) + } + + private async _listScopes( + workspaceId: string, + params?: { + page?: number + size?: number + reverse?: boolean + } + ): Promise> { + return this._http.post>( + `/${API_VERSION}/workspaces/${workspaceId}/scopes/list`, + { + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, + } + ) + } + private async _listSessions( workspaceId: string, params?: { @@ -359,6 +404,7 @@ export class Honcho { string, { observe_me?: boolean | null; observe_others?: boolean | null } > + scopes?: string[] } ): Promise { return this._http.post( @@ -369,6 +415,35 @@ export class Honcho { metadata: params.metadata, configuration: sessionConfigToApi(params.configuration), peers: params.peers, + scopes: params.scopes, + }, + } + ) + } + + private async _workspaceChat( + workspaceId: string, + params: WorkspaceChatParams + ): Promise { + await this._ensureWorkspace() + return this._http.post( + `/${API_VERSION}/workspaces/${workspaceId}/chat`, + { body: params } + ) + } + + private async _workspaceChatStream( + workspaceId: string, + params: Omit + ): Promise { + await this._ensureWorkspace() + return this._http.stream( + 'POST', + `/${API_VERSION}/workspaces/${workspaceId}/chat`, + { + body: { + ...params, + stream: true, }, } ) @@ -517,6 +592,10 @@ export class Honcho { * @param options.peers - Optional peers to attach to the session at creation. * Accepts the same shape as `session.addPeers()` (peer ID strings, * Peer objects, arrays of either, or a record with per-peer config). + * @param options.scopes - Optional scopes this session should join. Each scope is + * created if it does not exist yet. Attaching at creation avoids the + * asynchronous backfill that a later `scope.addSessions()` triggers, + * since there is no history to copy. * @returns Promise resolving to a Session object that can be used to add peers, * send messages, and manage conversation context * @throws Error if the session ID is empty or invalid @@ -527,6 +606,7 @@ export class Honcho { metadata?: SessionMetadata configuration?: SessionConfig peers?: PeerAddition + scopes?: (string | Scope)[] } ): Promise { await this._ensureWorkspace() @@ -541,12 +621,17 @@ export class Honcho { options?.peers !== undefined ? PeerAdditionToApiSchema.parse(options.peers) : undefined + const validatedScopes = + options?.scopes !== undefined + ? SessionScopesSchema.parse(options.scopes.map(resolveId)) + : undefined const sessionData = await this._getOrCreateSession(this.workspaceId, { id: validatedId, configuration: validatedConfiguration, metadata: validatedMetadata, peers: validatedPeers, + scopes: validatedScopes, }) return new Session( validatedId, @@ -560,6 +645,90 @@ export class Honcho { ) } + /** + * Get or create a scope with the given ID. + * + * A scope is a named set of sessions that acts as a visibility boundary: recall + * performed through the scope sees only what happened in its sessions, while the + * underlying peer keeps its single unified representation of everything. + * + * @param id - Unprefixed scope name, unique within the workspace + * @param options.metadata - Optional metadata to associate with this scope + * @returns Promise resolving to a Scope object for managing membership + * @throws Error if the scope ID is empty or invalid, or if a peer already occupies + * the scope's reserved internal name + * + * @example + * ```typescript + * const therapy = await honcho.scope('therapy') + * await therapy.addSessions([session1, session2]) + * ``` + */ + async scope( + id: string, + options?: { + metadata?: Record + } + ): Promise { + await this._ensureWorkspace() + const validatedId = ScopeIdSchema.parse(id) + + const scopeData = await this._getOrCreateScope(this.workspaceId, { + id: validatedId, + metadata: options?.metadata, + }) + return new Scope( + validatedId, + this.workspaceId, + this._http, + scopeData.metadata ?? undefined, + () => this._ensureWorkspace(), + scopeData.created_at + ) + } + + /** + * Get all scopes in the current workspace. + * + * @param options - Pagination options: `page`, `size`, and `reverse` + * @returns Promise resolving to a Page of Scope objects. Returns an empty page if + * no scopes exist + */ + async scopes(options?: { + page?: number + size?: number + reverse?: boolean + }): Promise> { + await this._ensureWorkspace() + const reverse = options?.reverse + const scopesPage = await this._listScopes(this.workspaceId, { + page: options?.page, + size: options?.size, + reverse, + }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listScopes(this.workspaceId, { page, size, reverse }) + } + + return new Page( + scopesPage, + (scope) => + new Scope( + scope.id, + this.workspaceId, + this._http, + scope.metadata ?? undefined, + () => this._ensureWorkspace(), + scope.created_at + ), + fetchNextPage + ) + } + /** * Get all sessions in the current workspace. * @@ -788,6 +957,9 @@ export class Honcho { * * @param query - The search query to use * @param filters - Optional filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters). + * @param options.scope - Optional scope to restrict the search to that scope's member + * sessions. Mutually exclusive with a `session_id` filter. A scope + * with no member sessions matches nothing rather than everything. * @param limit - Number of results to return (1-100, default: 10). * @returns Promise resolving to an array of Message objects representing the search results. * Returns an empty array if no messages are found. @@ -797,6 +969,7 @@ export class Honcho { query: string, options?: { filters?: Filters + scope?: string | Scope limit?: number } ): Promise { @@ -805,17 +978,124 @@ export class Honcho { const validatedFilters = options?.filters ? FilterSchema.parse(options.filters) : undefined + // Checked against undefined, not truthiness: `scope: ''` is invalid, and + // dropping it silently would diverge from the Python SDK, which rejects it. + const validatedScope = + options?.scope !== undefined + ? ScopeIdSchema.parse(resolveId(options.scope)) + : undefined const validatedLimit = options?.limit ? LimitSchema.parse(options.limit) : undefined const response = await this._searchWorkspace(this.workspaceId, { query: validatedQuery, filters: validatedFilters, + scope: validatedScope, limit: validatedLimit, }) return response.map(Message.fromApiResponse) } + /** + * Query the workspace's collective knowledge using natural language. + * + * Performs agentic search and reasoning across ALL peers and observations + * in the workspace to synthesize a comprehensive answer. Useful for + * cross-peer analysis, discovering common themes, and workspace-wide queries. + * + * @param query - The natural language question to ask + * @param options.session - Optional session to scope message search to. Can be a session + * ID string or a Session object. + * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", + * "medium", "high", or "max". Defaults to "low" if not provided. + * @param options.responseFormat - Optional JSON Schema (root type "object") the response + * must conform to. When provided, the response content is a + * JSON string matching this schema. + * @returns Promise resolving to the response string, or null if no relevant information + * + * @example + * ```typescript + * const response = await honcho.chat('What are common themes across all users?') + * ``` + */ + async chat( + query: string, + options?: { + session?: string | Session + reasoningLevel?: ReasoningLevel + responseFormat?: Record + scope?: string | string[] + } + ): Promise { + const validatedQuery = SearchQuerySchema.parse(query) + const resolvedSessionId = options?.session + ? resolveId(options.session) + : undefined + + const response = await this._workspaceChat(this.workspaceId, { + query: validatedQuery, + stream: false, + session_id: resolvedSessionId, + reasoning_level: options?.reasoningLevel, + response_format: options?.responseFormat, + scope: options?.scope, + }) + if (!response.content) { + return null + } + return response.content + } + + /** + * Query the workspace's collective knowledge with streaming response. + * + * Performs agentic search and reasoning across ALL peers and observations + * in the workspace to synthesize a comprehensive answer, streaming the + * response as it is generated. + * + * @param query - The natural language question to ask + * @param options.session - Optional session to scope message search to. Can be a session + * ID string or a Session object. + * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", + * "medium", "high", or "max". Defaults to "low" if not provided. + * @param options.responseFormat - Optional JSON Schema (root type "object") the response + * must conform to. When provided, the response content is a + * JSON string matching this schema. + * @returns Promise resolving to a DialecticStreamResponse that can be iterated over + * + * @example + * ```typescript + * const stream = await honcho.chatStream('What do all peers have in common?') + * for await (const chunk of stream) { + * process.stdout.write(chunk) + * } + * ``` + */ + async chatStream( + query: string, + options?: { + session?: string | Session + reasoningLevel?: ReasoningLevel + responseFormat?: Record + scope?: string | string[] + } + ): Promise { + const validatedQuery = SearchQuerySchema.parse(query) + const resolvedSessionId = options?.session + ? resolveId(options.session) + : undefined + + const response = await this._workspaceChatStream(this.workspaceId, { + query: validatedQuery, + session_id: resolvedSessionId, + reasoning_level: options?.reasoningLevel, + response_format: options?.responseFormat, + scope: options?.scope, + }) + + return createDialecticStream(response) + } + /** * Get the queue processing status, optionally scoped to an observer, sender, and/or session. * diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index 328bb2b3..470e1dd6 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -13,12 +13,12 @@ import type { import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation' /** - * Filter keys that define a conclusion scope (the observer/observed peer pair). - * They are set from the scope itself, so a caller must not pass them in `filters`. + * Filter keys that define a conclusions view (the observer/observed peer pair). + * They are set from the view itself, so a caller must not pass them in `filters`. */ const LIST_PAGE_CAP = 100 -const SCOPE_RESERVED_KEYS = [ +const VIEW_RESERVED_KEYS = [ 'observer', 'observed', 'observer_id', @@ -26,11 +26,11 @@ const SCOPE_RESERVED_KEYS = [ ] /** - * Throw if `filters` contains keys managed by the conclusion scope. + * Throw if `filters` contains keys managed by the conclusions view. * * The observer/observed peer pair (and, on `list`, the session) is fixed by the - * scope, so letting a user filter override it would silently return data from a - * different scope than requested. Fail loud instead. + * view, so letting a user filter override it would silently return data from a + * different pair than requested. Fail loud instead. */ function rejectReservedFilterKeys( filters: Record | undefined, @@ -45,7 +45,7 @@ function rejectReservedFilterKeys( guidance += '; use the session option to filter by session' } throw new Error( - `Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` + + `Filter key(s) ${clash.join(', ')} are managed by this conclusions view ` + `and cannot be passed in filters. ${guidance}.` ) } @@ -137,7 +137,7 @@ export class Conclusion { /** * Scoped access to conclusions for a specific observer/observed relationship. */ -export class ConclusionScope { +export class ConclusionsView { private _http: HonchoHTTPClient private _ensureWorkspace: () => Promise readonly workspaceId: string @@ -276,14 +276,14 @@ export class ConclusionScope { // =========================================================================== /** - * List conclusions in this scope. + * List conclusions in this view. * * @param options - Optional configuration for the list request * @param options.page - Page number (1-indexed, default: 1) * @param options.size - Number of items per page (default: 50) * @param options.session - Optional session (ID string or Session object) to filter by * @param options.filters - Optional additional filter criteria, merged with - * this scope's observer/observed (and session, if given). Supports the same + * this view's observer/observed (and session, if given). Supports the same * operators as other list endpoints β€” e.g. `{ level: 'explicit' }` to get * only conclusions extracted directly from messages (i.e. not derived during * dreaming), or `{ source_ids: { contains: '' } }` to get conclusions @@ -299,7 +299,7 @@ export class ConclusionScope { reverse?: boolean }): Promise> { rejectReservedFilterKeys(options?.filters, [ - ...SCOPE_RESERVED_KEYS, + ...VIEW_RESERVED_KEYS, 'session', 'session_id', ]) @@ -338,13 +338,13 @@ export class ConclusionScope { } /** - * Semantic search for conclusions in this scope. + * Semantic search for conclusions in this view. * * @param query - The search query string * @param topK - Maximum number of results to return (default: 10) * @param distance - Maximum cosine distance threshold (0.0-1.0) * @param filters - Optional additional filter criteria, merged with this - * scope's observer/observed. Supports the same operators as the list + * view's observer/observed. Supports the same operators as the list * endpoint β€” e.g. `{ level: 'deductive' }` to search only conclusions * derived during dreaming. See * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters @@ -355,7 +355,7 @@ export class ConclusionScope { distance?: number, filters?: Record ): Promise { - rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS) + rejectReservedFilterKeys(filters, VIEW_RESERVED_KEYS) const response = await this._query({ query, top_k: topK, @@ -466,7 +466,7 @@ export class ConclusionScope { } /** - * Create conclusions in this scope. + * Create conclusions in this view. */ async create( conclusions: ConclusionCreateParams | ConclusionCreateParams[] @@ -493,7 +493,7 @@ export class ConclusionScope { } /** - * Get the computed representation for this scope. + * Get the computed representation for this view. */ async representation(options?: RepresentationOptions): Promise { const searchQuery = normalizeSearchQuery(options?.searchQuery) @@ -517,7 +517,7 @@ export class ConclusionScope { } toString(): string { - return `ConclusionScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')` + return `ConclusionsView(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')` } } diff --git a/sdks/typescript/src/http/client.ts b/sdks/typescript/src/http/client.ts index e22480d2..fd5e6379 100644 --- a/sdks/typescript/src/http/client.ts +++ b/sdks/typescript/src/http/client.ts @@ -6,18 +6,27 @@ import { TimeoutError, } from './errors' +/** + * Query parameters for a request. An array value is sent as repeated + * parameters (`?k=a&k=b`), which is how the API reads list-valued parameters. + */ +export type QueryParams = Record< + string, + string | number | boolean | readonly (string | number | boolean)[] | undefined +> + export interface HonchoHTTPClientConfig { baseURL: string apiKey?: string timeout?: number maxRetries?: number defaultHeaders?: Record - defaultQuery?: Record + defaultQuery?: QueryParams } export interface RequestOptions { body?: unknown - query?: Record + query?: QueryParams headers?: Record timeout?: number signal?: AbortSignal @@ -37,7 +46,7 @@ export class HonchoHTTPClient { readonly timeout: number readonly maxRetries: number readonly defaultHeaders: Record - readonly defaultQuery?: Record + readonly defaultQuery?: QueryParams constructor(config: HonchoHTTPClientConfig) { // Remove trailing slash from baseURL @@ -273,21 +282,28 @@ export class HonchoHTTPClient { return JSON.parse(text) as T } - private buildURL( - path: string, - query?: Record - ): string { + private buildURL(path: string, query?: QueryParams): string { const url = new URL(path, this.baseURL) - const mergedQuery: Record = { + const mergedQuery: QueryParams = { ...(this.defaultQuery ?? {}), ...(query ?? {}), } for (const [key, value] of Object.entries(mergedQuery)) { - if (value !== undefined) { - url.searchParams.set(key, String(value)) + if (value === undefined) { + continue } + if (Array.isArray(value)) { + // Repeated params, not a comma-joined value: the API reads list-valued + // query parameters as `?k=a&k=b`, and String([a, b]) would arrive as a + // single malformed entry. + for (const entry of value) { + url.searchParams.append(key, String(entry)) + } + continue + } + url.searchParams.set(key, String(value)) } return url.toString() diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 64cceb86..8a2d1b22 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -6,7 +6,13 @@ export { Honcho } from './client' export { Conclusion, type ConclusionCreateParams, - ConclusionScope, + /** + * @deprecated Renamed to `ConclusionsView`. "Scope" now means a named set of + * sessions (see `Scope`), which this class is not β€” it is a view over one + * observer/observed pair. Kept as an alias for one more minor version. + */ + ConclusionsView as ConclusionScope, + ConclusionsView, WorkspaceConclusions, } from './conclusions' // HTTP infrastructure @@ -31,6 +37,11 @@ export { export { Message, type MessageInput } from './message' export { Page } from './pagination' export { Peer, PeerContext } from './peer' +export { + Scope, + type ScopeBackfillState, + type ScopeStatus, +} from './scope' export { Session } from './session' export { SessionContext, @@ -51,11 +62,15 @@ export type { QueueStatus, QueueStatusResponse, RepresentationOptions, + ScopeBackfillJob, + ScopeResponse, + ScopeStatusResponse, SessionContextResponse, SessionQueueStatus, SessionResponse, SessionSummariesResponse, SummaryResponse, + WorkspaceChatResponse, WorkspaceResponse, } from './types/api' diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts index adb67cb2..9dab1658 100644 --- a/sdks/typescript/src/peer.ts +++ b/sdks/typescript/src/peer.ts @@ -1,6 +1,6 @@ import { ZodType, z } from 'zod' import { API_VERSION } from './api-version' -import { ConclusionScope } from './conclusions' +import { ConclusionsView } from './conclusions' import type { HonchoHTTPClient } from './http/client' import { createDialecticStream, @@ -8,6 +8,9 @@ import { } from './http/streaming' import { Message, type MessageInput } from './message' import { Page } from './pagination' +// Type-only: scope.ts imports Session, which imports Peer. Importing the type +// keeps that cycle out of the emitted JS. +import type { Scope } from './scope' import { Session } from './session' import type { MessageResponse, @@ -40,6 +43,7 @@ import { peerConfigToApi, RepresentationOptionsSchema, SearchQuerySchema, + scopeRecallFields, sessionConfigFromApi, } from './validation' @@ -251,6 +255,8 @@ export class Peer { stream?: boolean target?: string session_id?: string + scope?: string | string[] + filters?: Record reasoning_level?: string response_format?: Record }): Promise { @@ -265,6 +271,8 @@ export class Peer { query: string target?: string session_id?: string + scope?: string | string[] + filters?: Record reasoning_level?: string response_format?: Record }): Promise { @@ -295,6 +303,8 @@ export class Peer { private async _getRepresentation(params: { session_id?: string + scope?: string | string[] + filters?: Record target?: string search_query?: string search_top_k?: number @@ -365,6 +375,18 @@ export class Peer { * @param options.session - Optional session to scope the query to. If provided, only * information from that session is considered. Can be a session * ID string or a Session object. + * @param options.scope - Optional scope(s) to confine the query to. A single scope answers + * from that scope's own view of the target, including the higher-order + * conclusions reasoned within it. A list of scopes restricts recall to + * the union of their member sessions, which β€” like `sessions` β€” yields + * only directly-stated conclusions. Mutually exclusive with `session` + * and `sessions`, and requires a workspace-level key. + * @param options.sessions - Optional allowlist of sessions to confine the query to, for + * one-off questions that span a handful of sessions. Recall is + * limited to conclusions stated directly in those sessions: + * conclusions produced by reasoning across sessions are excluded, + * because their provenance cannot be proven to sit inside the + * allowlist. Reach for a named `scope` when you need that depth. * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium", * "high", or "max". Defaults to "low" if not provided. * @returns Promise resolving to the response string, or null if no relevant information @@ -379,6 +401,16 @@ export class Peer { * target: otherPeer, * reasoningLevel: 'high' * }) + * + * // Answer only from a named scope + * const response = await peer.chat('What is stressing them out?', { + * scope: 'therapy', + * }) + * + * // Answer only from an ad-hoc set of sessions + * const response = await peer.chat('What did we decide?', { + * sessions: [session1, session2], + * }) * ``` */ async chat( @@ -386,6 +418,8 @@ export class Peer { options: { target?: string | Peer session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] reasoningLevel?: string responseFormat: ZodType } @@ -395,6 +429,8 @@ export class Peer { options?: { target?: string | Peer session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: Record } @@ -404,6 +440,8 @@ export class Peer { options?: { target?: string | Peer session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: ZodType | Record } @@ -423,6 +461,8 @@ export class Peer { query, target: targetId, session: resolvedSessionId, + scope: options?.scope, + sessions: options?.sessions, reasoningLevel: options?.reasoningLevel, responseFormat: options?.responseFormat, }) @@ -437,6 +477,7 @@ export class Peer { stream: false, target: chatParams.target, session_id: chatParams.session, + ...scopeRecallFields(chatParams), reasoning_level: chatParams.reasoningLevel, response_format: Peer.toResponseFormatSchema(options?.responseFormat), }) @@ -465,6 +506,9 @@ export class Peer { * @param options.session - Optional session to scope the query to. If provided, only * information from that session is considered. Can be a session * ID string or a Session object. + * @param options.scope - Optional scope(s) to confine the query to. See {@link Peer.chat}. + * @param options.sessions - Optional allowlist of sessions to confine the query to. + * See {@link Peer.chat} for the depth caveat. * @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low", "medium", * "high", or "max". Defaults to "low" if not provided. * @returns Promise resolving to a DialecticStreamResponse that can be iterated over @@ -489,6 +533,8 @@ export class Peer { options?: { target?: string | Peer session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] reasoningLevel?: string responseFormat?: ZodType | Record } @@ -508,6 +554,8 @@ export class Peer { query, target: targetId, session: resolvedSessionId, + scope: options?.scope, + sessions: options?.sessions, reasoningLevel: options?.reasoningLevel, responseFormat: options?.responseFormat, }) @@ -516,6 +564,7 @@ export class Peer { query: chatParams.query, target: chatParams.target, session_id: chatParams.session, + ...scopeRecallFields(chatParams), reasoning_level: chatParams.reasoningLevel, response_format: Peer.toResponseFormatSchema(options?.responseFormat), }) @@ -846,6 +895,8 @@ export class Peer { */ async representation(options?: { session?: string | Session + scope?: string | Scope | (string | Scope)[] + sessions?: (string | Session)[] target?: string | Peer searchQuery?: string | Message searchTopK?: number @@ -856,6 +907,8 @@ export class Peer { const searchQuery = normalizeSearchQuery(options?.searchQuery) const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({ session: options?.session, + scope: options?.scope, + sessions: options?.sessions, target: options?.target, options: { searchQuery, @@ -878,6 +931,7 @@ export class Peer { const response = await this._getRepresentation({ session_id: sessionId, + ...scopeRecallFields(getRepresentationParams), target: targetId, search_query: searchQuery, search_top_k: getRepresentationParams.options?.searchTopK, @@ -964,7 +1018,7 @@ export class Peer { * This property provides a convenient way to access conclusions that this peer * has made about themselves. Use this for self-conclusion scenarios. * - * @returns A ConclusionScope scoped to this peer's self-conclusions + * @returns A ConclusionsView scoped to this peer's self-conclusions * * @example * ```typescript @@ -978,8 +1032,8 @@ export class Peer { * await peer.conclusions.delete('obs-123') * ``` */ - get conclusions(): ConclusionScope { - return new ConclusionScope( + get conclusions(): ConclusionsView { + return new ConclusionsView( this._http, this.workspaceId, this.id, @@ -995,7 +1049,7 @@ export class Peer { * observer and the target is the observed peer. * * @param target - The target peer (either a Peer object or peer ID string) - * @returns A ConclusionScope scoped to this peer's conclusions of the target + * @returns A ConclusionsView scoped to this peer's conclusions of the target * * @example * ```typescript @@ -1012,9 +1066,9 @@ export class Peer { * const rep = await bobConclusions.representation() * ``` */ - conclusionsOf(target: string | Peer): ConclusionScope { + conclusionsOf(target: string | Peer): ConclusionsView { const targetId = typeof target === 'string' ? target : target.id - return new ConclusionScope( + return new ConclusionsView( this._http, this.workspaceId, this.id, diff --git a/sdks/typescript/src/scope.ts b/sdks/typescript/src/scope.ts new file mode 100644 index 00000000..5a1114fe --- /dev/null +++ b/sdks/typescript/src/scope.ts @@ -0,0 +1,278 @@ +import { API_VERSION } from './api-version' +import type { HonchoHTTPClient } from './http/client' +import { Page } from './pagination' +import { Session } from './session' +import type { + PageResponse, + ScopeStatusResponse, + SessionResponse, +} from './types/api' +import { resolveId } from './utils' +import { + ScopeSessionsSchema, + SessionIdSchema, + sessionConfigFromApi, +} from './validation' + +/** + * Backfill job state for one session in a scope. + */ +export interface ScopeBackfillState { + state: 'pending' | 'completed' | 'failed' + updatedAt: string + /** + * Number of documents copied into the scope. Present only once the backfill + * for this session completes. + */ + docsCopied?: number +} + +/** + * Backfill/reconciliation progress for a scope, keyed by session ID. + * + * Only sessions that have had a backfill enqueued appear. A scope whose + * sessions were all empty when added has an empty `backfillStatus`. + */ +export interface ScopeStatus { + backfillStatus: Record +} + +/** + * Represents a scope in the Honcho system. + * + * A scope is a named set of sessions that acts as a visibility boundary. Recall + * performed through a scope sees only what happened in that scope's sessions, + * while the underlying peer keeps its single unified representation across + * everything it has ever participated in. + * + * Membership changes are applied asynchronously: adding a session that already + * has messages copies its existing conclusions into the scope, and removing one + * reconciles them back out. Poll {@link Scope.status} to watch that settle. + * + * @example + * ```typescript + * const therapy = await honcho.scope('therapy') + * await therapy.addSessions([session1, session2]) + * + * // Ask a question answered only from the therapy sessions + * const answer = await user.chat('What is stressing them out?', { + * scope: 'therapy', + * }) + * ``` + */ +export class Scope { + /** + * Unique identifier for this scope, without the server-side `scope.` prefix. + */ + readonly id: string + /** + * Workspace ID for scoping operations. + */ + readonly workspaceId: string + private _http: HonchoHTTPClient + private _metadata?: Record + private _createdAt?: string + private _ensureWorkspace: () => Promise + + /** + * Cached metadata for this scope. May be stale if the scope was not recently + * fetched from the API. + */ + get metadata(): Record | undefined { + return this._metadata + } + + /** + * Timestamp when this scope was created. Only available if fetched from the API. + */ + get createdAt(): string | undefined { + return this._createdAt + } + + /** + * Initialize a new Scope. **Do not call this directly, use the client.scope() method instead.** + * + * @param id - Unprefixed scope name, unique within the workspace + * @param workspaceId - Workspace ID for scoping operations + * @param http - Reference to the HTTP client instance + * @param metadata - Optional metadata to initialize the cached value + * @param ensureWorkspace - Callback that guarantees the workspace exists + * @param createdAt - Creation timestamp, if already fetched + */ + constructor( + id: string, + workspaceId: string, + http: HonchoHTTPClient, + metadata?: Record, + ensureWorkspace: () => Promise = async () => undefined, + createdAt?: string + ) { + this.id = id + this.workspaceId = workspaceId + this._http = http + this._metadata = metadata + this._ensureWorkspace = ensureWorkspace + this._createdAt = createdAt + } + + // =========================================================================== + // Private API Methods + // =========================================================================== + + private get _basePath(): string { + return `/${API_VERSION}/workspaces/${this.workspaceId}/scopes/${this.id}` + } + + private async _addSessions(sessionIds: string[]): Promise { + await this._ensureWorkspace() + await this._http.post(`${this._basePath}/sessions`, { + body: { session_ids: sessionIds }, + }) + } + + private async _removeSession(sessionId: string): Promise { + await this._ensureWorkspace() + await this._http.delete(`${this._basePath}/sessions/${sessionId}`) + } + + private async _listSessions(params?: { + page?: number + size?: number + reverse?: boolean + }): Promise> { + await this._ensureWorkspace() + return this._http.post>( + `${this._basePath}/sessions/list`, + { + query: { + page: params?.page, + size: params?.size, + reverse: params?.reverse ? 'true' : undefined, + }, + } + ) + } + + private async _getStatus(): Promise { + await this._ensureWorkspace() + return this._http.get(`${this._basePath}/status`) + } + + // =========================================================================== + // Public API Methods + // =========================================================================== + + /** + * Add sessions to this scope. + * + * Every named session must already exist. Adding a session that is already a + * member is a no-op. + * + * Sessions that already hold messages are backfilled into the scope + * asynchronously, so recall through this scope may not reflect their history + * immediately β€” poll {@link Scope.status} to watch that complete. + * + * @param sessions - Sessions to add, as ID strings or Session objects. At most + * 100 per call, matching the server's limit; split larger + * membership changes into separate calls so a failure names + * the batch that failed. + */ + async addSessions(sessions: (string | Session)[]): Promise { + const sessionIds = ScopeSessionsSchema.parse(sessions.map(resolveId)) + await this._addSessions(sessionIds) + } + + /** + * Remove a session from this scope. + * + * Conclusions copied or derived while the session was a member are + * reconciled out asynchronously, and the scope's peer card is rebuilt from + * whatever evidence remains. Poll {@link Scope.status} to watch that settle. + * + * @param session - Session to remove, as an ID string or a Session object + * @throws If the session ID is malformed + */ + async removeSession(session: string | Session): Promise { + // Validated because this ID is interpolated into a request *path*: an + // unvalidated value silently changes which resource the request addresses. + // `valid-session?typo` would target `valid-session` with a stray query + // string, removing the wrong session and reconciling against it. + await this._removeSession(SessionIdSchema.parse(resolveId(session))) + } + + /** + * Get the sessions that are members of this scope. + * + * Ordered by how long each session has been a member β€” longest-standing + * first, or most recently added first when `reverse` is true. + * + * @param options - Pagination options: `page`, `size`, and `reverse` + * @returns Promise resolving to a paginated list of member Sessions + */ + async sessions(options?: { + page?: number + size?: number + reverse?: boolean + }): Promise> { + const reverse = options?.reverse + const sessionsPage = await this._listSessions({ + page: options?.page, + size: options?.size, + reverse, + }) + + const fetchNextPage = async ( + page: number, + size: number + ): Promise> => { + return this._listSessions({ page, size, reverse }) + } + + return new Page( + sessionsPage, + (session) => + new Session( + session.id, + this.workspaceId, + this._http, + session.metadata ?? undefined, + sessionConfigFromApi(session.configuration) ?? undefined, + () => this._ensureWorkspace(), + session.created_at, + session.is_active + ), + fetchNextPage + ) + } + + /** + * Get the backfill/reconciliation progress for this scope. + * + * Use this after a membership change to tell "the scope knows nothing about + * that session yet" apart from "the scope has caught up and there is genuinely + * nothing to recall". + * + * @returns Promise resolving to per-session backfill state + */ + async status(): Promise { + const response = await this._getStatus() + return { + backfillStatus: Object.fromEntries( + Object.entries(response.backfill_status ?? {}).map( + ([sessionId, job]) => [ + sessionId, + { + state: job.state, + updatedAt: job.updated_at, + docsCopied: job.docs_copied, + }, + ] + ) + ), + } + } + + toString(): string { + return `Scope(id='${this.id}', workspaceId='${this.workspaceId}')` + } +} diff --git a/sdks/typescript/src/session.ts b/sdks/typescript/src/session.ts index 0f9cf4aa..ed6140a6 100644 --- a/sdks/typescript/src/session.ts +++ b/sdks/typescript/src/session.ts @@ -3,6 +3,9 @@ import type { HonchoHTTPClient } from './http/client' import { Message } from './message' import { Page } from './pagination' import { Peer } from './peer' +// Type-only: scope.ts imports this module. Importing the type keeps that cycle +// out of the emitted JS. +import type { Scope } from './scope' import { SessionContext, SessionSummaries } from './session_context' import type { MessageResponse, @@ -17,7 +20,7 @@ import type { SessionResponse, SessionSummariesResponse, } from './types/api' -import { transformQueueStatus } from './utils' +import { resolveId, transformQueueStatus } from './utils' import { ContextParamsSchema, FileUploadSchema, @@ -220,6 +223,8 @@ export class Session { search_query?: string peer_target?: string peer_perspective?: string + scope?: string + sessions?: string[] limit_to_session?: boolean search_top_k?: number search_max_distance?: number @@ -752,6 +757,17 @@ export class Session { * @param options.tokens - Target token count for the context window * @param options.peerTarget - The peer to get representation for * @param options.peerPerspective - The peer whose perspective to use for representation + * @param options.scope - A scope to use as the perspective source instead of a peer: the + * target's representation and card are read from what that scope + * observed. Requires `peerTarget`, is mutually exclusive with + * `peerPerspective`, and requires a workspace-level key. + * @param options.sessions - Allowlist of sessions confining the target's representation + * to that set. This session must be one of them. Recall is + * limited to conclusions stated directly in those sessions, and + * the peer card is omitted, since neither derived conclusions + * nor cards carry provable per-session provenance. Mutually + * exclusive with `scope` and `limitToSession`; requires + * `peerTarget`. * @param options.limitToSession - Whether to limit representation to this session only * @param options.representationOptions - Options for representation retrieval (searchQuery, searchTopK, etc.) * @returns Promise resolving to a SessionContext with messages, summary, and representation @@ -764,6 +780,12 @@ export class Session { * peerTarget: user * }) * + * // Build the context from what a scope observed + * const ctx = await session.context({ + * peerTarget: user, + * scope: 'therapy', + * }) + * * // Convert to OpenAI format * const messages = ctx.toOpenAI(assistant) * ``` @@ -773,6 +795,8 @@ export class Session { tokens?: number peerTarget?: string | Peer peerPerspective?: string | Peer + scope?: string | Scope + sessions?: (string | Session)[] limitToSession?: boolean representationOptions?: RepresentationOptions }): Promise { @@ -795,6 +819,10 @@ export class Session { tokens: opts.tokens, peerTarget: peerTargetId, peerPerspective: peerPerspectiveId, + // Checked against undefined, not truthiness: `scope: ''` must reach the + // schema and be rejected, not be dropped into an unscoped context. + scope: opts.scope !== undefined ? resolveId(opts.scope) : undefined, + sessions: opts.sessions, limitToSession: opts.limitToSession, representationOptions: opts.representationOptions ? { @@ -810,6 +838,8 @@ export class Session { search_query: searchQuery, peer_target: contextParams.peerTarget, peer_perspective: contextParams.peerPerspective, + scope: contextParams.scope, + sessions: contextParams.sessions, limit_to_session: contextParams.limitToSession, search_top_k: contextParams.representationOptions?.searchTopK, search_max_distance: diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index fdd3eee5..aa1c6105 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -81,6 +81,19 @@ export interface PeerChatResponse { content: string | null } +export interface WorkspaceChatParams { + query: string + stream?: boolean + session_id?: string + reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max' + response_format?: Record + scope?: string | string[] +} + +export interface WorkspaceChatResponse { + content: string | null +} + export interface PeerRepresentationParams { session_id?: string target?: string @@ -133,6 +146,28 @@ export interface SessionCreateParams { metadata?: Record configuration?: SessionConfigApi peers?: Record + scopes?: string[] +} + +export interface ScopeResponse { + id: string + metadata: Record + created_at: string +} + +/** + * Per-session backfill job state for a scope. + * + * `docs_copied` is present only once a backfill completes. + */ +export interface ScopeBackfillJob { + state: 'pending' | 'completed' | 'failed' + updated_at: string + docs_copied?: number +} + +export interface ScopeStatusResponse { + backfill_status: Record } export interface SessionUpdateParams { diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts index 48255833..832690e2 100644 --- a/sdks/typescript/src/validation.ts +++ b/sdks/typescript/src/validation.ts @@ -159,6 +159,149 @@ export const SessionIdSchema = z */ const SessionIdObjectSchema = z.object({ id: SessionIdSchema }) +/** + * Reserved peer-name prefix the server uses to store a scope. + */ +const SCOPE_PEER_PREFIX = 'scope.' + +/** + * Scope IDs are stored as peer names with the reserved prefix prepended, so + * they must leave room for it within the 512-character peer name limit. + */ +const SCOPE_ID_MAX_LENGTH = 512 - SCOPE_PEER_PREFIX.length + +/** + * The scope ID rules, as a plain function rather than only a schema. + * + * Zod reports a failing union as a single `invalid_union` / "Invalid input" + * issue and buries the branch errors, so a schema alone cannot carry these + * messages out of `ScopeOptionSchema`. Keeping the rules callable lets both the + * bare schema and the union surface the same specific message. + * + * @returns The problems found, or an empty array when the ID is valid. + */ +function scopeIdIssues(value: string): string[] { + if (value.length < 1) { + return ['Scope ID must be a non-empty string'] + } + if (value.length > SCOPE_ID_MAX_LENGTH) { + return [`Scope ID can be at most ${SCOPE_ID_MAX_LENGTH} characters`] + } + // Checked before the charset: the reserved prefix contains '.', which is + // itself outside the charset, so a charset-first check would report the + // charset instead of the real mistake for a double-prefixed name. + if (value.startsWith(SCOPE_PEER_PREFIX)) { + return [ + `Scope ID must not start with the reserved prefix '${SCOPE_PEER_PREFIX}' (scope IDs are unprefixed)`, + ] + } + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + return [ + 'Scope ID may only contain letters, numbers, underscores, and hyphens', + ] + } + return [] +} + +/** + * Add every scope ID problem in `values` as a top-level issue. + */ +function addScopeIdIssues(values: string[], ctx: z.RefinementCtx): void { + for (const value of values) { + for (const message of scopeIdIssues(value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message }) + } + } +} + +/** + * Schema for scope ID validation. + * + * Scope IDs are unprefixed β€” the `scope.` prefix is a server-side storage + * detail and never appears on the wire. + */ +export const ScopeIdSchema = z.string().superRefine((val, ctx) => { + addScopeIdIssues([val], ctx) +}) + +/** + * Shape-only branch for the `scope` option: an ID string, or an object carrying + * one (so a `Scope` instance is accepted). The ID itself is validated after the + * union resolves β€” see `ScopeOptionSchema`. + */ +const ScopeIdLikeSchema = z.union([z.string(), z.object({ id: z.string() })]) + +/** + * Schema for the `scope` read option: one scope, or a bounded list of them. + * + * A single scope reads that scope's own view. A list restricts recall to the + * union of the scopes' member sessions. An empty list is rejected rather than + * resolved to an empty allowlist, which would silently recall nothing. + * + * The union discriminates shape only; IDs and list bounds are checked after the + * transform so their messages are not swallowed as `invalid_union`. + */ +export const ScopeOptionSchema = z + .union([ScopeIdLikeSchema, z.array(ScopeIdLikeSchema)]) + .transform((val) => + Array.isArray(val) + ? val.map((entry) => (typeof entry === 'string' ? entry : entry.id)) + : typeof val === 'string' + ? val + : val.id + ) + .superRefine((resolved, ctx) => { + if (Array.isArray(resolved)) { + if (resolved.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope must name at least one scope', + }) + } + if (resolved.length > 100) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope can name at most 100 scopes', + }) + } + } + addScopeIdIssues(Array.isArray(resolved) ? resolved : [resolved], ctx) + }) + +/** + * Schema for a scope membership change: the sessions to add to a scope. + * + * Capped at 100 to match the server rather than silently chunking, so a + * rejected batch is the batch the caller passed. + */ +export const ScopeSessionsSchema = z + .array(SessionIdSchema) + .min(1, 'At least one session must be given') + .max(100, 'At most 100 sessions can be added per call') + +/** + * Schema for the `scopes` option on session creation: the scopes a new session + * should join. + */ +export const SessionScopesSchema = z + .array(ScopeIdSchema) + .min(1, 'scopes must name at least one scope') + .max(100, 'scopes can name at most 100 scopes') + +/** + * Schema for the `sessions` allowlist option β€” sugar for the wire-level + * `filters: { session_id: [...] }`. + * + * Capped at 1,000 entries to match the server. An empty list is rejected: the + * server treats an empty allowlist as fail-closed (recalls nothing), which is + * never what a caller passing `sessions: []` intends. + */ +export const SessionAllowlistSchema = z + .array(z.union([SessionIdSchema, SessionIdObjectSchema])) + .min(1, 'sessions must name at least one session') + .max(1000, 'sessions can name at most 1000 sessions') + .transform((vals) => vals.map((v) => (typeof v === 'string' ? v : v.id))) + /** * Schema for session peer configuration. */ @@ -291,6 +434,58 @@ export function normalizeListOptions( return { filters: input as Filters } as T } +/** + * Translate validated `scope` / `sessions` options into their wire fields. + * + * `sessions` is sugar: it goes out as the constrained + * `filters: { session_id: [...] }` body the recall endpoints accept, never as a + * field of its own β€” the server rejects unknown keys with a 422. Shared by chat, + * chatStream, and representation so the three cannot drift apart. + * + * Purely a translation; the schemas have already rejected the invalid + * combinations by the time this runs. + */ +export function scopeRecallFields(options: { + scope?: string | string[] + sessions?: string[] +}): { scope?: string | string[]; filters?: Record } { + return { + scope: options.scope, + filters: options.sessions ? { session_id: options.sessions } : undefined, + } +} + +/** + * Add issues for the `scope` exclusions the server enforces with a 422. + * + * A scope already determines what a query can see, so combining it with a + * session allowlist or a single session is a contradiction rather than a + * narrowing. Shared by the chat, representation, and context schemas so the + * three surfaces cannot drift apart. + */ +function scopeExclusivityIssues( + data: { scope?: unknown; sessions?: unknown; session?: unknown }, + ctx: z.RefinementCtx +): void { + if (data.scope === undefined) { + return + } + if (data.sessions !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope and sessions are mutually exclusive', + path: ['sessions'], + }) + } + if (data.session !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope and session are mutually exclusive', + path: ['session'], + }) + } +} + /** * Schema for chat query parameters. */ @@ -309,6 +504,8 @@ export const ChatQuerySchema = z .transform((val) => val ? (typeof val === 'string' ? val : val.id) : undefined ), + scope: ScopeOptionSchema.optional(), + sessions: SessionAllowlistSchema.optional(), reasoningLevel: z .enum(['minimal', 'low', 'medium', 'high', 'max']) .optional(), @@ -319,6 +516,7 @@ export const ChatQuerySchema = z .optional(), }) .strict() + .superRefine(scopeExclusivityIssues) /** * Schema for representation options. @@ -356,11 +554,40 @@ export const ContextParamsSchema = z tokens: z.int('Token limit must be an integer').optional(), peerTarget: PeerIdSchema.optional(), peerPerspective: PeerIdSchema.optional(), + // Only a single scope is accepted here: the context route uses a scope as + // the *perspective source* for the target's representation and card, which + // is one observer. A list of scopes has no meaning for that. + scope: ScopeIdSchema.optional(), + sessions: SessionAllowlistSchema.optional(), limitToSession: z.boolean().optional(), representationOptions: RepresentationOptionsSchema.optional(), }) .strict() .superRefine((data, ctx) => { + if (data.sessions && !data.peerTarget) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'peerTarget is required when sessions is provided', + path: ['sessions'], + }) + } + + if (data.sessions && data.scope) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'sessions and scope are mutually exclusive', + path: ['sessions'], + }) + } + + if (data.sessions && data.limitToSession) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'sessions and limitToSession are mutually exclusive', + path: ['sessions'], + }) + } + if (data.representationOptions?.searchQuery && !data.peerTarget) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -376,6 +603,22 @@ export const ContextParamsSchema = z path: ['peerPerspective'], }) } + + if (data.scope && !data.peerTarget) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'peerTarget is required when scope is provided', + path: ['scope'], + }) + } + + if (data.scope && data.peerPerspective) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'scope and peerPerspective are mutually exclusive', + path: ['scope'], + }) + } }) /** @@ -437,10 +680,13 @@ export const GetRepresentationParamsSchema = z export const PeerGetRepresentationParamsSchema = z .object({ session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(), + scope: ScopeOptionSchema.optional(), + sessions: SessionAllowlistSchema.optional(), target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(), options: RepresentationOptionsSchema.optional(), }) .strict() + .superRefine(scopeExclusivityIssues) /** * Schema for peer card target parameter. diff --git a/skills/honcho-cli/SKILL.md b/skills/honcho-cli/SKILL.md index e773cfa0..4f75f259 100644 --- a/skills/honcho-cli/SKILL.md +++ b/skills/honcho-cli/SKILL.md @@ -18,6 +18,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep ## Command groups - `honcho config` β€” CLI configuration +- `honcho start` / `stop` / `status` β€” local Docker stack (does not change `environmentUrl`). First start pins the Honcho image digest and writes `config.toml` into the profile. Pass `--setup basic` or `--setup advanced` for an interactive config wizard (TTY only; writes `.env` overrides). `honcho status` lists every profile; pass `--profile` for one. - `honcho workspace` β€” inspect, delete, search - `honcho peer` β€” inspect, card, chat, search - `honcho session` β€” inspect, view (transcript), context, summaries @@ -31,6 +32,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep - Use `honcho session context` to see exactly what an agent receives. - Never run `honcho workspace delete` without `honcho workspace inspect` first. - Compare peer card with conclusions to understand memory state. +- `honcho start` does not rewrite `environmentUrl`. Use `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local stack. ## Inspection tour diff --git a/skills/pre-pr/SKILL.md b/skills/pre-pr/SKILL.md new file mode 100644 index 00000000..0c1e1797 --- /dev/null +++ b/skills/pre-pr/SKILL.md @@ -0,0 +1,63 @@ +--- +name: pre-pr +description: Prepare a Honcho change for a pull request to plastic-labs/honcho. Invoke before opening a PR, when drafting a PR body, when asked if a branch is PR-ready, or when filling the pull request template. Checks the linked issue, required tests and docs, then writes Description / Proofs / Fixes. +--- + +# Pre-PR checklist + +Do this after the change works, before anyone opens the GitHub PR. Output is a filled template body β€” do not create the PR. + +The template lives at `.github/pull_request_template.md`. Do not add extra sections. + +## 1. Issue gate (hard stop) + +A PR without a maintainer-approved issue will be closed. + +```bash +gh issue view --repo plastic-labs/honcho --json number,title,labels,state +``` + +Stop if any of these fail: + +- no issue number, or the issue is not in `plastic-labs/honcho` +- issue is closed (unless this PR is explicitly reopening it) +- labels do not include `maintainer-approved` + +Say which check failed. Do not draft a PR body around it. + +## 2. Classify the diff + +```bash +git diff main...HEAD --stat +``` + +Pick one primary kind: bug, feature, docs. Then decide layers: + +| Surface touched | Required | +| --- | --- | +| `src/` (non-prompt) | unit tests under the matching `tests/` tree | +| deriver / dialectic / dreamer / LLM path | unit + consider live-llm (`tests/live_llm`) | +| queue, config hierarchy, multi-turn, SDK contract | unified (`uv run python -m tests.unified.run`) | +| `/v3` HTTP or deriver queue behavior | `/verify` skill (runtime, not just pytest) | +| public API, SDK exports, `config.toml` / settings, mintlify `docs/` | documentation in the matching file | + +Skip a layer only with a one-line reason (e.g. "docs-only", "comment-only"). "When appropriate" is not a skip. + +Invoke `/verify` when the runtime surface moved. Do not restate that skill here. + +Lint/type before claiming tests are green: `uv run ruff check src/` β†’ `uv run basedpyright` β†’ the pytest command for the layer. + +## 3. Proofs + +Collect evidence that belongs in the PR, not in the commit: + +- command + pass/fail for what you ran +- a log snippet, screenshot, or file path that shows the new behavior +- for bugs: the failing case before vs after, if you have it + +If `/verify` ran, the proofs *are* that session's output. Do not invent green runs. + +## 4. Write the body + +Fill the description, proofs, checklist portion of the pull request description template. +Make sure to link the related github issue, otherwise the PR will be auto-closed. diff --git a/src/cache/client.py b/src/cache/client.py index 1ad8e3f5..4b9fabb8 100644 --- a/src/cache/client.py +++ b/src/cache/client.py @@ -123,6 +123,28 @@ def get_cache_namespace() -> str: return cast(str, settings.CACHE.NAMESPACE) +# On Redis Cluster a key's slot is derived from the substring inside the first +# {...}, when one is present. Tagging the namespace puts every key an instance +# writes on a single slot, and therefore a single shard, so its client holds +# connections to one node rather than to all of them. Namespaces still hash +# independently of one another, so keys stay spread across the cluster. +# +# Two spellings, because the two ways a key gets built treat the string +# differently: cashews runs `prefix=` through format substitution, so braces +# have to be doubled to survive as literals, while direct construction does no +# substitution and needs them single. Both render to the same bytes, which +# tests/cache/test_cache_namespace_hash_tag.py asserts -- a mismatch would send +# writes and deletes to different keys with nothing raised. +def cache_key_namespace() -> str: + """Tagged namespace for keys built by string concatenation.""" + return "{" + get_cache_namespace() + "}" + + +def cache_prefix_namespace() -> str: + """Tagged namespace for cashews `prefix=`, which format-substitutes.""" + return "{{" + get_cache_namespace() + "}}" + + async def init_cache() -> None: """Initialize and verify cache connection if enabled.""" async with _cache_lock: @@ -256,6 +278,8 @@ __all__ = [ "init_cache", "close_cache", "cache", + "cache_key_namespace", + "cache_prefix_namespace", "safe_cache_delete", "safe_cache_set", ] diff --git a/src/config.py b/src/config.py index 092cb6ee..993f9bfb 100644 --- a/src/config.py +++ b/src/config.py @@ -2,7 +2,7 @@ import logging import math import os from pathlib import Path -from typing import Annotated, Any, ClassVar, Literal, cast +from typing import Annotated, Any, ClassVar, Literal, cast, get_args from urllib.parse import urlparse import tomllib @@ -998,15 +998,14 @@ class PeerCardSettings(HonchoSettings): ENABLED: bool = True -# Reasoning levels for dialectic - defined here to avoid circular imports with schemas +# Reasoning levels for dialectic - defined here to avoid circular imports with schemas. +# region ai +# REASONING_LEVELS is derived from the Literal, not hand-listed: the annotation +# rejects an invalid member but not a MISSING one, so a hand-written copy could +# silently drop a level and still typecheck. +# endregion ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] -REASONING_LEVELS: list[ReasoningLevel] = [ - "minimal", - "low", - "medium", - "high", - "max", -] +REASONING_LEVELS: list[ReasoningLevel] = list(get_args(ReasoningLevel)) class DialecticLevelSettings(BaseModel): diff --git a/src/crud/__init__.py b/src/crud/__init__.py index 38be14da..0e920717 100644 --- a/src/crud/__init__.py +++ b/src/crud/__init__.py @@ -58,6 +58,7 @@ from .scope import ( invalidate_scope_peer_cache, remove_session_from_scope, resolve_scope_peers, + resolve_scope_session_union, update_scope_backfill_status, ) from .session import ( @@ -81,16 +82,24 @@ from .webhook import ( list_webhook_endpoints, ) from .workspace import ( + ActivePeer, WorkspaceDeletionResult, + WorkspaceStats, check_no_active_sessions, delete_workspace, + get_active_peers, get_all_workspaces, get_or_create_workspace, get_workspace, + get_workspace_stats, update_workspace, ) __all__ = [ + "get_workspace_stats", + "get_active_peers", + "WorkspaceStats", + "ActivePeer", # Collection "get_collection", "get_or_create_collection", @@ -150,6 +159,7 @@ __all__ = [ "invalidate_scope_peer_cache", "remove_session_from_scope", "resolve_scope_peers", + "resolve_scope_session_union", "update_scope_backfill_status", # Session "SessionDeletionResult", diff --git a/src/crud/collection.py b/src/crud/collection.py index 63a775e3..a5101b6e 100644 --- a/src/crud/collection.py +++ b/src/crud/collection.py @@ -10,7 +10,8 @@ from sqlalchemy.orm import make_transient_to_detached from src import models from src.cache.client import ( cache, - get_cache_namespace, + cache_key_namespace, + cache_prefix_namespace, safe_cache_delete, safe_cache_set, ) @@ -22,13 +23,13 @@ logger = getLogger(__name__) COLLECTION_CACHE_KEY_TEMPLATE = ( "v2:workspace:{workspace_name}:collection:{observer}:{observed}" ) -COLLECTION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2" +COLLECTION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2" def collection_cache_key(workspace_name: str, observer: str, observed: str) -> str: """Generate cache key for collection.""" return ( - get_cache_namespace() + cache_key_namespace() + ":" + COLLECTION_CACHE_KEY_TEMPLATE.format( workspace_name=workspace_name, @@ -41,7 +42,7 @@ def collection_cache_key(workspace_name: str, observer: str, observed: str) -> s @cache( key=COLLECTION_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s", - prefix=get_cache_namespace(), + prefix=cache_prefix_namespace(), condition=NOT_NONE, ) @cache.locked( diff --git a/src/crud/document.py b/src/crud/document.py index c20f1fda..b011717a 100644 --- a/src/crud/document.py +++ b/src/crud/document.py @@ -19,7 +19,7 @@ from src.crud.collection import get_or_create_collection from src.crud.peer import get_peer, reject_scope_observed from src.crud.session import get_session from src.dependencies import tracked_db -from src.embedding_client import embedding_client +from src.embedding_client import EmbeddingTokenLimitError, embedding_client from src.exceptions import ( ResourceNotFoundException, ValidationException, @@ -255,6 +255,9 @@ async def query_external_vector_document_ids( if _uses_pgvector(): return None + if top_k <= 0: + return [] + external_vector_store = get_external_vector_store() if external_vector_store is None: return [] @@ -386,7 +389,7 @@ async def query_documents( if embedding is None: try: embedding = await embedding_client.embed(query) - except ValueError as e: + except EmbeddingTokenLimitError as e: raise ValidationException( "Query exceeds maximum token limit of " + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}." @@ -1006,8 +1009,10 @@ async def create_observations( # Generate embeddings in batch contents = [obs.content for obs in observations] try: - embeddings = await embedding_client.simple_batch_embed(contents) - except ValueError as e: + embeddings = await embedding_client.simple_batch_embed( + contents, on_oversize="truncate" + ) + except EmbeddingTokenLimitError as e: raise ValidationException(str(e)) from e # Create document objects and track embeddings for vector store @@ -1255,7 +1260,7 @@ async def is_rejected_duplicate( # another derivation rather than resetting times_derived to 1. doc.times_derived = max(doc.times_derived, existing_doc.times_derived + 1) # Soft-delete the existing document - reconciliation will clean up vectors and hard-delete - existing_doc.deleted_at = datetime.datetime.now(datetime.timezone.utc) + existing_doc.deleted_at = datetime.datetime.now(datetime.UTC) await db.flush() return ( SemanticRejectionResult.REPLACED_EXISTING @@ -1301,7 +1306,7 @@ async def cleanup_soft_deleted_documents( Returns: Count of documents cleaned up (only those where vector deletion succeeded). """ - cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + cutoff = datetime.datetime.now(datetime.UTC) - datetime.timedelta( minutes=older_than_minutes ) diff --git a/src/crud/message.py b/src/crud/message.py index fbfb1698..15b4902c 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -6,6 +6,7 @@ from typing import Any from nanoid import generate as generate_nanoid from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute from src import models, schemas from src.config import settings @@ -159,6 +160,91 @@ async def resolve_session_scope( return (allowed, False) if allowed else (None, True) +def observer_scope_clause( + workspace_name: str, + observer: str, + session_column: InstrumentedAttribute[str], +) -> ColumnElement[bool]: + """Correlated EXISTS restricting ``session_column`` to the observer's sessions. + + The in-database equivalent of filtering on :func:`get_peer_session_names`. + Prefer it whenever the scope feeds a single SQL statement: a peer's + membership count is unbounded, and materializing the names turns each one + into its own bind parameter. The PostgreSQL wire protocol caps parameters + at 65535 per statement, so a peer in enough sessions produces a query the + driver cannot serialize at all β€” and the resulting error carries every + parameter in its text. + + Matches the loose membership definition ``get_peer_session_names`` uses by + default: any membership record grants visibility, whether or not the peer + has since left the session. + """ + session_peers = models.session_peers_table + return ( + select(1) + .where(session_peers.c.workspace_name == workspace_name) + .where(session_peers.c.peer_name == observer) + .where(session_peers.c.session_name == session_column) + .exists() + ) + + +def resolve_session_scope_clauses( + workspace_name: str, + session_name: str | None, + session_allowlist: list[str] | None, + observer: str | None, + session_column: InstrumentedAttribute[str], +) -> tuple[list[ColumnElement[bool]], bool]: + """SQL-side counterpart to :func:`resolve_session_scope`. + + Returns ``(clauses, deny)``, where ``clauses`` are ANDed onto the caller's + statement and ``deny=True`` means return an empty result without querying. + Unlike :func:`resolve_session_scope` this touches no database and grows no + bind parameters with the observer's session count β€” the observer half + becomes a correlated EXISTS instead of an ``IN`` over fetched names. + + Scoping matches :func:`resolve_session_scope` case for case, with one + deliberate difference: where that function returns ``deny=True`` because an + observer's membership (or its intersection with the allowlist) is empty, + this returns clauses that simply match no rows. Callers reach the same empty + result, at the cost of running one indexed query that returns nothing. + + ``session_allowlist`` stays an ``IN`` clause: it is caller-supplied and + therefore bounded, so it carries none of the unbounded-growth risk. + + Args: + workspace_name: Name of the workspace + session_name: A single pinned session, if the caller named one. The + caller applies its own equality filter; this function only checks + the allowlist permits it. + session_allowlist: Optional session allowlist. ``None`` is + unrestricted; an empty list fails closed. + observer: When set, scope is limited to this peer's sessions + session_column: The session-name column to scope, e.g. + ``models.Message.session_name`` + """ + if session_name: + # Fail closed when the allowlist forbids the pinned session, matching + # `resolve_session_scope` β€” routes guard this too, but the dialectic + # tools reach CRUD directly, so enforce it at the boundary. + if session_allowlist is not None and session_name not in session_allowlist: + return [], True + return [], False + + clauses: list[ColumnElement[bool]] = [] + + if observer is not None: + clauses.append(observer_scope_clause(workspace_name, observer, session_column)) + + if session_allowlist is not None: + if not session_allowlist: + return [], True + clauses.append(session_column.in_(session_allowlist)) + + return clauses, False + + def _apply_token_limit( base_conditions: list[ColumnElement[Any]], token_limit: int ) -> Select[tuple[models.Message]]: @@ -661,6 +747,9 @@ async def _search_messages_external( Multiple vector records can map to the same message (chunked embeddings), so we oversample from the vector store and deduplicate by message_id. """ + if limit <= 0: + return [] + external_vector_store = get_external_vector_store() if external_vector_store is None: return [] diff --git a/src/crud/peer.py b/src/crud/peer.py index 2f81f938..0bd1e120 100644 --- a/src/crud/peer.py +++ b/src/crud/peer.py @@ -12,7 +12,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import make_transient_to_detached from src import models, schemas -from src.cache.client import cache, get_cache_namespace, safe_cache_delete +from src.cache.client import ( + cache, + cache_key_namespace, + cache_prefix_namespace, + safe_cache_delete, +) from src.config import settings from src.crud.workspace import get_or_create_workspace from src.exceptions import ( @@ -32,13 +37,13 @@ logger = getLogger(__name__) PEER_NAME_MAX_LENGTH = 512 PEER_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:peer:{peer_name}" -PEER_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2" +PEER_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2" def peer_cache_key(workspace_name: str, peer_name: str) -> str: """Generate cache key for peer.""" return ( - get_cache_namespace() + cache_key_namespace() + ":" + PEER_CACHE_KEY_TEMPLATE.format( workspace_name=workspace_name, @@ -391,7 +396,7 @@ async def get_or_create_peers( @cache( key=PEER_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s", - prefix=get_cache_namespace(), + prefix=cache_prefix_namespace(), condition=NOT_NONE, ) @cache.locked( diff --git a/src/crud/representation.py b/src/crud/representation.py index 57e6e8e1..85b3e931 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -13,7 +13,7 @@ from src import crud, exceptions, models, schemas from src.config import settings from src.dependencies import tracked_db from src.dreamer.dream_scheduler import check_and_schedule_dream -from src.embedding_client import embedding_client +from src.embedding_client import EmbeddingTokenLimitError, embedding_client from src.schemas import ResolvedConfiguration from src.telemetry.events import EmbeddingCallPurpose from src.telemetry.logging import accumulate_metric @@ -25,6 +25,7 @@ from src.utils.representation import ( Representation, allowlist_safe_levels, ) +from src.utils.sanitization import strip_nul from src.utils.types import embedding_call_purpose logger = logging.getLogger(__name__) @@ -38,10 +39,21 @@ def _observation_text(obs: ExplicitObservation | DeductiveObservation) -> str: def _normalized_observation( obs: ExplicitObservation | DeductiveObservation, ) -> ExplicitObservation | DeductiveObservation: - """Return an observation with its persisted/embed text normalized.""" - text = _observation_text(obs).strip() + """Return an observation with its persisted/embed text normalized. + + NUL bytes are removed here rather than closer to the database so that the + text that gets embedded is the same text that gets stored. + """ + text = strip_nul(_observation_text(obs)).strip() if isinstance(obs, DeductiveObservation): - return obs.model_copy(update={"conclusion": text}) + return obs.model_copy( + update={ + "conclusion": text, + # Premises ride along in internal_metadata, and jsonb rejects + # NUL in strings just as text columns do. + "premises": strip_nul(obs.premises), + } + ) return obs.model_copy(update={"content": text}) @@ -87,10 +99,15 @@ class RepresentationManager: logger.debug("No observations to save") return empty_result + # Normalize before the emptiness check: str.strip() does not remove + # NUL, so content that normalizes away has to be dropped afterwards. all_observations = [ - _normalized_observation(obs) - for obs in representation.deductive + representation.explicit - if _observation_text(obs).strip() + normalized + for normalized in ( + _normalized_observation(obs) + for obs in representation.deductive + representation.explicit + ) + if _observation_text(normalized) ] if not all_observations: logger.debug("No non-empty observations to save") @@ -107,9 +124,9 @@ class RepresentationManager: parent_category="representation", ): embeddings = await embedding_client.simple_batch_embed( - observation_texts + observation_texts, on_oversize="truncate" ) - except ValueError as e: + except EmbeddingTokenLimitError as e: raise exceptions.ValidationException( "Observation content exceeds maximum token limit of " + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}." diff --git a/src/crud/scope.py b/src/crud/scope.py index d5b92c31..0fc8e41f 100644 --- a/src/crud/scope.py +++ b/src/crud/scope.py @@ -319,6 +319,26 @@ async def resolve_scope_peers( return resolved +async def resolve_scope_session_union( + db: AsyncSession, + workspace_name: str, + scope_names: Sequence[str], +) -> list[str]: + """Return the union of member sessions across the given scopes.""" + from src.crud.message import get_peer_session_names + + union: list[str] = [] + seen: set[str] = set() + for scope_peer in await resolve_scope_peers(db, workspace_name, scope_names): + for session_name in await get_peer_session_names( + db, workspace_name, scope_peer + ): + if session_name not in seen: + seen.add(session_name) + union.append(session_name) + return union + + async def get_scope_sessions( workspace_name: str, scope_name: str, diff --git a/src/crud/session.py b/src/crud/session.py index efa3f664..216d4949 100644 --- a/src/crud/session.py +++ b/src/crud/session.py @@ -29,7 +29,8 @@ from sqlalchemy.types import BigInteger, Boolean from src import models, schemas from src.cache.client import ( cache, - get_cache_namespace, + cache_key_namespace, + cache_prefix_namespace, safe_cache_delete, safe_cache_set, ) @@ -67,13 +68,13 @@ class SessionDeletionResult: SESSION_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:session:{session_name}" -SESSION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2" +SESSION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2" def session_cache_key(workspace_name: str, session_name: str) -> str: """Generate cache key for session.""" return ( - get_cache_namespace() + cache_key_namespace() + ":" + SESSION_CACHE_KEY_TEMPLATE.format( workspace_name=workspace_name, @@ -85,7 +86,7 @@ def session_cache_key(workspace_name: str, session_name: str) -> str: @cache( key=SESSION_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s", - prefix=get_cache_namespace(), + prefix=cache_prefix_namespace(), condition=NOT_NONE, ) @cache.locked( @@ -1050,25 +1051,31 @@ async def set_peers_for_session( peer_names: dict[str, schemas.SessionPeerConfig], ) -> list[models.SessionPeer]: """ - Set peers for a session, overwriting any existing peers. - If peers don't exist, they will be created. + Replace a session's ordinary peer set with ``peer_names``. + + Active members keep their joined_at but take the incoming configuration: + this is a replace, so the caller's map is the desired end state. Departed + members rejoin with the incoming configuration. Scope memberships are + preserved. Args: db: Database session workspace_name: Name of the workspace session_name: Name of the session - peer_names: Set of peer names to set for the session + peer_names: Mapping of peer names to session-level configuration Returns: List of SessionPeer objects for all peers in the session Raises: ResourceNotFoundException: If the session does not exist + ObserverException: If the resulting peer set would exceed the observer + limit """ - # Validate observer limit before making any changes - observer_count = count_observers_in_config(peer_names) - if observer_count > settings.SESSION_OBSERVERS_LIMIT: - raise ObserverException(session_name, observer_count) + # No observer pre-check here: an already-active membership keeps its stored + # configuration, so the incoming map is not what lands. Counting it would + # reject a request that lowers the observer count as often as one that raises + # it. _get_or_add_peers_to_session enforces the limit on the resulting rows. # Verify session exists stmt = ( @@ -1084,20 +1091,21 @@ async def set_peers_for_session( f"Session {session_name} not found in workspace {workspace_name}" ) - # Soft delete every *ordinary* active membership. Scope memberships are - # deliberately preserved: this route replaces the peers the caller names, and a - # caller detaches a scope by simply *omitting* it from an otherwise valid - # replacement map β€” never naming it, so no request-level guard can see it. - # Without the exclusion a plain replacement silently bypasses the facade that - # owns scope membership and its removal reconciliation. Being part of the - # UPDATE, this holds regardless of the request body or concurrent scope - # creation. + # Soft delete every *ordinary* active membership not in the incoming map. + # Scope memberships are deliberately preserved: this route replaces the peers + # the caller names, and a caller detaches a scope by simply *omitting* it from + # an otherwise valid replacement map β€” never naming it, so no request-level + # guard can see it. Without the exclusion a plain replacement silently + # bypasses the facade that owns scope membership and its removal + # reconciliation. Being part of the UPDATE, this holds regardless of the + # request body or concurrent scope creation. update_stmt = ( update(models.SessionPeer) .where( models.SessionPeer.session_name == session_name, models.SessionPeer.workspace_name == workspace_name, models.SessionPeer.left_at.is_(None), # Only update active peers + models.SessionPeer.peer_name.notin_(peer_names.keys()), ~exists( select(models.Peer.id) .where(models.Peer.workspace_name == workspace_name) @@ -1118,12 +1126,14 @@ async def set_peers_for_session( ) _reject_resolved_scope_peers(peers_result.resource) - # Add new peers to session + # Add new peers to session. This route replaces the session's peer set, so the + # incoming configuration is authoritative even for an already-active member. peers = await _get_or_add_peers_to_session( db, workspace_name=workspace_name, session_name=session_name, peer_names=peer_names, + replace_config=True, ) await db.commit() @@ -1162,13 +1172,22 @@ async def _get_or_add_peers_to_session( peer_names: dict[str, schemas.SessionPeerConfig], *, fetch_after_upsert: bool = True, + replace_config: bool = False, ) -> list[models.SessionPeer]: """ Upsert session-peer memberships for a session and optionally fetch the active memberships afterward. New peers are inserted, peers that previously left the session are rejoined, - and already-active peers keep their existing session-level configuration. + and already-active peers keep their existing joined_at. + + An already-active peer also keeps its stored configuration unless + ``replace_config`` is set: an add must not overwrite configuration it was + never asked about, while a replace states the desired end state. + + The observer limit is checked against the rows the upsert actually produced, + not against the incoming map, since under the add semantics the incoming map + is not necessarily what lands. Args: db: Database session @@ -1177,13 +1196,17 @@ async def _get_or_add_peers_to_session( peer_names: Mapping of peer names to session-level configuration fetch_after_upsert: If True, query and return the active session peers after the upsert. If False, skip that read and return an empty list. + replace_config: If True, an already-active membership takes the incoming + configuration instead of keeping its stored one. Set by replace-style + callers; leave False for add-style callers. Returns: Active SessionPeer objects after the upsert, or an empty list when the post-upsert fetch is skipped Raises: - ObserverException: If adding peers would exceed the observer limit + ObserverException: If the resulting active peer set would exceed the + observer limit """ # If no peers to add, skip the insert and just return existing active session peers if not peer_names: @@ -1202,43 +1225,10 @@ async def _get_or_add_peers_to_session( # costs document rows, not LLM calls, and counting them would # cap scopes-per-session at SESSION_OBSERVERS_LIMIT and surface as an # observer-shaped 400 through a facade that hides observers entirely. + # Resolved up front because the limit check below gates on whether this + # request asks for a *non-scope* observer. scopes_being_added = await scope_peer_names(db, workspace_name, peer_names.keys()) - # Only validate observer limit if we're adding non-scope peers with observe_others=True - new_observer_count = count_observers_in_config( - {n: c for n, c in peer_names.items() if n not in scopes_being_added} - ) - - if new_observer_count > 0: - # Use a single efficient query to count existing observers not being updated - # This uses PostgreSQL's JSONB operators to check the observe_others field directly - existing_observers_stmt = select(func.count()).where( - models.SessionPeer.session_name == session_name, - models.SessionPeer.workspace_name == workspace_name, - models.SessionPeer.left_at.is_(None), # Only active peers - models.SessionPeer.peer_name.notin_( - peer_names.keys() - ), # Exclude peers being updated - models.SessionPeer.configuration["observe_others"].astext.cast( - Boolean - ), # Only observers - # Existing scope memberships are excluded for the same reason as above. - ~exists( - select(models.Peer.id) - .where(models.Peer.workspace_name == workspace_name) - .where(models.Peer.name == models.SessionPeer.peer_name) - .where(scope_peer_clause()) - .correlate(models.SessionPeer) - ), - ) - result = await db.execute(existing_observers_stmt) - existing_observer_count = result.scalar() or 0 - - total_observers = existing_observer_count + new_observer_count - - if total_observers > settings.SESSION_OBSERVERS_LIMIT: - raise ObserverException(session_name, total_observers) - # Use upsert to handle both new peers and rejoining peers stmt = pg_insert(models.SessionPeer).values( [ @@ -1254,15 +1244,27 @@ async def _get_or_add_peers_to_session( ] ) - # On conflict, update joined_at and clear left_at (rejoin scenario) - # If left_at is not None (peer has left the session): Use the new configuration (stmt.excluded.configuration) - # If left_at is None (peer is still active): Keep the existing configuration (models.SessionPeer.configuration) + # On conflict, rejoin departed peers. joined_at always survives on an active + # membership -- advancing it would move the peer_perspective search window + # past messages the peer was present for (issue #940). + # + # Configuration depends on the caller's semantics. An add ("ensure this peer + # is here") must not silently overwrite a config it never asked about, so an + # active membership keeps its stored one. A replace ("these are the session's + # peers, configured thus") states a desired end state, so the incoming config + # wins -- otherwise PUT /peers could never change the configuration of a peer + # already in the session. stmt = stmt.on_conflict_do_update( index_elements=["session_name", "peer_name", "workspace_name"], set_={ - "joined_at": func.now(), + "joined_at": case( + (models.SessionPeer.left_at.is_not(None), func.now()), + else_=models.SessionPeer.joined_at, + ), "left_at": None, - "configuration": case( + "configuration": stmt.excluded.configuration + if replace_config + else case( (models.SessionPeer.left_at.is_not(None), stmt.excluded.configuration), else_=models.SessionPeer.configuration, ), @@ -1270,6 +1272,49 @@ async def _get_or_add_peers_to_session( ) await db.execute(stmt) + # Enforce the observer limit on the resulting rows rather than predicting them. + # Under add semantics an already-active membership keeps its stored + # configuration (see the CASE above), so the incoming config is not what lands + # and cannot be counted: predicting from it silently undercounts preserved + # observers and lets a session grow past the limit indefinitely by re-sending + # its current observers at a lower config alongside new ones. Counting after + # the upsert is correct under both configuration semantics and cannot desync + # from those branches. Raising here rolls the upsert back: ObserverException + # is never caught, and both get_db and tracked_db roll back on exception. + # + # Gated on the request actually asking for a non-scope observer so that a + # session already over the limit keeps behaving as it does today: it can + # still take non-observers and scope attachments, and only a request that + # would make it worse is rejected. + if any( + config.observe_others + for peer_name, config in peer_names.items() + if peer_name not in scopes_being_added + ): + observer_count = ( + await db.scalar( + select(func.count()).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.left_at.is_(None), # Only active peers + models.SessionPeer.configuration["observe_others"].astext.cast( + Boolean + ), # Only observers + # Scope memberships are excluded for the reason given above. + ~exists( + select(models.Peer.id) + .where(models.Peer.workspace_name == workspace_name) + .where(models.Peer.name == models.SessionPeer.peer_name) + .where(scope_peer_clause()) + .correlate(models.SessionPeer) + ), + ) + ) + or 0 + ) + if observer_count > settings.SESSION_OBSERVERS_LIMIT: + raise ObserverException(session_name, observer_count) + if not fetch_after_upsert: return [] diff --git a/src/crud/workspace.py b/src/crud/workspace.py index c9040f55..05536bf2 100644 --- a/src/crud/workspace.py +++ b/src/crud/workspace.py @@ -1,6 +1,8 @@ """CRUD helpers for workspace records and workspace deletion checks.""" +from collections.abc import Sequence from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from logging import getLogger from typing import Any @@ -13,7 +15,8 @@ from sqlalchemy.orm import make_transient_to_detached from src import models, schemas from src.cache.client import ( cache, - get_cache_namespace, + cache_key_namespace, + cache_prefix_namespace, safe_cache_delete, safe_cache_set, ) @@ -38,13 +41,13 @@ class WorkspaceDeletionResult: WORKSPACE_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}" -WORKSPACE_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2" +WORKSPACE_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2" def workspace_cache_key(workspace_name: str) -> str: """Generate cache key for workspace.""" return ( - get_cache_namespace() + cache_key_namespace() + ":" + WORKSPACE_CACHE_KEY_TEMPLATE.format(workspace_name=workspace_name) ) @@ -53,7 +56,7 @@ def workspace_cache_key(workspace_name: str) -> str: @cache( key=WORKSPACE_CACHE_KEY_TEMPLATE, ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s", - prefix=get_cache_namespace(), + prefix=cache_prefix_namespace(), condition=NOT_NONE, ) @cache.locked( @@ -535,3 +538,206 @@ async def delete_workspace( messages_deleted=messages_count, conclusions_deleted=conclusions_count, ) + + +@dataclass +class WorkspaceStats: + """Workspace-level aggregate statistics.""" + + peer_count: int + session_count: int + message_count: int + oldest_message_at: datetime | None + newest_message_at: datetime | None + + +@dataclass +class ActivePeer: + """A peer with activity metrics.""" + + name: str + message_count: int + last_message_at: datetime | None + + +async def get_workspace_stats( + db: AsyncSession, + workspace_name: str, + session_names: Sequence[str] | None = None, +) -> WorkspaceStats: + """Get aggregate statistics for a workspace. + + Scope peers are excluded from ``peer_count``. When ``session_names`` is + provided, counts are restricted to that allowlist (empty β†’ zeros). + """ + from src.crud.peer import scope_peer_clause + + if session_names is not None and not session_names: + return WorkspaceStats( + peer_count=0, + session_count=0, + message_count=0, + oldest_message_at=None, + newest_message_at=None, + ) + + msg_filters = [models.Message.workspace_name == workspace_name] + if session_names is not None: + msg_filters.append(models.Message.session_name.in_(session_names)) + peer_count = int( + await db.scalar( + select(func.count(func.distinct(models.Message.peer_name))) + .select_from(models.Message) + .join( + models.Peer, + (models.Peer.workspace_name == models.Message.workspace_name) + & (models.Peer.name == models.Message.peer_name), + ) + .where(*msg_filters, ~scope_peer_clause()) + ) + or 0 + ) + session_count = int( + await db.scalar( + select(func.count(models.Session.id)).where( + models.Session.workspace_name == workspace_name, + models.Session.name.in_(session_names), + ) + ) + or 0 + ) + else: + peer_count = int( + await db.scalar( + select(func.count(models.Peer.id)).where( + models.Peer.workspace_name == workspace_name, + ~scope_peer_clause(), + ) + ) + or 0 + ) + session_count = int( + await db.scalar( + select(func.count(models.Session.id)).where( + models.Session.workspace_name == workspace_name + ) + ) + or 0 + ) + + msg_row = ( + await db.execute( + select( + func.count(models.Message.id), + func.min(models.Message.created_at), + func.max(models.Message.created_at), + ).where(*msg_filters) + ) + ).one() + message_count = int(msg_row[0] or 0) + oldest_message_at = msg_row[1] + newest_message_at = msg_row[2] + + return WorkspaceStats( + peer_count=peer_count, + session_count=session_count, + message_count=message_count, + oldest_message_at=oldest_message_at, + newest_message_at=newest_message_at, + ) + + +# Activity window for get_active_peers. Bounds the per-peer aggregation +# (which runs on the workspace-chat request path) so it never scans a large +# workspace's full message history; peers idle longer than this still appear +# via the Peer outer join, with zero count and no last-active date. +ACTIVE_PEER_WINDOW_DAYS = 90 + + +async def get_active_peers( + db: AsyncSession, + workspace_name: str, + limit: int = 20, + sort_by: str = "recent_activity", + session_names: Sequence[str] | None = None, +) -> list[ActivePeer]: + """Get the most active peers in a workspace. + + Activity is measured over the trailing ACTIVE_PEER_WINDOW_DAYS days. + Scope peers are excluded. When ``session_names`` is provided, only peers + with messages in that allowlist are returned (empty β†’ no peers). + """ + from src.crud.peer import scope_peer_clause + + if limit <= 0: + return [] + if session_names is not None and not session_names: + return [] + limit = min(limit, 50) + + window_start = datetime.now(timezone.utc) - timedelta(days=ACTIVE_PEER_WINDOW_DAYS) + + msg_filters = [ + models.Message.workspace_name == workspace_name, + models.Message.created_at >= window_start, + ] + if session_names is not None: + msg_filters.append(models.Message.session_name.in_(session_names)) + + # Subquery: aggregate messages per peer within the activity window + subq = ( + select( + models.Message.peer_name, + func.count(models.Message.id).label("msg_count"), + func.max(models.Message.created_at).label("last_msg_at"), + ) + .where(*msg_filters) + .group_by(models.Message.peer_name) + .subquery() + ) + + columns = ( + models.Peer.name, + func.coalesce(subq.c.msg_count, 0).label("msg_count"), + subq.c.last_msg_at, + ) + if session_names is not None: + stmt = ( + select(*columns) + .join(subq, models.Peer.name == subq.c.peer_name) + .where( + models.Peer.workspace_name == workspace_name, + ~scope_peer_clause(), + ) + ) + else: + stmt = ( + select(*columns) + .outerjoin(subq, models.Peer.name == subq.c.peer_name) + .where( + models.Peer.workspace_name == workspace_name, + ~scope_peer_clause(), + ) + ) + + # Peer name as secondary key so ties (notably all-NULL activity in young + # workspaces) return a stable order across calls. + if sort_by == "message_count": + stmt = stmt.order_by( + func.coalesce(subq.c.msg_count, 0).desc(), models.Peer.name + ) + else: + # Default: recent_activity β€” peers with most recent messages first + stmt = stmt.order_by(subq.c.last_msg_at.desc().nulls_last(), models.Peer.name) + + stmt = stmt.limit(limit) + + rows = (await db.execute(stmt)).all() + return [ + ActivePeer( + name=row[0], + message_count=int(row[1]), + last_message_at=row[2], + ) + for row in rows + ] diff --git a/src/db.py b/src/db.py index 005d52eb..633f6f06 100644 --- a/src/db.py +++ b/src/db.py @@ -12,7 +12,11 @@ from sqlalchemy.orm import declarative_base from sqlalchemy.pool import NullPool, QueuePool from src.config import settings -from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge +from src.telemetry.prometheus.metrics import ( + db_connections_established_counter, + db_connections_open_gauge, + db_queries_in_flight_gauge, +) logger = logging.getLogger(__name__) @@ -220,6 +224,81 @@ def register_db_query_instrumentation(instance_type: str) -> None: _db_query_instrumentation_registered = True +class DBConnectionTracker: + """Tracks physical DB connections open on this engine via pool lifecycle events. + + Drift-proof, mirroring ``DBQueryInflightTracker``: marks the ``ConnectionRecord`` + on ``connect`` and decrements only if that mark is still present on + ``close``/``invalidate``, so each physical connection increments the gauge + exactly once and decrements at most once β€” it can't leak upward or go negative + when both events fire during invalidation cleanup. Works for every pool class, + including ``NullPool`` (whose pool keeps no records, so the scrape-time + ``db_pool_connections`` collector reads zero). + """ + + # Marker on ConnectionRecord.info recording that we incremented for this + # connection, so we decrement exactly once across close/invalidate. + OPEN_KEY: str = "_honcho_conn_open" + + def __init__(self, open_child: Any, established_child: Any) -> None: + self._open: Any = open_child + self._established: Any = established_child + + def on_connect(self, _dbapi_connection: Any, connection_record: Any) -> None: + try: + connection_record.info[self.OPEN_KEY] = True + self._established.inc() + self._open.inc() + except Exception: + logger.debug("db-connection gauge inc failed", exc_info=True) + + def on_close(self, _dbapi_connection: Any, connection_record: Any, *_: Any) -> None: + try: + if connection_record is not None and connection_record.info.pop( + self.OPEN_KEY, False + ): + self._open.dec() + except Exception: + logger.debug("db-connection gauge dec failed", exc_info=True) + + +# Process-wide tracker, created at registration (None until then / if metrics off). +_connection_tracker: DBConnectionTracker | None = None + + +_db_connection_instrumentation_registered = False + + +def register_db_connection_instrumentation(instance_type: str) -> None: + """Attach physical-connection tracking to the engine (no-op if metrics off). + + Counts connections via pool lifecycle events, so it reports real numbers under + any pool class β€” unlike the pool-object collector, which reads zero under + ``NullPool``. Pre-resolving the labeled children materializes both series at 0, + so an absent series signals a broken scrape rather than "no connections" (the + zero-init convention). Idempotent: repeated calls won't attach duplicate + listeners, which would double-count connections. + """ + global _connection_tracker, _db_connection_instrumentation_registered + if not settings.METRICS.ENABLED or _db_connection_instrumentation_registered: + return + open_child = db_connections_open_gauge.labels(instance_type=instance_type) + established_child = db_connections_established_counter.labels( + instance_type=instance_type + ) + _connection_tracker = DBConnectionTracker(open_child, established_child) + sync_engine = engine.sync_engine + event.listen(sync_engine, "connect", _connection_tracker.on_connect) + # A connection is torn down by close (normal return / recycle discard), + # invalidate (broken connection), or detach β€” the last fires on GC-cleanup of an + # abandoned async connection, where NullPool's close is a no-op so `close` never + # fires. All three carry the ConnectionRecord; the marker dedupes if more than + # one fires for the same connection. + for teardown_event in ("close", "invalidate", "detach"): + event.listen(sync_engine, teardown_event, _connection_tracker.on_close) + _db_connection_instrumentation_registered = True + + # Define your naming convention convention = { "ix": "ix_%(table_name)s_%(column_0_N_name)s", # Index - supports multi-column diff --git a/src/deriver/__main__.py b/src/deriver/__main__.py index c56ed6a0..9f5e4d91 100644 --- a/src/deriver/__main__.py +++ b/src/deriver/__main__.py @@ -6,10 +6,15 @@ import uvloop from prometheus_client import start_http_server from src.config import settings -from src.db import engine, register_db_query_instrumentation +from src.db import ( + engine, + register_db_connection_instrumentation, + register_db_query_instrumentation, +) from src.startup import validate_embedding_schema from src.telemetry import ( initialize_telemetry_async, + prometheus_metrics, register_db_pool_collector, shutdown_telemetry, ) @@ -25,6 +30,13 @@ def start_metrics_server() -> None: # Expose DB connection-pool stats for this deriver instance. register_db_pool_collector("deriver") register_db_query_instrumentation("deriver") + register_db_connection_instrumentation("deriver") + + # region ai + # Zero-init bounded-label counters so a missing series signals a broken scrape, + # not "no events" β€” see initialize_bounded_metrics. No-op if metrics off. + # endregion + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") logger.info("Prometheus metrics server started on port 9090") diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 2ad7d14f..f76c4d52 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -7,6 +7,7 @@ from src import crud from src.config import ConfiguredModelSettings, settings from src.crud.representation import RepresentationManager from src.dependencies import tracked_db +from src.exceptions import RepresentationSaveError from src.llm import honcho_llm_call from src.llm.types import LLMTelemetryContext from src.models import Message @@ -196,6 +197,7 @@ async def process_representation_tasks_batch( agg_representation_result = crud.CreateDocumentsResult() successful_observer_count = 0 + save_errors: list[tuple[str, Exception]] = [] if observations.is_empty() or not message_ids: logger.warning( "Deriver generated zero observations for messages %s:%s in %s/%s!", @@ -236,10 +238,11 @@ async def process_representation_tasks_batch( representation_result.semantic_dup_replaced_count ) successful_observer_count += 1 - except Exception as e: - logger.error( - "Failed to save representation for observer %s: %s", observer, e + except Exception as e: # noqa: BLE001 + logger.exception( + "Failed to save representation for observer %s", observer ) + save_errors.append((observer, e)) # Log metrics overall_duration = (time.perf_counter() - overall_start) * 1000 @@ -337,5 +340,16 @@ async def process_representation_tasks_batch( exact_dup_in_batch_count=agg_representation_result.exact_dup_in_batch_count, semantic_dup_rejected_count=agg_representation_result.semantic_dup_rejected_count, semantic_dup_replaced_count=agg_representation_result.semantic_dup_replaced_count, + failed_observer_count=len(save_errors), ) ) + + if save_errors and successful_observer_count == 0: + details = "; ".join( + f"{observer}: {exc.__class__.__name__}: {exc}" + for observer, exc in save_errors + ) + raise RepresentationSaveError( + f"save_representation failed for all {len(save_errors)} observer(s): " + + details + ) from save_errors[0][1] diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index ba066741..47f8dff7 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -14,6 +14,7 @@ from src import crud, models from src.config import ReasoningLevel from src.dependencies import tracked_db from src.dialectic.core import DialecticAgent +from src.dialectic.workspace import WorkspaceDialecticAgent from src.exceptions import ValidationException from src.utils.config_helpers import get_configuration from src.utils.scopes import is_scope_peer @@ -205,3 +206,61 @@ async def agentic_chat_stream( async for chunk in agent.answer_stream(query, response_model=response_model): yield chunk + + +async def workspace_chat( + workspace_name: str, + session_name: str | None, + query: str, + reasoning_level: ReasoningLevel = "low", + response_model: type[BaseModel] | None = None, + session_allowlist: list[str] | None = None, +) -> str: + """Answer a query across all peers in a workspace.""" + async with tracked_db("dialectic.workspace_preflight", read_only=True) as db: + await crud.get_workspace(db, workspace_name=workspace_name) + session = None + if session_name: + session = await crud.get_session( + db, workspace_name=workspace_name, session_name=session_name + ) + session_id = session.id if session else None + # DB session closed -- agent runs without holding a connection + + agent = WorkspaceDialecticAgent( + workspace_name=workspace_name, + session_name=session_name, + session_id=session_id, + reasoning_level=reasoning_level, + session_allowlist=session_allowlist, + ) + return await agent.answer(query, response_model=response_model) + + +async def workspace_chat_stream( + workspace_name: str, + session_name: str | None, + query: str, + reasoning_level: ReasoningLevel = "low", + response_model: type[BaseModel] | None = None, + session_allowlist: list[str] | None = None, +) -> AsyncIterator[str]: + """Streaming variant of :func:`workspace_chat`.""" + async with tracked_db("dialectic.workspace_preflight", read_only=True) as db: + await crud.get_workspace(db, workspace_name=workspace_name) + session = None + if session_name: + session = await crud.get_session( + db, workspace_name=workspace_name, session_name=session_name + ) + session_id = session.id if session else None + + agent = WorkspaceDialecticAgent( + workspace_name=workspace_name, + session_name=session_name, + session_id=session_id, + reasoning_level=reasoning_level, + session_allowlist=session_allowlist, + ) + async for chunk in agent.answer_stream(query, response_model=response_model): + yield chunk diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 9580b94c..57964c87 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -105,7 +105,15 @@ class DialecticAgent: { "role": "system", "content": prompts.agent_system_prompt( - observer, observed, observer_peer_card, observed_peer_card + observer, + observed, + observer_peer_card, + observed_peer_card, + available_tools={ + name + for tool in self._select_tools() + if isinstance((name := tool.get("name")), str) + }, ), } ] @@ -303,9 +311,8 @@ class DialecticAgent: if prefetched_observations: user_content = ( f"Query: {query}\n\n" - f"## Relevant Observations (prefetched)\n" - f"The following observations were found to be semantically relevant to your query. " - f"Use these as primary context. You may still use tools to find additional information if needed.\n\n" + f"## {self._prefetch_heading()}\n" + f"{self._prefetch_intro()}\n\n" f"{prefetched_observations}" ) accumulate_metric( @@ -318,7 +325,14 @@ class DialecticAgent: tool_executor: Callable[ [str, dict[str, Any]], Any - ] = await create_tool_executor( + ] = await self._create_tool_executor() + + return tool_executor, task_name, run_id, start_time + + async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]: + """Build the tool executor. Subclasses override to change tool scoping + (e.g. WorkspaceDialecticAgent uses the workspace executor).""" + return await create_tool_executor( workspace_name=self.workspace_name, session_name=self.session_name, session_allowlist=self.session_allowlist, @@ -330,7 +344,16 @@ class DialecticAgent: parent_category="dialectic", ) - return tool_executor, task_name, run_id, start_time + def _prefetch_heading(self) -> str: + """Heading for the prefetched block in the user message.""" + return "Relevant Observations (prefetched)" + + def _prefetch_intro(self) -> str: + """Sentence introducing the prefetched block in the user message.""" + return ( + "The following observations were found to be semantically relevant to your query. " + "Use these as primary context. You may still use tools to find additional information if needed." + ) def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext: """Build the LLMTelemetryContext shared by answer() and answer_stream(). diff --git a/src/dialectic/prompts.py b/src/dialectic/prompts.py index 948bff70..5dfe6604 100644 --- a/src/dialectic/prompts.py +++ b/src/dialectic/prompts.py @@ -2,24 +2,138 @@ System prompts for the Dialectic Agent. """ +from collections.abc import Iterable + +# Curated tool docs, keyed by the `name` each loadout actually exposes. +# `_select_tools` filters this set per request (minimal / session allowlist). +_PAIR_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [ + ( + "Memory", + [ + ( + "search_memory", + "Semantic search over conclusions about this pair.", + ), + ( + "get_reasoning_chain", + "Premises and downstream conclusions for a specific conclusion.", + ), + ( + "get_observation_context", + "Messages around a specific conclusion.", + ), + ], + ), + ( + "Conversation", + [ + ( + "search_messages", + "Semantic search over messages in this query's scope.", + ), + ( + "grep_messages", + "Exact text search. Use for names, dates, keywords.", + ), + ( + "get_messages_by_date_range", + "Messages in a time window.", + ), + ( + "search_messages_temporal", + "Semantic search with a date filter.", + ), + ], + ), +] + +_WORKSPACE_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [ + ( + "Discovery", + [ + ( + "get_workspace_stats", + "Counts (peers, sessions, messages), date range, and the most active peers.", + ), + ], + ), + ( + "Memory (pair-scoped β€” you must name the pair)", + [ + ( + "search_memory", + "Semantic search over conclusions. Requires `observer` and `observed`. For a peer's own representation, set both to the same name. Use different names only when you want one peer's view of another.", + ), + ( + "get_peer_card", + "Biographical summary for a pair. Same observer/observed rule.", + ), + ( + "get_reasoning_chain", + "Premises and downstream conclusions for a specific conclusion.", + ), + ], + ), + ( + "Conversation (workspace-wide β€” results include `peer_name`)", + [ + ("search_messages", "Semantic search over messages."), + ("grep_messages", "Exact text search."), + ( + "get_observation_context", + "Messages around a specific conclusion.", + ), + ("get_messages_by_date_range", "Messages in a time window."), + ("search_messages_temporal", "Semantic search with a date filter."), + ], + ), +] + +PAIR_PROMPT_TOOLS: frozenset[str] = frozenset( + name for _, items in _PAIR_TOOL_GROUPS for name, _ in items +) +WORKSPACE_PROMPT_TOOLS: frozenset[str] = frozenset( + name for _, items in _WORKSPACE_TOOL_GROUPS for name, _ in items +) + + +def _available_tool_names( + available_tools: Iterable[str] | None, + default: frozenset[str], +) -> frozenset[str]: + if available_tools is None: + return default + return frozenset(available_tools) + + +def _render_tool_groups( + available: frozenset[str], + groups: list[tuple[str, list[tuple[str, str]]]], +) -> str: + parts: list[str] = [] + for heading, items in groups: + lines = [f"- `{name}`: {desc}" for name, desc in items if name in available] + if lines: + parts.append(f"**{heading}**\n" + "\n".join(lines)) + return "\n\n".join(parts) + def agent_system_prompt( observer: str, observed: str, observer_peer_card: list[str] | None, observed_peer_card: list[str] | None, + available_tools: Iterable[str] | None = None, ) -> str: - """ - Generate the agent system prompt for the dialectic agent. + """System prompt for pair-scoped dialectic recall. Args: observer: The peer making the query observed: The peer being queried about observer_peer_card: Biographical information about the observer observed_peer_card: Biographical information about the observed peer - - Returns: - Formatted system prompt string for the agent + available_tools: Tool names offered on this request. Defaults to the + full pair loadout. """ # Determine if we have any peer card data peer_cards_enabled = ( @@ -79,25 +193,25 @@ Peer cards are **constructed summaries** - they are synthesized from the same ob - The peer card is a convenience summary, not a separate source of truth """ - return f""" -You are a helpful and concise context synthesis agent that answers questions about users by gathering relevant information from a memory system. + tools = _available_tool_names(available_tools, PAIR_PROMPT_TOOLS) + tools_section = _render_tool_groups(tools, _PAIR_TOOL_GROUPS) -Always give users the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. You have many tools for gathering context. Search wisely. + return f""" +You are Honcho's dialectic: a recall agent that answers questions from memory about one peer, or about one peer's understanding of another. + +Honcho is a memory system. Applications record conversations; Honcho derives conclusions about the people and agents in them. You are the query interface for one observer/observed pair. You do not speak as a participant. You search memory and synthesize a grounded answer. + +A **peer** is any participant, human or AI. A **session** is a conversation they take part in. A **message** is a raw turn. A **conclusion** (tools may say observation) is a derived or stored fact about a peer, kept in this pair. A **peer card** is a short constructed bio for the pair, synthesized from the same conclusions β€” a convenience summary, not a separate source of truth. + +Always give the asker the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. Search wisely. {perspective_section} {peer_card_explanation} -## AVAILABLE TOOLS +## TOOLS -**Observation Tools (read):** -- `search_memory`: Semantic search over observations about the peer. Use for specific topics. -- `get_reasoning_chain`: **CRITICAL for grounding answers**. Use this to traverse the reasoning tree for any observation. Shows premises (what it's based on) and conclusions (what depends on it). +Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have. -**Conversation Tools (read):** -- `search_messages`: Semantic search over messages in the session. -- `grep_messages`: Grep for text matches in messages. Use for specific names, dates, keywords. -- `get_observation_context`: Get messages surrounding specific observations. -- `get_messages_by_date_range`: Get messages within a specific time period. -- `search_messages_temporal`: Semantic search with date filtering. +{tools_section} ## WORKFLOW @@ -166,10 +280,6 @@ Always give users the answer *they expect* based on the message history -- the g - Apply user preferences to your response style if relevant - **For enumeration questions**: Before answering, ask yourself "Could there be more items I haven't found?" If you haven't done multiple grep searches AND a semantic search, keep searching -8. **Save novel deductions** (optional): - - If you discovered new insights by combining existing observations - - Use `create_observations_deductive` to save these for future queries - ## CRITICAL: HANDLING CONTRADICTORY INFORMATION As you search, actively watch for contradictions - cases where the user has made conflicting statements: @@ -235,3 +345,64 @@ After gathering context, reason through the information you found *before* stati Do not explain your tool usage - just provide the synthesized answer. """ + + +def workspace_agent_system_prompt( + available_tools: Iterable[str] | None = None, +) -> str: + """System prompt for workspace-wide dialectic recall.""" + tools = _available_tool_names(available_tools, WORKSPACE_PROMPT_TOOLS) + tools_section = _render_tool_groups(tools, _WORKSPACE_TOOL_GROUPS) + return f""" +You are Honcho's workspace dialectic: a recall agent that answers questions about everyone and everything stored in this workspace. + +## HONCHO + +Honcho is a memory system. Applications record conversations here; Honcho derives conclusions about the people and agents in those conversations. You are the query interface over one workspace. You do not speak as a participant. You search memory and synthesize a grounded answer. + +## THIS WORKSPACE + +A workspace is one isolated tenant. Everything you can see belongs to it. Inside it: + +- **Peer**: any participant, human or AI. Both are first-class. +- **Session**: a conversation that one or more peers take part in. +- **Message**: a raw turn someone said in a session. Messages are the source material. +- **Conclusion** (tools may say observation): a fact Honcho derived, or that was stored, about a peer. Conclusions live in a pair: + - `observer` is whose model this is + - `observed` is who the fact is about + - A peer's own model of themselves is `observer` = `observed` = that peer's name. Most information lives there. + - One peer's model of another is `observer` = Alice, `observed` = Bob. +- **Peer card**: a short constructed bio for a pair, synthesized from the same conclusions. It is a convenience summary, not a separate source of truth. + +You are not bound to any one peer. Discover who is relevant, then query each pair individually. + +## TOOLS + +Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have. + +{tools_section} + +Message search is how you find peers the overview missed. Memory search is how you learn about a peer once you know their name. + +If this query is restricted to a session or a set of sessions, message tools already honor that restriction. Peer cards and reasoning chains may be unavailable then, because they span sessions. + +## WORKFLOW + +1. **Orient**. Scale and the most active peers are already in your query context. Call `get_workspace_stats` only if you need a refresh. If the query names a peer, go straight to that peer. + +2. **Discover**. If you do not know who is relevant, use `search_messages` or `grep_messages`. Hits carry peer names. + +3. **Recall**. For each relevant peer, `search_memory(observer=name, observed=name, query=...)`. For comparisons, search each peer separately, then compare. Only use a mixed observer/observed pair when the question is specifically about one peer's understanding of another. + +4. **Attribute**. Every fact you state names the peer it is about. If it is a cross-peer view, also name whose model it came from. Example: "Alice is a violinist." / "From Bob's model of Alice, …" + +5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use. + +## NEVER FABRICATE + +State only what you found. If you have related context but not the asked-for detail, say what you know and what you don't. "I don't have information about X" is the correct answer when memory is empty. Do not guess, hedge-invent, or fill gaps with general knowledge. + +## CONCLUSION LEVELS + +`explicit` conclusions are derived from a single session. Deductive and inductive conclusions consolidate across sessions. Prefer those for cross-session or cross-peer answers, and use `get_reasoning_chain` to check their premises. +""" diff --git a/src/dialectic/workspace.py b/src/dialectic/workspace.py new file mode 100644 index 00000000..5383cd75 --- /dev/null +++ b/src/dialectic/workspace.py @@ -0,0 +1,190 @@ +"""Workspace-level dialectic agent. + +Answers queries across ALL peers in a workspace. Where DialecticAgent is +bound to a single (observer, observed) pair, this agent routes first β€” +workspace stats, active peers, and peer cards are prefetched for +orientation; message search is workspace-flat and reveals which peers +discussed a topic β€” and then recalls through the same pair-scoped +observation machinery, supplying the pair as tool arguments. + +Observation search deliberately stays pair-scoped: it matches both the +(observer, observed) collection ownership and the per-pair vector-store +namespaces, and avoids retrieval dilution from a workspace-flat top-k. + +Design carried over from plastic-labs/honcho#373 (Dan), re-grown on the +current DialecticAgent seams instead of a base-class extraction. +""" + +import logging +from collections.abc import Callable +from typing import Any + +from src import crud +from src.config import ReasoningLevel, settings +from src.dependencies import tracked_db +from src.dialectic import prompts +from src.dialectic.core import DialecticAgent +from src.llm.types import LLMTelemetryContext +from src.utils.agent_tools import ( + WORKSPACE_DIALECTIC_TOOLS, + WORKSPACE_TOOLS_MINIMAL, + create_workspace_tool_executor, + format_workspace_stats, +) + +logger = logging.getLogger(__name__) + +# How many active peers (with their self peer cards) to inject at prefetch. +# Routing-obvious queries should resolve without a discovery tool round β€” +# each avoided tool round is a full model turn (~1.3s measured). +_PREFETCH_ACTIVE_PEERS = 5 + + +class WorkspaceDialecticAgent(DialecticAgent): + """Dialectic agent scoped to a whole workspace instead of a peer pair.""" + + def __init__( + self, + workspace_name: str, + session_name: str | None = None, + metric_key: str | None = None, + reasoning_level: ReasoningLevel = "low", + session_id: str | None = None, + session_allowlist: list[str] | None = None, + ) -> None: + super().__init__( + workspace_name=workspace_name, + session_name=session_name, + observer="", + observed="", + metric_key=metric_key, + reasoning_level=reasoning_level, + session_id=session_id, + session_allowlist=session_allowlist, + ) + # Replace the pair-oriented system prompt with the workspace one. + self.messages[0] = { + "role": "system", + "content": prompts.workspace_agent_system_prompt( + { + name + for tool in self._select_tools() + if isinstance((name := tool.get("name")), str) + } + ), + } + + # ------------------------------------------------------------------ + # DialecticAgent seams + # ------------------------------------------------------------------ + + async def _prefetch_relevant_observations(self, query: str) -> str | None: + """Orientation + routing prefetch: stats, active peers, peer cards. + + No semantic retrieval here β€” a workspace-flat observation top-k + would be dominated by the most verbose peers. Instead give the + agent what it needs to ROUTE: who is here, who is active, and what + is known about them at a glance. + """ + _ = query + # Like the base agent, prefetch failure degrades to no prefetched + # block rather than failing the whole request (the caller in + # _prepare_query does not guard this). + try: + async with tracked_db("dialectic.workspace_prefetch", read_only=True) as db: + stats = await crud.get_workspace_stats( + db, + self.workspace_name, + session_names=self.session_allowlist, + ) + if stats.peer_count == 0: + return None + peers = await crud.get_active_peers( + db, + self.workspace_name, + limit=_PREFETCH_ACTIVE_PEERS, + session_names=self.session_allowlist, + ) + # `peers` is already allowlist-filtered, but a peer card is a + # single cross-session aggregate: an in-scope peer's card can + # still carry facts derived from sessions outside the scope. + # Drop cards entirely under an allowlist β€” same rule the + # get_peer_card tool enforces β€” and route on stats alone. + cards: dict[str, list[str]] = {} + if self.session_allowlist is None: + for peer in peers: + card = await crud.get_peer_card( + db, + workspace_name=self.workspace_name, + observer=peer.name, + observed=peer.name, + ) + if card: + cards[peer.name] = card + except Exception: + logger.warning( + "Failed to prefetch workspace overview for workspace=%s", + self.workspace_name, + exc_info=True, + ) + return None + + return format_workspace_stats(stats, peers, cards) + + def _prefetch_heading(self) -> str: + return "Workspace overview (prefetched)" + + def _prefetch_intro(self) -> str: + return ( + "Workspace scale, the most active peers, and any known " + "biographical facts about them. Use this to decide who is " + "relevant, then search that peer's own representation with " + "search_memory (observer and observed both set to their name), " + "or search_messages / get_workspace_stats to find peers not " + "listed here." + ) + + def _select_tools(self) -> list[dict[str, Any]]: + tools = ( + WORKSPACE_TOOLS_MINIMAL + if self.reasoning_level == "minimal" + else WORKSPACE_DIALECTIC_TOOLS + ) + # Mirror the base agent's allowlist rule, for both tools that cannot + # honor an allowlist: reasoning chains traverse provenance across + # sessions, and a peer card is one cross-session aggregate with no + # per-session attribution. Both fail closed in their handlers too; + # dropping them here avoids paying the schema tokens and a wasted + # turn on a tool that can only refuse. + if self.session_allowlist is not None: + unscopable = {"get_reasoning_chain", "get_peer_card"} + tools = [t for t in tools if t.get("name") not in unscopable] + return tools + + async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]: + return await create_workspace_tool_executor( + workspace_name=self.workspace_name, + session_name=self.session_name, + session_allowlist=self.session_allowlist, + history_token_limit=settings.DIALECTIC.HISTORY_TOKEN_LIMIT, + run_id=self._run_id, + agent_type="workspace_dialectic", + parent_category="dialectic", + ) + + # Workspace chat shares the base "dialectic_chat" Langfuse trace name; + # scope is distinguished by the agent_type/track_name below. + + def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext: + return LLMTelemetryContext( + workspace_name=self.workspace_name, + call_purpose="dialectic.answer", + parent_category="dialectic", + agent_type="workspace_dialectic", + run_id=self._run_id, + trace_id=self._run_id, + span_id=self._run_id, + session_id=self.session_id, + peer_name="(workspace)", + track_name=track_name or "Workspace Dialectic Agent", + ) diff --git a/src/embedding_client.py b/src/embedding_client.py index 3cee4484..d3f4e369 100644 --- a/src/embedding_client.py +++ b/src/embedding_client.py @@ -159,6 +159,17 @@ def _publish_embedding_event( logger.debug("Failed to emit EmbeddingCallCompletedEvent", exc_info=True) +class EmbeddingTokenLimitError(ValueError): + """Raised when input text genuinely exceeds the model's token limit. + + Subclasses ``ValueError`` so existing broad handlers keep working, while + letting callers tell a real "content too long" condition apart from a + transient provider or configuration failure (dimension mismatch, empty + response, upstream error). Only the pre-flight token checks raise this; + provider failures keep raising plain ``ValueError``. + """ + + class BatchItem(NamedTuple): """A single item in a batch with its metadata.""" @@ -272,7 +283,7 @@ class _EmbeddingClient: token_count = len(self.encoding.encode(query)) if token_count > self.max_embedding_tokens: - raise ValueError( + raise EmbeddingTokenLimitError( f"Query exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {token_count} tokens)" ) @@ -321,39 +332,78 @@ class _EmbeddingClient: fn=_call_openai, ) - async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]: + def _truncate_to_token_limit(self, text: str) -> tuple[str, int]: + """Return a prefix of `text` whose re-encoded token count fits the cap. + + Decode/re-encode after slicing: BPE boundaries can re-expand past the cap. """ - Batch-embed a list of text strings. Each input must already fit within - `max_embedding_tokens`; this method does not sub-chunk oversized inputs. + token_ids = self.encoding.encode(text) + keep = self.max_embedding_tokens + while len(token_ids) > self.max_embedding_tokens: + keep = min(keep, len(token_ids) - 1) + if keep < 1: + return "", 0 + text = self.encoding.decode(token_ids[:keep]) + token_ids = self.encoding.encode(text) + keep -= 1 + return text, len(token_ids) + + async def simple_batch_embed( + self, + texts: list[str], + *, + on_oversize: Literal["raise", "truncate"] = "raise", + ) -> list[list[float]]: + """ + Batch-embed a list of text strings. Does not sub-chunk oversized inputs. Internally goes through the same token-aware batching pipeline as `batch_embed()` so the per-request token cap is respected. Args: texts: List of text strings to embed + on_oversize: ``"raise"`` (default) errors; ``"truncate"`` embeds a + token-capped prefix. Returns: List of embedding vectors, one per input text (in order) Raises: - ValueError: If any text exceeds token limits + EmbeddingTokenLimitError: If any text exceeds token limits and + `on_oversize` is ``"raise"`` """ if not texts: return [] - # Validate per-input token limit and collect token counts for batching + # Validate / cap per-input token limit and collect counts for batching + prepared_texts: list[str] = [] token_counts: list[int] = [] for idx, text in enumerate(texts): - tokens = len(self.encoding.encode(text)) - if tokens > self.max_embedding_tokens: - raise ValueError( - f"Text at index {idx} exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {tokens} tokens)" - ) + token_ids = self.encoding.encode(text) + if len(token_ids) > self.max_embedding_tokens: + if on_oversize == "truncate": + original_count = len(token_ids) + text, tokens = self._truncate_to_token_limit(text) + logger.warning( + "truncated oversize embedding input at idx %d: %d->%d tokens", + idx, + original_count, + tokens, + ) + else: + raise EmbeddingTokenLimitError( + f"Text at index {idx} exceeds maximum token limit of " + + f"{self.max_embedding_tokens} tokens (got {len(token_ids)} tokens)" + ) + else: + tokens = len(token_ids) + prepared_texts.append(text) token_counts.append(tokens) # Use positional indices as text_ids so we can reassemble in input order. text_chunks: dict[str, list[tuple[str, int]]] = { - str(i): [(text, token_counts[i])] for i, text in enumerate(texts) + str(i): [(prepared_texts[i], token_counts[i])] + for i in range(len(prepared_texts)) } batches = self._create_batches(text_chunks) @@ -695,9 +745,16 @@ class EmbeddingClient: """Embed a single query string.""" return await self._get_client().embed(query) - async def simple_batch_embed(self, texts: list[str]) -> list[list[float]]: + async def simple_batch_embed( + self, + texts: list[str], + *, + on_oversize: Literal["raise", "truncate"] = "raise", + ) -> list[list[float]]: """Batch embed a list of text strings (each must fit token limit).""" - return await self._get_client().simple_batch_embed(texts) + return await self._get_client().simple_batch_embed( + texts, on_oversize=on_oversize + ) def prepare_chunks(self, id_resource_dict: dict[str, str]) -> dict[str, list[str]]: """Chunk texts using the same rules as `batch_embed` (no network).""" diff --git a/src/exceptions.py b/src/exceptions.py index 129324b6..41d775fe 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -133,6 +133,14 @@ class VectorStoreError(HonchoException): detail = "Vector store operation failed" +@final +class RepresentationSaveError(HonchoException): + """Raised when every observer's representation save fails in a batch.""" + + status_code: int = 500 + detail: str = "Representation save failed for all observers" + + class LLMError(Exception): """Exception raised when an LLM call fails. diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index 672709ff..5b910ae9 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -441,10 +441,19 @@ class OpenAIBackend: ) cache_creation, cache_read = extract_openai_cache_tokens(usage) + # content_override=None means no override, not "force content to None" + if content_override is not None: + content: Any = content_override + elif message.content is not None: + content = message.content + elif tool_calls: + # Preserve null content on tool-call turns for history replay + content = None + else: + content = "" + return CompletionResult( - content=content_override - if content_override is not None - else (message.content or ""), + content=content, input_tokens=usage.prompt_tokens if usage else 0, output_tokens=usage.completion_tokens if usage else 0, cache_creation_input_tokens=cache_creation, diff --git a/src/llm/history_adapters.py b/src/llm/history_adapters.py index 02d2ea05..8e3b837e 100644 --- a/src/llm/history_adapters.py +++ b/src/llm/history_adapters.py @@ -121,6 +121,8 @@ class OpenAIHistoryAdapter: } if result.reasoning_details: message["reasoning_details"] = result.reasoning_details + elif result.thinking_content: + message["reasoning_content"] = result.thinking_content return message def format_tool_results( diff --git a/src/llm/tool_loop.py b/src/llm/tool_loop.py index 0feec1d8..783d4965 100644 --- a/src/llm/tool_loop.py +++ b/src/llm/tool_loop.py @@ -212,6 +212,7 @@ def format_assistant_tool_message( tool_calls: list[dict[str, Any]], thinking_blocks: list[dict[str, Any]] | None = None, reasoning_details: list[dict[str, Any]] | None = None, + thinking_content: str | None = None, ) -> dict[str, Any]: """Format an assistant message with tool calls in provider-native shape.""" from .backend import CompletionResult as BackendCompletionResult @@ -229,6 +230,7 @@ def format_assistant_tool_message( ) for tool_call in tool_calls ], + thinking_content=thinking_content, thinking_blocks=thinking_blocks or [], reasoning_details=reasoning_details or [], ) @@ -573,6 +575,7 @@ async def execute_tool_loop( response.tool_calls_made, response.thinking_blocks, response.reasoning_details, + response.thinking_content, ) conversation_messages.append(assistant_message) diff --git a/src/main.py b/src/main.py index 75eac455..a1ec9765 100644 --- a/src/main.py +++ b/src/main.py @@ -17,7 +17,12 @@ from sentry_sdk.integrations.starlette import StarletteIntegration from src._version import HONCHO_VERSION from src.cache.client import close_cache, init_cache from src.config import settings -from src.db import engine, register_db_query_instrumentation, request_context +from src.db import ( + engine, + register_db_connection_instrumentation, + register_db_query_instrumentation, + request_context, +) from src.exceptions import HonchoException from src.routers import ( conclusions, @@ -109,6 +114,13 @@ async def lifespan(_: FastAPI): # Expose DB connection-pool stats for this API instance (no-op if metrics off) register_db_pool_collector("api") register_db_query_instrumentation("api") + register_db_connection_instrumentation("api") + + # region ai + # Zero-init bounded-label counters so a missing series signals a broken scrape, + # not "no events" β€” see initialize_bounded_metrics. No-op if metrics off. + # endregion + prometheus_metrics.initialize_bounded_metrics(instance_type="api") # Validate embedding schema before serving any traffic. Fails closed: if # the configured EMBEDDING_VECTOR_DIMENSIONS does not match the physical diff --git a/src/reconciler/scheduler.py b/src/reconciler/scheduler.py index e171c4fa..08941970 100644 --- a/src/reconciler/scheduler.py +++ b/src/reconciler/scheduler.py @@ -21,6 +21,7 @@ from src import models from src.config import settings from src.dependencies import tracked_db from src.models import QueueItem +from src.reconciler.sync_vectors import record_pending_embeddings_backlog logger = logging.getLogger(__name__) @@ -145,15 +146,26 @@ class ReconcilerScheduler: async def _scheduler_loop(self) -> None: """ - Main scheduler loop that enqueues tasks based on their intervals. + Main scheduler loop that enqueues tasks based on their intervals, and + refreshes the service-wide pending-embeddings backlog gauge each pass. Each task has its own interval and the loop checks all tasks on each - iteration, enqueueing any that are due. + iteration, enqueueing any that are due. The loop sleeps until the next + task is due, so the gauge's refresh cadence tracks the SHORTEST task + interval. """ try: while not self._shutdown_event.is_set(): now = datetime.now(timezone.utc) + # region ai + # Refresh on EVERY replica, not just whichever wins the sync_vectors + # work unit: the count is DB-global, so a replica that never ran a + # cycle would otherwise export a stale (or zero-initialized) value + # forever. Full rationale in record_pending_embeddings_backlog. + # endregion + await record_pending_embeddings_backlog() + # Check each task and enqueue if due for task_name, task in RECONCILER_TASKS.items(): next_run = self._next_run.get(task_name, now) diff --git a/src/reconciler/sync_vectors.py b/src/reconciler/sync_vectors.py index 68e9ac59..1a8e99b5 100644 --- a/src/reconciler/sync_vectors.py +++ b/src/reconciler/sync_vectors.py @@ -23,6 +23,7 @@ from src.config import settings from src.dependencies import tracked_db from src.embedding_client import embedding_client from src.exceptions import VectorStoreError +from src.telemetry import prometheus_metrics from src.telemetry.events import EmbeddingCallPurpose from src.utils.types import embedding_call_purpose from src.vector_store import VectorRecord, VectorStore, get_external_vector_store @@ -301,7 +302,9 @@ async def _sync_documents( EmbeddingCallPurpose.VECTOR_SYNC.value, parent_category="reconciliation", ): - new_embeddings = await embedding_client.simple_batch_embed(contents) + new_embeddings = await embedding_client.simple_batch_embed( + contents, on_oversize="truncate" + ) if len(new_embeddings) != len(docs_needing_embed): logger.warning( @@ -700,6 +703,42 @@ async def _cleanup_pgvector_batch( return True +async def record_pending_embeddings_backlog() -> None: + """Set the pending-embeddings backlog gauge to the current count of + MessageEmbedding rows awaiting a vector (sync_state='pending').""" + # region ai + # Called from ``ReconcilerScheduler._scheduler_loop``, deliberately NOT from + # ``run_vector_reconciliation_cycle``: the cycle runs off the queue behind + # work-unit dedup, so exactly one deriver replica executes it. Driving the gauge + # from there would leave every other replica exporting a stale value β€” or, since + # this metric is zero-initialized, a confident permanent 0 it never measured. The + # count is a property of the database, not the process, so every replica must + # refresh it on its own timer for ``max()``/``avg()`` to mean anything. + # + # Cost: one COUNT per replica per scheduler interval (~5 min by default). + # ``ix_message_embeddings_sync_state_last_sync_at`` keeps the scan proportional to + # the pending backlog, not the whole table β€” which is not the same as cheap: after + # an embedding outage the backlog is exactly what is large. Still a small duty + # cycle, and the cost shrinks as the reconciler drains. + # + # Best-effort: a metrics/DB hiccup here must never break the scheduler loop. + # endregion + if not settings.METRICS.ENABLED: + return + try: + async with tracked_db("reconciler_pending_count", read_only=True) as db: + count = await db.scalar( + select(func.count()) + .select_from(models.MessageEmbedding) + .where(models.MessageEmbedding.sync_state == "pending") + ) + prometheus_metrics.set_message_embeddings_pending(count=count or 0) + except Exception: + logger.warning( + "Failed to record pending-embeddings backlog gauge", exc_info=True + ) + + async def run_vector_reconciliation_cycle() -> ReconciliationMetrics: """ Run a complete reconciliation cycle. diff --git a/src/routers/peers.py b/src/routers/peers.py index 7843081e..c5edfd4e 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -5,7 +5,6 @@ import logging from collections.abc import AsyncIterator from contextlib import suppress from time import perf_counter -from typing import Any from fastapi import APIRouter, Body, Depends, Path, Query, Response from fastapi.responses import StreamingResponse @@ -35,6 +34,7 @@ from src.utils.scopes import ( is_scope_peer, is_scope_peer_name, validate_no_scope_peer_names, + validate_scope_read_option, ) from src.utils.search import search from src.utils.types import embedding_call_purpose @@ -47,33 +47,6 @@ router = APIRouter( ) -def _validate_scope_option( - *, - filters: dict[str, Any] | None, - session_id: str | None, - jwt_params: JWTParams, -) -> None: - """Enforce the v1 `scope` exclusions and auth rule (chat/representation). - - `scope` is mutually exclusive with `filters` and `session_id` (422), and a - scope's member sessions may exceed a peer's own membership, so scoped - reads require a workspace- or admin-level key. - - 401 rather than 403: every other scope surface refuses a narrow key with 401 - β€” the `/scopes` router via `require_auth`, and the `scopes` field on session - create β€” so a peer key would otherwise get two different codes for the same - feature depending on which side of it was touched. - """ - if filters is not None: - raise ValidationException("`scope` and `filters` are mutually exclusive") - if session_id: - raise ValidationException("`scope` and `session_id` are mutually exclusive") - if jwt_params.p is not None: - raise AuthenticationException( - "`scope` requires a workspace- or admin-level key" - ) - - async def _resolve_scope_option( workspace_id: str, scope: str | list[str], @@ -95,16 +68,7 @@ async def _resolve_scope_option( ) return scope_peer, None - scope_peers = await crud.resolve_scope_peers(scope_db, workspace_id, scope) - union: list[str] = [] - seen: set[str] = set() - for scope_peer in scope_peers: - for session_name in await get_peer_session_names( - scope_db, workspace_id, scope_peer - ): - if session_name not in seen: - seen.add(session_name) - union.append(session_name) + union = await crud.resolve_scope_session_union(scope_db, workspace_id, scope) if len(union) > MAX_SESSION_ALLOWLIST_ENTRIES: raise ValidationException( @@ -316,7 +280,7 @@ async def chat( observer = peer_id scope_session_union: list[str] | None = None if options.scope is not None: - _validate_scope_option( + validate_scope_read_option( filters=options.filters, session_id=options.session_id, jwt_params=jwt_params, @@ -506,7 +470,7 @@ async def get_representation( observer = peer_id scope_session_union: list[str] | None = None if options.scope is not None: - _validate_scope_option( + validate_scope_read_option( filters=options.filters, session_id=options.session_id, jwt_params=jwt_params, diff --git a/src/routers/sessions.py b/src/routers/sessions.py index 10b769c4..f0d5b056 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import config, crud, schemas from src.cache.client import safe_cache_delete +from src.crud.message import get_peer_session_names from src.crud.session import session_cache_key from src.dependencies import db, read_db from src.deriver.enqueue import enqueue_deletion @@ -23,6 +24,7 @@ from src.exceptions import ( from src.security import JWTParams, require_auth from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit from src.utils import summarizer +from src.utils.filter import normalize_session_allowlist from src.utils.representation import Representation from src.utils.search import search from src.utils.tokens import estimate_tokens @@ -714,9 +716,27 @@ async def get_session_context( None, description="An (unprefixed) scope name to use as the perspective source: the representation and peer card of `peer_target` are read from the scope's observations instead of the global (or `peer_perspective`) view. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace- or admin-level key.", ), + sessions: list[str] | None = Query( + None, + description=( + "Optional allowlist of session IDs confining the representation of " + "`peer_target` to those sessions. This session must be one of them. " + "Recall is restricted to conclusions stated directly in the allowed " + "sessions β€” conclusions synthesized across sessions are excluded, " + "since their provenance cannot be proven to sit inside the allowlist " + "β€” and the peer card is omitted for the same reason. Mutually " + "exclusive with `scope` and `limit_to_session`. A peer-scoped key " + "must be an active member of every session named. The 1,000-session " + "cap shared with the recall endpoints applies but is not reachable " + "here: these are repeated query parameters, so a long list exceeds " + "the request-line limit of the server or any proxy in front of it " + "(a 414/431, not a 422) at a few hundred entries. Use a named " + "`scope` for large or reusable session sets." + ), + ), limit_to_session: bool = Query( default=False, - description="Only used if `search_query` is provided. Whether to limit the representation to the session (as opposed to everything known about the target peer)", + description="Whether to limit the representation to the session (as opposed to everything known about the target peer). Narrows recall the same way `sessions` does, so the same restrictions apply: explicit-only conclusions, and the peer card is omitted because it carries no per-session provenance.", ), search_top_k: int | None = Query( None, @@ -801,6 +821,47 @@ async def get_session_context( "`scope` requires a workspace- or admin-level key" ) + # The session allowlist confines the representation to a set of sessions this + # one belongs to. `scope` already determines what can be seen and + # `limit_to_session` already pins the set to this session alone, so both are + # contradictions rather than further narrowings β€” refused rather than given a + # silent precedence order. + session_allowlist: list[str] | None = None + if sessions is not None: + if scope is not None: + raise ValidationException("`sessions` and `scope` are mutually exclusive") + if limit_to_session: + raise ValidationException( + "`sessions` and `limit_to_session` are mutually exclusive" + ) + if not peer_target: + # The allowlist only reaches the representation, and there is no + # representation without a target. Refused rather than accepted and + # silently ignored, which would read as a scoped context. + raise ValidationException( + "peer_target must be provided if sessions is provided" + ) + # `must_include` keeps the allowlist from contradicting the route's own + # session: this session's messages and summary are always part of the + # response, so an allowlist excluding it would describe a context that + # cannot be assembled. + session_allowlist = normalize_session_allowlist( + sessions, field="sessions", must_include=session_id + ) + # A peer-scoped key may only name sessions its peer belongs to. Mirrors + # the chat route's gate (see routers/peers.py), including `active_only`, + # so both answer the same question for a peer that has left a session. + # Reuses the handler's session rather than opening its own: this is a + # DB-only read and the handler already holds a connection. + if jwt_params.p is not None: + member_sessions = set( + await get_peer_session_names( + db, workspace_id, jwt_params.p, active_only=True + ) + ) + if not set(session_allowlist) <= member_sessions: + raise AuthenticationException("JWT not permissioned for this resource") + if not peer_target: # No representation or card needed summary, messages = await _get_session_context_task( @@ -865,6 +926,17 @@ async def get_session_context( ): embedding = await embedding_client.embed(search_query) + # The allowlist recall must respect, whichever way the caller expressed it. + # `sessions` and `limit_to_session` are mutually exclusive (422 above), so at + # most one of these is set. `session_allowlist` is never an empty list here β€” + # `must_include=session_id` guarantees at least this session β€” so the + # None-check is the only distinction that matters. + effective_allowlist = ( + session_allowlist + if session_allowlist is not None + else ([session_id] if limit_to_session else None) + ) + # Sequential calls on shared DB session representation = await _get_working_representation_task( db, @@ -872,15 +944,34 @@ async def get_session_context( search_query, observer=observer, observed=observed, - session_allowlist=[session_id] if limit_to_session else None, + session_allowlist=effective_allowlist, search_top_k=search_top_k, search_max_distance=search_max_distance, include_most_derived=include_most_frequent, max_observations=max_conclusions, embedding=embedding, ) - card = await _get_peer_card_task( - db, workspace_id, observer=observer, observed=observed + # A peer card is keyed by (workspace, observer, observed) with no session + # dimension (crud/peer_card.py), so it is synthesized from everything the + # observer has ever seen and cannot be narrowed to an allowlist. Returning it + # would leak exactly what the allowlist exists to exclude, so it is dropped β€” + # the same fail-closed reasoning that limits allowlisted conclusion recall to + # ALLOWLIST_SAFE_LEVELS. + # + # Gated on the *effective* allowlist, not on `sessions` alone: + # `limit_to_session=true` narrows recall identically, so carving out only the + # newer parameter would leave a control that one parameter swap defeats. + # `scope` needs no carve-out at all β€” it swaps the observer to the scope peer + # above, so the card read below is the scope's own. + # + # POST /peers/{id}/chat still injects an unscoped card under an allowlist + # (src/dialectic/chat.py) β€” tracked in DEV-2201, not fixed here. + card = ( + None + if effective_allowlist is not None + else await _get_peer_card_task( + db, workspace_id, observer=observer, observed=observed + ) ) short_summary, long_summary = await _get_both_summaries_task( db, workspace_id, session_id diff --git a/src/routers/workspaces.py b/src/routers/workspaces.py index 42c111ad..5d449f9a 100644 --- a/src/routers/workspaces.py +++ b/src/routers/workspaces.py @@ -1,10 +1,14 @@ """FastAPI routes for workspace resources and workspace-scoped operations.""" +import json import logging +from collections.abc import AsyncIterator from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response +from fastapi.responses import StreamingResponse from fastapi_pagination import Page from fastapi_pagination.ext.sqlalchemy import apaginate +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas @@ -12,8 +16,13 @@ from src.config import settings from src.crud.message import get_peer_session_names from src.dependencies import db, read_db, tracked_db from src.deriver.enqueue import enqueue_deletion, enqueue_dream +from src.dialectic.chat import workspace_chat, workspace_chat_stream from src.exceptions import AuthenticationException, ValidationException from src.security import JWTParams, require_auth +from src.telemetry import prometheus_metrics +from src.utils.filter import MAX_SESSION_ALLOWLIST_ENTRIES +from src.utils.schema_conversion import json_response_schema_to_pydantic +from src.utils.scopes import validate_scope_read_option from src.utils.search import search logger = logging.getLogger(__name__) @@ -276,3 +285,92 @@ async def schedule_dream( observed, request.session_id, ) + + +@router.post( + "/{workspace_id}/chat", + responses={ + 200: { + "content": { + "application/json": { + "schema": schemas.DialecticResponse.model_json_schema() + }, + "text/event-stream": {}, + }, + }, + }, +) +async def chat( + workspace_id: str = Path(...), + options: schemas.WorkspaceChatOptions = Body(...), + jwt_params: JWTParams = Depends(require_auth(workspace_name="workspace_id")), +): + """Query the entire workspace using natural language. + + Pass `scope` to restrict recall to the union of those scopes' member + sessions. A scope with no member sessions recalls nothing (fail-closed). + """ + session_allowlist: list[str] | None = None + if options.scope is not None: + validate_scope_read_option( + filters=None, + session_id=options.session_id, + jwt_params=jwt_params, + ) + names = [options.scope] if isinstance(options.scope, str) else options.scope + async with tracked_db( + "workspaces.chat.resolve_scope", read_only=True + ) as scope_db: + session_allowlist = await crud.resolve_scope_session_union( + scope_db, workspace_id, names + ) + if len(session_allowlist) > MAX_SESSION_ALLOWLIST_ENTRIES: + raise ValidationException( + "The scopes' combined membership exceeds the maximum of " + + f"{MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request" + ) + + response_model: type[BaseModel] | None = None + if options.response_format is not None: + try: + response_model = json_response_schema_to_pydantic(options.response_format) + except ValueError as e: + raise ValidationException(f"Invalid response_format: {e}") from None + + if settings.METRICS.ENABLED: + prometheus_metrics.record_dialectic_call( + workspace_name=workspace_id, + reasoning_level=options.reasoning_level, + ) + + if options.stream: + + async def format_sse_stream(chunks: AsyncIterator[str]) -> AsyncIterator[str]: + """Format chunks as SSE events.""" + async for chunk in chunks: + yield f"data: {json.dumps({'delta': {'content': chunk}, 'done': False})}\n\n" + yield f"data: {json.dumps({'done': True})}\n\n" + + return StreamingResponse( + format_sse_stream( + workspace_chat_stream( + workspace_name=workspace_id, + session_name=options.session_id, + query=options.query, + reasoning_level=options.reasoning_level, + response_model=response_model, + session_allowlist=session_allowlist, + ) + ), + media_type="text/event-stream", + ) + + response = await workspace_chat( + workspace_name=workspace_id, + session_name=options.session_id, + query=options.query, + reasoning_level=options.reasoning_level, + response_model=response_model, + session_allowlist=session_allowlist, + ) + return schemas.DialecticResponse(content=response if response else None) diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index b4211b99..9f414583 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -54,6 +54,7 @@ from src.schemas.api import ( WebhookEndpointCreate, Workspace, WorkspaceBase, + WorkspaceChatOptions, WorkspaceCreate, WorkspaceGet, WorkspaceMessageSearchOptions, @@ -114,6 +115,7 @@ __all__ = [ "ConclusionQuery", "DialecticOptions", "DialecticResponse", + "WorkspaceChatOptions", "DialecticStreamChunk", "DialecticStreamDelta", "Message", diff --git a/src/schemas/api.py b/src/schemas/api.py index 207acf96..427c9a82 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -31,6 +31,7 @@ from src.schemas.configuration import ( SessionPeerConfig, WorkspaceConfiguration, ) +from src.utils.sanitization import NulStripped, strip_nul from src.utils.scopes import ( SCOPE_PEER_PREFIX, is_scope_peer_name, @@ -48,19 +49,6 @@ _METADATA_MAX_KEYS = 100 _METADATA_MAX_DEPTH = 5 -def _sanitize_value(v: Any) -> Any: - """Recursively strip NUL bytes from strings in nested data structures.""" - if isinstance(v, str): - return v.replace("\x00", "") - if isinstance(v, dict): - d = cast(dict[str, Any], v) - return {_sanitize_value(k): _sanitize_value(val) for k, val in d.items()} - if isinstance(v, list): - lst = cast(list[Any], v) - return [_sanitize_value(item) for item in lst] - return v - - def _check_metadata_limits( data: dict[str, Any], *, @@ -88,7 +76,7 @@ def _validate_metadata(v: Any) -> Any: return v data = cast(dict[str, Any], v) _check_metadata_limits(data) - return _sanitize_value(data) + return strip_nul(data) _SanitizedMetadata = Annotated[dict[str, Any], BeforeValidator(_validate_metadata)] @@ -322,7 +310,7 @@ class PeerCardSet(BaseModel): def sanitize_peer_card(cls, v: Any) -> Any: if isinstance(v, list): return [ - item.replace("\x00", "") if isinstance(item, str) else item + strip_nul(item) if isinstance(item, str) else item for item in cast(list[Any], v) ] return v @@ -349,7 +337,7 @@ class MessageCreate(MessageBase): @field_validator("content", mode="after") @classmethod def sanitize_content(cls, v: str) -> str: - return v.replace("\x00", "") + return strip_nul(v) @property def encoded_message(self) -> list[int]: @@ -694,7 +682,7 @@ class ConclusionCreate(BaseModel): @field_validator("content", mode="after") @classmethod def sanitize_content(cls, v: str) -> str: - return v.replace("\x00", "") + return strip_nul(v) @model_validator(mode="after") def validate_token_count(self) -> Self: @@ -729,7 +717,7 @@ class ConclusionBatchCreate(BaseModel): class MessageSearchOptions(BaseModel): - query: Annotated[str, Field(..., description="Search query")] + query: Annotated[str, Field(..., description="Search query"), NulStripped] filters: dict[str, Any] | None = Field( default=None, description="Filters to scope the search" ) @@ -740,11 +728,6 @@ class MessageSearchOptions(BaseModel): description="Number of results to return", ) - @field_validator("query", mode="after") - @classmethod - def sanitize_query(cls, v: str) -> str: - return v.replace("\x00", "") - class WorkspaceMessageSearchOptions(MessageSearchOptions): """Workspace-level message search options, extended with `scope`.""" @@ -797,7 +780,9 @@ class DialecticOptions(BaseModel): description="Optional peer to get the representation for, from the perspective of this peer", ) query: Annotated[ - str, Field(min_length=1, max_length=10000, description="Dialectic API Prompt") + str, + Field(min_length=1, max_length=10000, description="Dialectic API Prompt"), + NulStripped, ] stream: bool = False reasoning_level: ReasoningLevel = Field( @@ -815,10 +800,39 @@ class DialecticOptions(BaseModel): ), ) - @field_validator("query", mode="after") - @classmethod - def sanitize_query(cls, v: str) -> str: - return v.replace("\x00", "") + +class WorkspaceChatOptions(BaseModel): + """Options for workspace-level chat (no anchor peer; see DialecticOptions).""" + + session_id: str | None = Field( + None, description="Optional session to scope message tools to" + ) + query: Annotated[ + str, + Field(min_length=1, max_length=10000, description="Workspace chat prompt"), + NulStripped, + ] + stream: bool = False + reasoning_level: ReasoningLevel = Field( + default="low", + description="Level of reasoning to apply: minimal, low, medium, high, or max", + ) + response_format: dict[str, Any] | None = Field( + None, + description=( + "Optional JSON Schema (root type 'object') the response must conform" + " to. When provided, `content` is a JSON string matching this schema." + ), + ) + scope: _ScopeOption | None = Field( + None, + description=( + "Optional (unprefixed) scope name(s) restricting recall to the " + "union of the scopes' member sessions (explicit allowlist, " + "fail-closed: an empty union recalls nothing). Mutually exclusive " + "with `session_id`. Requires a workspace- or admin-level key." + ), + ) class DialecticResponse(BaseModel): diff --git a/src/schemas/internal.py b/src/schemas/internal.py index e014431f..2d299feb 100644 --- a/src/schemas/internal.py +++ b/src/schemas/internal.py @@ -6,10 +6,11 @@ These are not part of the public API contract and may change without notice. from enum import Enum from typing import Annotated, Literal, Self -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, model_validator from src.schemas.api import MessageCreate from src.schemas.configuration import SessionPeerConfig +from src.utils.sanitization import NulStripped from src.utils.types import DocumentLevel @@ -59,7 +60,7 @@ class DocumentMetadata(BaseModel): class DocumentCreate(DocumentBase): - content: Annotated[str, Field(min_length=1, max_length=100000)] + content: Annotated[str, Field(min_length=1, max_length=100000), NulStripped] session_name: str | None = Field( default=None, description="The session from which the document was derived (NULL for global observations)", @@ -85,7 +86,7 @@ class DocumentCreate(DocumentBase): class ObservationInput(BaseModel): """Validated observation input from LLM tool calls.""" - content: Annotated[str, Field(min_length=1)] + content: Annotated[str, Field(min_length=1), NulStripped] level: DocumentLevel = "explicit" source_ids: list[str] | None = None premises: list[str] | None = None @@ -96,11 +97,6 @@ class ObservationInput(BaseModel): ) = None confidence: Literal["high", "medium", "low"] | None = None - @field_validator("content", mode="after") - @classmethod - def sanitize_content(cls, v: str) -> str: - return v.replace("\x00", "") - @model_validator(mode="after") def validate_level_fields(self) -> Self: """Validate that level-specific fields are present when required.""" diff --git a/src/telemetry/emitter.py b/src/telemetry/emitter.py index 201d16aa..397ecd90 100644 --- a/src/telemetry/emitter.py +++ b/src/telemetry/emitter.py @@ -172,6 +172,21 @@ class TelemetryEmitter: ) self._running = True self._flush_task = asyncio.create_task(self._periodic_flush()) + + # region ai + # Pre-create the dropped-event counter children at 0: a labeled counter + # exports nothing until its first observation, so this makes the metric + # visible before any drop and lets us tell "no drops" from "metric missing". + # endregion + from src.telemetry.prometheus.metrics import prometheus_metrics + + prometheus_metrics.initialize_telemetry_dropped_metrics( + reasons=[ + f"{self.drop_reason_prefix}buffer_full", + f"{self.drop_reason_prefix}send_failed", + ] + ) + logger.info("Telemetry emitter started, endpoint: %s", self.endpoint) async def shutdown(self) -> None: diff --git a/src/telemetry/events/__init__.py b/src/telemetry/events/__init__.py index 000e70b4..f8950d0f 100644 --- a/src/telemetry/events/__init__.py +++ b/src/telemetry/events/__init__.py @@ -138,9 +138,74 @@ __all__ = [ # Lifecycle "initialize_telemetry_events", "shutdown_telemetry_events", + # Zero-init registry + "ALL_EVENT_TYPES", + "HIGH_VOLUME_EVENT_TYPES", ] +# Explicit registry of CloudEvents `type` values, used to zero-initialize the +# telemetry_events_emitted / telemetry_events_sampled_out counter children. +# region ai +# See metrics.py:initialize_bounded_metrics for why absent and zero are worth +# distinguishing. Explicit literal, not a set derived from BaseEvent subclasses: a +# derived set would follow whatever happens to be imported at init time, so a type +# could drop out of the registry with no code change. A hand-maintained list plus a +# drift-guard test fails loud at the right moment instead. +# +# When you add a BaseEvent subclass, add its `_event_type` here (and to +# HIGH_VOLUME_EVENT_TYPES if `_volume_class == "high_volume"`). The drift-guard test +# tests/telemetry/test_metric_zero_init.py fails until you do β€” it asserts this +# registry equals the set discovered by walking BaseEvent subclasses. +# endregion +ALL_EVENT_TYPES: tuple[str, ...] = ( + # api + "message.created", + "file.uploaded", + "context.retrieved", + # agent + "agent.iteration", + "agent.tool.conclusions.created", + "agent.tool.conclusions.deleted", + "agent.tool.peer_card.updated", + "agent.tool.summary.created", + "agent.tool.call.completed", + # deletion / dialectic / dream / representation + "deletion.completed", + "dialectic.completed", + "dream.run", + "dream.specialist", + "representation.completed", + # llm / embedding + "llm.call.completed", + "embedding.call.completed", + # reconciliation + "reconciliation.sync_vectors.completed", + "reconciliation.cleanup_stale_items.completed", + # trace stream + # region ai + # Only emitted when TELEMETRY.TRACE_PAYLOADS_ENABLED, but they flow through the + # same emit() path and increment the same counters, so they belong in the set. + # endregion + "llm.call.traced", + "embedding.call.traced", + "trace.content", +) + +# Subset of ALL_EVENT_TYPES whose `_volume_class == "high_volume"`. +# region ai +# Only these can ever be counted by ``telemetry_events_sampled_out``; ground_truth +# events skip the sampler entirely (pre-creating their sampled_out series would be a +# permanently-misleading 0). +# endregion +HIGH_VOLUME_EVENT_TYPES: tuple[str, ...] = ( + "agent.iteration", + "agent.tool.call.completed", + "llm.call.completed", + "embedding.call.completed", +) + + def emit(event: BaseEvent) -> None: """Queue an event for emission to the telemetry backend. diff --git a/src/telemetry/events/representation.py b/src/telemetry/events/representation.py index ad6dfca4..c4291092 100644 --- a/src/telemetry/events/representation.py +++ b/src/telemetry/events/representation.py @@ -154,6 +154,10 @@ class RepresentationCompletedEvent(BaseEvent): default=0, description="Number of observers this representation was saved against", ) + failed_observer_count: int = Field( + default=0, + description="Number of observers whose save_representation failed (partial or total)", + ) def get_resource_id(self) -> str: """Resource ID includes workspace, session, and latest message for uniqueness.""" diff --git a/src/telemetry/prometheus/metrics.py b/src/telemetry/prometheus/metrics.py index 01d7be4f..cead893a 100644 --- a/src/telemetry/prometheus/metrics.py +++ b/src/telemetry/prometheus/metrics.py @@ -20,7 +20,8 @@ from prometheus_client.core import GaugeMetricFamily from starlette.requests import Request from starlette.responses import Response -from src.config import settings +from src.config import REASONING_LEVELS, settings +from src.utils.types import walk_subclasses disable_created_metrics() @@ -66,6 +67,32 @@ class DialecticComponents(Enum): TOTAL = "total" +# Valid (token_type, component) pairs for deriver_tokens_processed, per task_type, +# used to zero-initialize counter children (see initialize_bounded_metrics). +# region ai +# NOT the cartesian product: input tokens only pair with input components, output +# only with OUTPUT_TOTAL, and PREVIOUS_SUMMARY occurs only for summary tasks +# (ingestion has no previous summary). Enumerating anything broader would fabricate +# impossible always-0 series (e.g. output/prompt, or ingestion/previous_summary). +# Explicit literal, drift-guarded by tests/telemetry/test_metric_zero_init.py. +# Sources: track_deriver_input_tokens (src/utils/tokens.py) + the OUTPUT_TOTAL sites +# in src/deriver/deriver.py and src/utils/summarizer.py. +# endregion +_DERIVER_TOKEN_COMBOS_BY_TASK: dict[str, tuple[tuple[str, str], ...]] = { + DeriverTaskTypes.INGESTION.value: ( + (TokenTypes.INPUT.value, DeriverComponents.PROMPT.value), + (TokenTypes.INPUT.value, DeriverComponents.MESSAGES.value), + (TokenTypes.OUTPUT.value, DeriverComponents.OUTPUT_TOTAL.value), + ), + DeriverTaskTypes.SUMMARY.value: ( + (TokenTypes.INPUT.value, DeriverComponents.PROMPT.value), + (TokenTypes.INPUT.value, DeriverComponents.MESSAGES.value), + (TokenTypes.INPUT.value, DeriverComponents.PREVIOUS_SUMMARY.value), + (TokenTypes.OUTPUT.value, DeriverComponents.OUTPUT_TOTAL.value), + ), +} + + api_requests_counter = NamespacedCounter( "api_requests", "Total API requests", @@ -155,6 +182,23 @@ telemetry_buffer_size_gauge = NamespacedGauge( ["namespace"], ) +# Embedding backlog: MessageEmbedding rows still awaiting a vector +# (sync_state='pending'). +# region ai +# Distinct from embed_now_tasks_in_flight (in-flight fast-path work in the API +# process) β€” this is the durable, DB-wide backlog the reconciler drains. Every +# deriver replica refreshes it on its own timer from +# ReconcilerScheduler._scheduler_loop, so replicas disagree by at most one interval. +# Service-wide, not per-process β€” hence the help string's "never sum()". +# endregion +message_embeddings_pending_gauge = NamespacedGauge( + "message_embeddings_pending", + "MessageEmbedding rows awaiting embedding (sync_state='pending'). " + + "Service-wide DB count, reported independently by every replica β€” " + + "aggregate with max() or avg(), never sum()", + ["namespace"], +) + # DB connection-pool health. The in-flight gauge counts statements actually # executing on the wire, so checked_out minus in_flight reveals connections held # but parked (the "idle in transaction during an external call" antipattern). @@ -164,6 +208,27 @@ db_queries_in_flight_gauge = NamespacedGauge( ["namespace", "instance_type"], ) +# Physical DB connections, tracked via SQLAlchemy connection-lifecycle events +# (see DBConnectionTracker in src/db.py) rather than the pool object, so they are +# visible under EVERY pool class β€” including NullPool, whose pool holds no records +# for the scrape-time db_pool_connections collector to read. Under QueuePool this +# roughly equals db_pool_connections{checked_in} + {checked_out}; its unique value +# is under NullPool, where that collector reads zero. +db_connections_open_gauge = NamespacedGauge( + "db_connections_open", + "Physical DB connections currently open by this instance, across all pool " + + "classes (tracks concurrency of DB work under NullPool, pool occupancy " + + "under QueuePool)", + ["namespace", "instance_type"], +) + +db_connections_established_counter = NamespacedCounter( + "db_connections_established", + "Physical DB connections established since process start. Under NullPool, " + + "rate() approximates request rate (one connect per DB checkout)", + ["namespace", "instance_type"], +) + @final class PrometheusMetrics: @@ -325,12 +390,164 @@ class PrometheusMetrics: except Exception as e: self._handle_metric_error("record_telemetry_event_dropped", e) + def _touch(self, counter: NamespacedCounter, **labels: str) -> None: + """Pre-create a counter child series at 0 without incrementing it.""" + # region ai + # A labeled Prometheus counter exports no time series until its first + # ``labels(...)`` call, so pre-touching a child keeps it present at 0 β€” a + # missing series then signals a broken scrape rather than "no events". + # Fail-soft (like the recorders): a bad init must never crash startup. + # endregion + try: + counter.labels(**labels) + except Exception as e: + self._handle_metric_error("_touch", e) + + def initialize_telemetry_dropped_metrics(self, *, reasons: list[str]) -> None: + """Pre-create telemetry_events_dropped ``(namespace, reason)`` children at 0. + + Args: + reasons: The reason label values the calling emitter can produce. + """ + # region ai + # The metric stays invisible in Prometheus/Grafana until an event is actually + # dropped, so materializing the children at startup keeps it present at 0 β€” a + # missing series then means a broken scrape, not "no drops" (see _touch). + # + # Called per-emitter from ``TelemetryEmitter.start()`` rather than hoisted into + # the process-level ``initialize_bounded_metrics``: the trace emitter (whose + # reasons carry a ``trace_`` prefix) only exists when ``TRACE_PAYLOADS_ENABLED`` + # is set, so hoisting would fabricate ``trace_*`` series on deployments that run + # with tracing off. + # endregion + if not settings.METRICS.ENABLED: + return + + for reason in reasons: + self._touch(telemetry_events_dropped_counter, reason=reason) + + def initialize_bounded_metrics(self, *, instance_type: str) -> None: + """Pre-create bounded-label counter children at 0 for this process, so an + absent series means a broken scrape rather than "nothing happened". + + Note: the DB-instrumentation metrics (db_queries_in_flight, + db_connections_open, db_connections_established) are NOT initialized here β€” + they zero-init via the pre-resolved labeled children in their register_db_* + functions in src/db.py, so an auditor should not read them as forgotten. + + Args: + instance_type: "api" or "deriver" β€” selects the process-specific + counters. Event-type and buffer metrics are initialized in both. + """ + # region ai + # A Prometheus counter does not exist until its first increment, so a + # never-yet-incremented metric is indistinguishable from a broken scrape: + # you cannot graph or alert on a series that is absent. Materializing the + # children at 0 inverts that β€” a missing series now means something is wrong, + # and "no events" reads as a flat 0 instead of a gap. + # + # That only holds for label sets we can enumerate honestly, so a metric is + # initialized here only when its full label domain is bounded, enumerable at + # startup, and actually emitted by THIS process. High-cardinality labels + # (endpoint, workspace_name) and impossible label tuples are deliberately left + # absent β€” fabricating a permanently-0 series that no code path can ever + # increment is the same lie in the other direction. + # + # Multi-instance safety splits the metrics here into three buckets: + # + # 1. instance-scoped (``telemetry_buffer_size``, ``embed_now_tasks_in_flight``) + # β€” per-process by nature, so any aggregation is meaningful and zero-init is + # unambiguously right. + # 2. service-scoped additive (the token counters, ``telemetry_events_emitted``) + # β€” each instance holds a partial count and ``sum()`` reconstructs the whole, + # so multi-instance safe. + # 3. service-scoped non-additive β€” every instance reports the whole service's + # value, so the instances are N witnesses to one fact rather than N parts of + # one whole. ``sum()`` is therefore never correct here: it scales with the + # replica count. Scale-preserving aggregations (``max()``, ``avg()``, + # quantiles) ARE correct, but only while the witnesses disagree by a bounded + # amount β€” which requires every instance to refresh on its own timer (see + # ``message_embeddings_pending``, refreshed per replica from + # ``ReconcilerScheduler._scheduler_loop``). A bucket-3 metric that cannot + # meet that bar does not belong in the app at all β€” it belongs in an exporter + # that yields exactly one series. + # + # Prometheus stamps ``instance``/``job`` at scrape time, which is why buckets 1 + # and 2 need no special handling. ``telemetry_events_dropped`` is handled + # separately, per-emitter, in ``TelemetryEmitter.start()`` (prefix-dependent). + # endregion + if not settings.METRICS.ENABLED: + return + + # ai: lazy import avoids an import-time cycle (metrics is imported widely) + from src.telemetry.events import ALL_EVENT_TYPES, HIGH_VOLUME_EVENT_TYPES + + # region ai + # Common: both processes run a TelemetryEmitter, so both emit their own subset + # of event types. The domain is bounded/low-cardinality (~21 types), so init + # the full set in each process rather than maintain a fragile + # per-event-type -> process map. + # endregion + for event_type in ALL_EVENT_TYPES: + self._touch(telemetry_events_emitted_counter, type=event_type) + for event_type in HIGH_VOLUME_EVENT_TYPES: + self._touch(telemetry_events_sampled_out_counter, type=event_type) + self.set_telemetry_buffer_size(size=0) + + if instance_type == "api": + # dialectic tokens: token_type x component(total) x reasoning_level + for token_type in TokenTypes: + for level in REASONING_LEVELS: + self._touch( + dialectic_tokens_processed_counter, + token_type=token_type.value, + component=DialecticComponents.TOTAL.value, + reasoning_level=level, + ) + # ai: embed_now fast path runs as an API-process background task + self._touch(embed_now_tasks_shed_counter) + self.set_embed_now_tasks_in_flight(0) + + elif instance_type == "deriver": + # deriver tokens: only the valid (token_type, component) tuples per + # task_type (see _DERIVER_TOKEN_COMBOS_BY_TASK). + for task_type_value, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items(): + for token_type_value, component_value in combos: + self._touch( + deriver_tokens_processed_counter, + task_type=task_type_value, + token_type=token_type_value, + component=component_value, + ) + # dreamer tokens: specialist_name x token_type. + # region ai + # Names come from the concrete BaseSpecialist subclasses (walked recursively + # via walk_subclasses) so a new specialist can't silently miss init. + # endregion + from src.dreamer.specialists import BaseSpecialist + + for specialist in walk_subclasses(BaseSpecialist): + for token_type in TokenTypes: + self._touch( + dreamer_tokens_processed_counter, + specialist_name=specialist.name, + token_type=token_type.value, + ) + # ai: init at 0 so the gauge is visible before its first per-replica refresh + self.set_message_embeddings_pending(count=0) + def set_telemetry_buffer_size(self, *, size: int) -> None: try: telemetry_buffer_size_gauge.labels().set(size) except Exception as e: self._handle_metric_error("set_telemetry_buffer_size", e) + def set_message_embeddings_pending(self, *, count: int) -> None: + try: + message_embeddings_pending_gauge.labels().set(count) + except Exception as e: + self._handle_metric_error("set_message_embeddings_pending", e) + prometheus_metrics = PrometheusMetrics() diff --git a/src/utils/agent_tools.py b/src/utils/agent_tools.py index 175cc6f2..c4265a19 100644 --- a/src/utils/agent_tools.py +++ b/src/utils/agent_tools.py @@ -2,7 +2,7 @@ import asyncio import logging import weakref from collections.abc import Callable, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime from typing import Any, cast @@ -13,7 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas from src.config import settings from src.dependencies import tracked_db -from src.embedding_client import embedding_client +from src.embedding_client import EmbeddingTokenLimitError, embedding_client +from src.exceptions import ResourceNotFoundException from src.models import Document from src.schemas import ResolvedConfiguration from src.telemetry.events import ( @@ -35,6 +36,7 @@ from src.utils.representation import ( Representation, allowlist_safe_levels, ) +from src.utils.sanitization import strip_nul from src.utils.types import ToolResult, embedding_call_purpose, get_current_iteration logger = logging.getLogger(__name__) @@ -76,8 +78,20 @@ def _validate_peer_card_entry(line: str) -> bool: def _normalized_observation_input( obs: schemas.ObservationInput, ) -> schemas.ObservationInput: - """Return an observation input with content normalized for persistence/embedding.""" - return obs.model_copy(update={"content": obs.content.strip()}) + """Return an observation input with content normalized for persistence/embedding. + + NUL bytes are removed here rather than closer to the database so that the + text that gets embedded is the same text that gets stored. `premises` and + `sources` ride along in internal_metadata, and jsonb rejects NUL in strings + just as text columns do. + """ + return obs.model_copy( + update={ + "content": strip_nul(obs.content).strip(), + "premises": strip_nul(obs.premises), + "sources": strip_nul(obs.sources), + } + ) def _base_observation_properties() -> dict[str, Any]: @@ -291,6 +305,11 @@ def _safe_int(value: Any, default: int) -> int: return default +def _bounded_int(value: Any, default: int, *, lo: int = 1, hi: int) -> int: + """Coerce a tool int into ``[lo, hi]``, falling back to ``default`` on bad input.""" + return max(lo, min(_safe_int(value, default), hi)) + + # Module-level lock registry for thread-safe observation creation. # Keyed by (workspace_name, observer, observed) to ensure all tool executors # operating on the same data share the same lock. @@ -785,6 +804,59 @@ TOOLS: dict[str, dict[str, Any]] = { "required": ["observation_id"], }, }, + "search_memory_workspace": { + "name": "search_memory", + "description": "Search within a specific peer representation's memory using semantic similarity. You MUST specify observer and observed. To get a peer's global representation, set observer AND observed to the SAME peer name (this is where most information lives). Only use different observer/observed when seeking one peer's specific understanding of another.", + "input_schema": { + "type": "object", + "properties": { + "observer": { + "type": "string", + "description": "Name of the observer peer", + }, + "observed": { + "type": "string", + "description": "Name of the observed peer", + }, + "query": { + "type": "string", + "description": "Search query text", + }, + "top_k": { + "type": "integer", + "description": "(Optional) number of results to return (default: 20, max: 40)", + "default": 20, + }, + }, + "required": ["observer", "observed", "query"], + }, + }, + "get_workspace_stats": { + "name": "get_workspace_stats", + "description": "Get workspace-level statistics β€” peer count, session count, message count, date range of messages β€” plus the most recently active peers with their message counts and last-active timestamps. Use this to orient yourself and discover which peers are most relevant.", + "input_schema": { + "type": "object", + "properties": {}, + }, + }, + "get_peer_card_by_name": { + "name": "get_peer_card", + "description": "Get the peer card for a specific peer relationship. Specify the observer and observed peer names.", + "input_schema": { + "type": "object", + "properties": { + "observer": { + "type": "string", + "description": "Name of the observer peer", + }, + "observed": { + "type": "string", + "description": "Name of the observed peer", + }, + }, + "required": ["observer", "observed"], + }, + }, } # Tools for the dialectic agent (analysis) @@ -806,6 +878,31 @@ DIALECTIC_TOOLS_MINIMAL: list[dict[str, Any]] = [ TOOLS["search_messages"], ] +# Tools for the workspace-level dialectic agent. Observation search stays +# pair-scoped (observer/observed are TOOL ARGUMENTS the agent must supply +# after routing) -- matching both the (observer, observed) collection +# ownership and the per-pair vector-store namespaces. Message tools are +# workspace-flat and double as the routing signal (results carry peer_name). +WORKSPACE_DIALECTIC_TOOLS: list[dict[str, Any]] = [ + TOOLS["get_workspace_stats"], + TOOLS["search_memory_workspace"], + TOOLS["search_messages"], + TOOLS["get_observation_context"], + TOOLS["grep_messages"], + TOOLS["get_peer_card_by_name"], + TOOLS["get_messages_by_date_range"], + TOOLS["search_messages_temporal"], + TOOLS["get_reasoning_chain"], +] + +# Reduced workspace loadout for reasoning_level="minimal" (token cost of the +# tool definitions themselves), mirroring DIALECTIC_TOOLS_MINIMAL. +WORKSPACE_TOOLS_MINIMAL: list[dict[str, Any]] = [ + TOOLS["get_workspace_stats"], + TOOLS["search_memory_workspace"], + TOOLS["search_messages"], +] + # Tools for the dreamer agent (consolidation + peer card + deduplication) DREAMER_TOOLS: list[dict[str, Any]] = [ # Preference extraction (should be called first) @@ -902,10 +999,12 @@ async def create_observations( logger.warning("create_observations called with empty list") return ObservationsCreatedResult(created_count=0, created_levels=[], failed=[]) + # Normalize before the emptiness check: str.strip() does not remove NUL, + # so content that normalizes away has to be dropped afterwards. normalized_observations = [ - _normalized_observation_input(obs) - for obs in observations - if obs.content.strip() + normalized + for normalized in (_normalized_observation_input(obs) for obs in observations) + if normalized.content ] if not normalized_observations: logger.info("No non-empty observations to create") @@ -930,7 +1029,9 @@ async def create_observations( run_id=run_id, parent_category=parent_category, ): - embeddings = await embedding_client.simple_batch_embed(contents) + embeddings = await embedding_client.simple_batch_embed( + contents, on_oversize="truncate" + ) embeddings_by_index = dict( zip(range(len(normalized_observations)), embeddings, strict=True) ) @@ -1009,7 +1110,7 @@ async def create_observations( workspace_name=workspace_name, observer=observer, observed=observed, - deduplicate=True, + deduplicate=settings.DERIVER.DEDUPLICATE, ) ).created_documents logger.info( @@ -1191,10 +1292,19 @@ async def get_observation_context( if not message_ids: return [] - from src.crud.message import resolve_session_scope + from src.crud.message import resolve_session_scope_clauses - allowed_session_names, deny = await resolve_session_scope( - db, workspace_name, session_name, session_allowlist, observer + # Scope as SQL rather than as a fetched name list. The scope is applied to + # both the CTE and the outer select, so a materialized list would spend two + # bind parameters per session the observer belongs to β€” enough sessions and + # the statement exceeds the driver's 65535-parameter ceiling and cannot be + # sent at all. + scope_clauses, deny = resolve_session_scope_clauses( + workspace_name, + session_name, + session_allowlist, + observer, + models.Message.session_name, ) if deny: return [] @@ -1208,8 +1318,8 @@ async def get_observation_context( if session_name: stmt = stmt.where(models.Message.session_name == session_name) - elif allowed_session_names is not None: - stmt = stmt.where(models.Message.session_name.in_(allowed_session_names)) + for clause in scope_clauses: + stmt = stmt.where(clause) target_seqs_cte = stmt.cte("target_seqs") @@ -1232,8 +1342,8 @@ async def get_observation_context( if session_name: stmt = stmt.where(models.Message.session_name == session_name) - elif allowed_session_names is not None: - stmt = stmt.where(models.Message.session_name.in_(allowed_session_names)) + for clause in scope_clauses: + stmt = stmt.where(clause) result = await db.execute(stmt) messages = list(result.scalars().all()) @@ -1792,7 +1902,7 @@ async def _handle_search_memory( """Handle search_memory tool.""" from src.utils.types import ToolResult - top_k = min(_safe_int(tool_input.get("top_k"), 20), 40) + top_k = _bounded_int(tool_input.get("top_k"), 20, hi=40) query = tool_input["query"] try: with embedding_call_purpose( @@ -1802,11 +1912,15 @@ async def _handle_search_memory( parent_category=ctx.parent_category, ): query_embedding = await embedding_client.embed(query) - except ValueError: + except EmbeddingTokenLimitError: return ( "ERROR: Query exceeds maximum token limit of " + f"{settings.EMBEDDING.MAX_INPUT_TOKENS}. Please use a shorter query." ) + except ValueError as e: + # Provider/config failure, not an oversized query. Keep returning a + # string so the tool loop can continue, but don't blame the query. + return f"ERROR: Embedding the query failed: {e}" # Base telemetry metadata; results_count gets filled in below. search_meta: dict[str, Any] = { @@ -1848,8 +1962,8 @@ async def _handle_search_memory( # here, we automatically search the message history for relevant # information. zero_hit_meta = {**search_meta, "results_count": 0} - if ctx.agent_type == "dialectic": - limit = min(_safe_int(tool_input.get("top_k"), 20), 20) + if ctx.agent_type in ("dialectic", "workspace_dialectic"): + limit = _bounded_int(tool_input.get("top_k"), 20, hi=20) message_output = None snippets = await crud.search_messages( workspace_name=ctx.workspace_name, @@ -1900,7 +2014,7 @@ async def _handle_get_observation_context( workspace_name=ctx.workspace_name, session_name=ctx.session_name, message_ids=tool_input["message_ids"], - observer=ctx.observer, + observer=ctx.observer or None, session_allowlist=ctx.session_allowlist, ) if not messages: @@ -1926,7 +2040,7 @@ async def _handle_search_messages( from src.utils.types import ToolResult query = tool_input["query"] - limit = min(_safe_int(tool_input.get("limit"), 10), 20) # Cap at 20 + limit = _bounded_int(tool_input.get("limit"), 10, hi=20) # Pre-compute embedding outside DB session to avoid holding a connection # during the external API call (same pattern as _handle_search_memory). with embedding_call_purpose( @@ -1943,7 +2057,7 @@ async def _handle_search_messages( limit=limit, context_window=2, embedding=query_embedding, - observer=ctx.observer, + observer=ctx.observer or None, session_allowlist=ctx.session_allowlist, ) search_meta: dict[str, Any] = { @@ -1969,10 +2083,8 @@ async def _handle_grep_messages( text = tool_input.get("text", "") if not text: return "ERROR: 'text' parameter is required" - limit = min(_safe_int(tool_input.get("limit"), 10), 30) # Cap at 30 - context_window = min( - _safe_int(tool_input.get("context_window"), 2), 2 - ) # Cap context + limit = _bounded_int(tool_input.get("limit"), 10, hi=30) + context_window = _bounded_int(tool_input.get("context_window"), 2, lo=0, hi=2) snippets = await crud.grep_messages( workspace_name=ctx.workspace_name, @@ -1980,7 +2092,7 @@ async def _handle_grep_messages( text=text, limit=limit, context_window=context_window, - observer=ctx.observer, + observer=ctx.observer or None, session_allowlist=ctx.session_allowlist, ) if not snippets: @@ -2025,7 +2137,7 @@ async def _handle_get_messages_by_date_range( """Handle get_messages_by_date_range tool.""" after_date_str = tool_input.get("after_date") before_date_str = tool_input.get("before_date") - limit = min(_safe_int(tool_input.get("limit"), 20), 20) + limit = _bounded_int(tool_input.get("limit"), 20, hi=20) order = tool_input.get("order", "desc") after_date = _parse_date(after_date_str, "after_date") @@ -2045,7 +2157,7 @@ async def _handle_get_messages_by_date_range( before_date=before_date, limit=limit, order=order, - observer=ctx.observer, + observer=ctx.observer or None, session_allowlist=ctx.session_allowlist, ) msg_count = len(messages) @@ -2091,8 +2203,8 @@ async def _handle_search_messages_temporal( after_date_str = tool_input.get("after_date") before_date_str = tool_input.get("before_date") - limit = min(_safe_int(tool_input.get("limit"), 10), 10) - context_window = min(_safe_int(tool_input.get("context_window"), 2), 2) + limit = _bounded_int(tool_input.get("limit"), 10, hi=10) + context_window = _bounded_int(tool_input.get("context_window"), 2, lo=0, hi=2) after_date = _parse_date(after_date_str, "after_date") if isinstance(after_date, str): @@ -2121,7 +2233,7 @@ async def _handle_search_messages_temporal( context_window=context_window, session_allowlist=ctx.session_allowlist, embedding=query_embedding, - observer=ctx.observer, + observer=ctx.observer or None, ) date_filter: list[str] = [] if after_date_str: @@ -2162,7 +2274,7 @@ async def _handle_get_recent_observations( workspace_name=ctx.workspace_name, observer=ctx.observer, observed=ctx.observed, - limit=min(_safe_int(tool_input.get("limit"), 10), 100), + limit=_bounded_int(tool_input.get("limit"), 10, hi=100), session_name=ctx.session_name if session_only else None, ) representation = Representation.from_documents(documents) @@ -2188,7 +2300,7 @@ async def _handle_get_most_derived_observations( workspace_name=ctx.workspace_name, observer=ctx.observer, observed=ctx.observed, - limit=min(_safe_int(tool_input.get("limit"), 10), 100), + limit=_bounded_int(tool_input.get("limit"), 10, hi=100), ) representation = Representation.from_documents(documents) total_count = representation.len() @@ -2226,6 +2338,16 @@ async def _handle_get_session_summary( async def _handle_get_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) -> str: """Handle get_peer_card tool.""" _ = tool_input + # A peer card lives in Peer.internal_metadata as a single cross-session + # aggregate, so it carries no session attribution and cannot be filtered + # to an allowlist. Fail closed rather than leak facts derived from + # out-of-scope sessions, the same rule get_reasoning_chain follows. + # No-op for agents that never set an allowlist (dreamer, pair dialectic). + if ctx.session_allowlist is not None: + return ( + "Peer cards are unavailable for session-scoped queries. " + "Use search_memory instead." + ) async with tracked_db("tool.get_peer_card", read_only=True) as db: peer_card = await crud.get_peer_card( db, @@ -2511,6 +2633,7 @@ async def create_tool_executor( agent_type: str | None = None, parent_category: str | None = None, session_allowlist: list[str] | None = None, + handler_resolver: Callable[[str], Any] | None = None, ) -> Callable[[str, dict[str, Any]], Any]: """ Create a unified tool executor function for all agent operations. @@ -2533,6 +2656,11 @@ async def create_tool_executor( run_id: Optional run ID for telemetry correlation agent_type: Optional agent type for telemetry (dialectic, deriver, dreamer) parent_category: Optional parent category for CloudEvents + session_allowlist: Optional list of session names message tools are + restricted to (None means no restriction) + handler_resolver: Optional callback that replaces the default + handler-table lookup for resolving tool names to handlers. + Returning None takes the "Unknown tool" path. Returns: An async callable that executes tools with the captured context @@ -2596,7 +2724,7 @@ async def create_tool_executor( tool_obs = _begin_tool_observation(tool_name, tool_input) try: - handler = _TOOL_HANDLERS.get(tool_name) + handler = (handler_resolver or _TOOL_HANDLERS.get)(tool_name) if handler: handler_result = await handler(ctx, tool_input) # Handlers return either a plain str (existing contract) or a @@ -2794,3 +2922,181 @@ def _estimate_tokens_safe(text: str | None) -> int | None: if not text: return None return _estimate_tokens(text) + + +# --------------------------------------------------------------------------- +# Workspace-level tool handlers (workspace chat) +# +# The workspace agent is not bound to an (observer, observed) pair. Handlers +# that need a pair take it from tool_input (the agent routes first, then +# supplies the pair); the rest are workspace-scoped reads. Message-search +# fallthrough handlers run with observer="" and normalize it to None at the +# crud boundary (`ctx.observer or None`) -- None means "no perspective +# scoping", which is correct for a workspace-level read. The empty string +# must never reach resolve_session_scope: it would be looked up as a real +# peer with no session memberships and deny all results. +# --------------------------------------------------------------------------- + + +async def _handle_search_memory_workspace( + ctx: ToolContext, tool_input: dict[str, Any] +) -> "str | ToolResult": + """Pair-scoped observation search; the pair comes from tool arguments.""" + observer = tool_input.get("observer", "") + observed = tool_input.get("observed", "") + if not observer or not observed: + return ( + "ERROR: 'observer' and 'observed' are required. For a peer's " + "global representation set both to the SAME peer name." + ) + pair_ctx = replace(ctx, observer=observer, observed=observed) + result = await _handle_search_memory(pair_ctx, tool_input) + # Attribute the pair in the output β€” the workspace agent may query + # several pairs in one turn and must not conflate their results. + if isinstance(result, ToolResult): + return replace(result, content=f"[{observer}->{observed}]\n{result.content}") + return f"[{observer}->{observed}]\n{result}" + + +async def _handle_get_peer_card_by_name( + ctx: ToolContext, tool_input: dict[str, Any] +) -> str: + """get_peer_card with the pair taken from tool arguments.""" + observer = tool_input.get("observer", "") + observed = tool_input.get("observed", "") + if not observer or not observed: + return "ERROR: 'observer' and 'observed' are required parameters" + pair_ctx = replace(ctx, observer=observer, observed=observed) + try: + return await _handle_get_peer_card(pair_ctx, tool_input) + except ResourceNotFoundException: + # The workspace agent names peers from its own routing, so guessing a + # peer that doesn't exist is an expected turn, not a fault. Answer the + # model instead of letting the executor log it as an unexpected error. + return f"No peer named '{observer}' exists in this workspace" + + +# Peers listed by get_workspace_stats. Fixed rather than a tool argument: +# folding active peers into stats keeps the tool zero-arg (one discovery +# round instead of two); deeper discovery goes through search_messages. +_STATS_ACTIVE_PEERS = 10 + + +# Peer-card facts listed per peer when cards are supplied. +_STATS_CARD_FACTS = 8 + + +def format_workspace_stats( + stats: "crud.WorkspaceStats", + peers: "Sequence[crud.ActivePeer]", + cards: dict[str, list[str]] | None = None, +) -> str: + """Render workspace counts and most-active peers as prompt-ready lines. + + Shared by the get_workspace_stats tool and WorkspaceDialecticAgent's + routing prefetch; the prefetch passes ``cards`` to nest each peer's + known biographical facts under it. + """ + lines = [ + f"Peers: {stats.peer_count}", + f"Sessions: {stats.session_count}", + f"Messages: {stats.message_count}", + ] + if stats.oldest_message_at and stats.newest_message_at: + lines.append( + f"Date range: {stats.oldest_message_at:%Y-%m-%d} to {stats.newest_message_at:%Y-%m-%d}" + ) + if peers: + lines.append("") + lines.append(f"Most active peers (top {len(peers)}):") + for peer in peers: + last_active = ( + f", last active {peer.last_message_at:%Y-%m-%d}" + if peer.last_message_at + else "" + ) + lines.append(f"- {peer.name} ({peer.message_count} messages{last_active})") + for fact in (cards or {}).get(peer.name, [])[:_STATS_CARD_FACTS]: + lines.append(f" - {fact}") + return "\n".join(lines) + + +async def _handle_get_workspace_stats( + ctx: ToolContext, tool_input: dict[str, Any] +) -> str: + """Workspace-level counts, message date range, and most active peers.""" + _ = tool_input + async with tracked_db("workspace_tool.get_workspace_stats", read_only=True) as db: + stats = await crud.get_workspace_stats( + db, ctx.workspace_name, session_names=ctx.session_allowlist + ) + peers = await crud.get_active_peers( + db, + ctx.workspace_name, + limit=_STATS_ACTIVE_PEERS, + session_names=ctx.session_allowlist, + ) + return "Workspace stats:\n" + format_workspace_stats(stats, peers) + + +# Dispatch table consulted before _TOOL_HANDLERS by the workspace executor. +_WORKSPACE_TOOL_HANDLERS: dict[str, Callable[[ToolContext, dict[str, Any]], Any]] = { + "search_memory": _handle_search_memory_workspace, + "get_workspace_stats": _handle_get_workspace_stats, + "get_peer_card": _handle_get_peer_card_by_name, + "get_reasoning_chain": _handle_get_reasoning_chain, # already workspace-scoped +} + +# Standard handlers that are safe with an empty observer/observed sentinel +# (they only read messages, treating observer="" as unscoped visibility). +_WORKSPACE_SAFE_FALLTHROUGH_TOOLS: frozenset[str] = frozenset( + { + "get_observation_context", + "search_messages", + "grep_messages", + "get_messages_by_date_range", + "search_messages_temporal", + } +) + + +def _workspace_handler_resolver(tool_name: str) -> Any: + handler = _WORKSPACE_TOOL_HANDLERS.get(tool_name) + if handler is not None: + return handler + if tool_name in _WORKSPACE_SAFE_FALLTHROUGH_TOOLS: + return _TOOL_HANDLERS.get(tool_name) + return None + + +async def create_workspace_tool_executor( + workspace_name: str, + session_name: str | None = None, + session_allowlist: list[str] | None = None, + history_token_limit: int = 8192, + run_id: str | None = None, + agent_type: str | None = None, + parent_category: str | None = None, +) -> Callable[[str, dict[str, Any]], Any]: + """Tool executor for workspace-level operations (no bound peer pair). + + Reuses create_tool_executor's telemetry/error plumbing via the + handler_resolver seam. observer/observed are empty-string sentinels only + ever seen by handlers in _WORKSPACE_SAFE_FALLTHROUGH_TOOLS, which + normalize them to None before hitting crud (None means "no perspective + scoping"; an empty string would read as a real peer with no sessions and + deny everything). + """ + return await create_tool_executor( + workspace_name=workspace_name, + observer="", + observed="", + session_name=session_name, + session_allowlist=session_allowlist, + include_observation_ids=True, + history_token_limit=history_token_limit, + run_id=run_id, + agent_type=agent_type, + parent_category=parent_category, + handler_resolver=_workspace_handler_resolver, + ) diff --git a/src/utils/filter.py b/src/utils/filter.py index b2c362ea..64598ea9 100644 --- a/src/utils/filter.py +++ b/src/utils/filter.py @@ -292,16 +292,50 @@ def extract_session_allowlist( 'filters.session_id must be a session id, a list of session ids, or {"in": [...]}' ) + return normalize_session_allowlist( + entries, field="filters.session_id", must_include=must_include + ) + + +def normalize_session_allowlist( + entries: Sequence[Any], + *, + field: str, + must_include: str | None = None, +) -> list[str]: + """Validate and de-duplicate a session allowlist. + + Shared by every route-level entry point that accepts one β€” the ``filters`` + body on the recall endpoints and the ``sessions`` query parameter on session + context β€” so the cap, the id charset, and the ``must_include`` rule cannot + drift apart between them. Only the parameter *name* in error messages + differs, which is what ``field`` supplies. + + Args: + entries: Raw allowlist entries as the caller supplied them. + field: Caller-facing parameter name, used in error messages. + must_include: A session id that must appear in the allowlist β€” used by + routes that also carry a session of their own, so the two can't + contradict each other. + + Returns: + The allowlist, de-duplicated, in first-seen order. An empty input yields + an empty list so downstream consumers fail closed. + + Raises: + FilterError: On an over-cap list, a malformed session id, or a + ``must_include`` session missing from the allowlist. + """ if len(entries) > MAX_SESSION_ALLOWLIST_ENTRIES: raise FilterError( - f"filters.session_id supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request" + f"{field} supports at most {MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request" ) allowlist: list[str] = [] seen: set[str] = set() for entry in entries: if not isinstance(entry, str) or not entry: - raise FilterError("filters.session_id entries must be non-empty strings") + raise FilterError(f"{field} entries must be non-empty strings") # Only names a session could actually have. The allowlist reaches # queries three ways β€” direct `IN`, the filter DSL, and a Python # membership test β€” and they don't agree on a value like "*", which the @@ -311,14 +345,14 @@ def extract_session_allowlist( # {"in": [...]}) which never included wildcards. if not re.fullmatch(RESOURCE_NAME_PATTERN, entry): raise FilterError( - f"Invalid session id in filters.session_id: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}" + f"Invalid session id in {field}: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}" ) if entry not in seen: seen.add(entry) allowlist.append(entry) if must_include is not None and must_include not in seen: - raise FilterError("session_id must be included in filters.session_id") + raise FilterError(f"session_id must be included in {field}") return allowlist @@ -731,7 +765,9 @@ def _build_comparison_condition( "lte": lambda a, v: a <= v, "gt": lambda a, v: a > v, "lt": lambda a, v: a < v, - "ne": lambda a, v: a != v, + # IS DISTINCT FROM, not <>: (metadata ->> key) is NULL for an + # absent key, and `NULL <> v` is NULL, so <> drops those rows. + "ne": lambda a, v: a.is_distinct_from(v), } return operator_map[operator](safe_accessor, safe_value) except Exception as e: @@ -967,7 +1003,7 @@ def _validate_datetime_string(value: str) -> datetime.datetime | None: try: parsed = datetime.datetime.strptime(value, fmt) # Assume UTC timezone for naive datetimes - return parsed.replace(tzinfo=datetime.timezone.utc) + return parsed.replace(tzinfo=datetime.UTC) except ValueError: continue diff --git a/src/utils/sanitization.py b/src/utils/sanitization.py new file mode 100644 index 00000000..4879cc72 --- /dev/null +++ b/src/utils/sanitization.py @@ -0,0 +1,46 @@ +"""Helpers for stripping bytes Postgres cannot store in text columns. + +Postgres rejects NUL (0x00) in ``text``/``varchar`` values and in ``jsonb`` +strings, so any string bound into a query or persisted to those columns has to +have NUL removed first. This applies to model-generated text as much as to +user-supplied input: an LLM can emit a ``\\u0000`` escape in its tool-call +arguments, which the JSON parser decodes into a real NUL byte. +""" + +from typing import Any, cast, overload + +from pydantic import BeforeValidator + +__all__ = ["NulStripped", "strip_nul"] + + +@overload +def strip_nul(value: str) -> str: ... + + +@overload +def strip_nul(value: Any) -> Any: ... + + +def strip_nul(value: Any) -> Any: + """Recursively remove NUL bytes from strings, including nested ones. + + Dict keys are stripped alongside values. Anything that is not a string, + dict, or list -- ``None`` included -- is returned unchanged, so this can be + applied to an optional field without a guard. + """ + if isinstance(value, str): + return value.replace("\x00", "") + if isinstance(value, dict): + d = cast(dict[str, Any], value) + return {strip_nul(k): strip_nul(v) for k, v in d.items()} + if isinstance(value, list): + lst = cast(list[Any], value) + return [strip_nul(item) for item in lst] + return value + + +# Reusable annotation for string fields; composes with a per-field Field(...). +# Runs *before* the field's own constraints, so `min_length` is checked against +# the stripped value and all-NUL input is rejected instead of becoming "". +NulStripped = BeforeValidator(strip_nul) diff --git a/src/utils/scopes.py b/src/utils/scopes.py index c7b89192..5f01916b 100644 --- a/src/utils/scopes.py +++ b/src/utils/scopes.py @@ -19,7 +19,8 @@ carries a look-alike ``configuration``, is not a scope. from collections.abc import Iterable from typing import Any -from src.exceptions import ValidationException +from src.exceptions import AuthenticationException, ValidationException +from src.security import JWTParams # Reserved peer-name prefix for scope peers. User-created peers may not use it. # @@ -87,3 +88,20 @@ def validate_no_scope_peer_names(names: Iterable[str], *, action: str) -> None: f"Peer name(s) {offenders} use the reserved scope prefix " + f"'{SCOPE_PEER_PREFIX}'. {action}" ) + + +def validate_scope_read_option( + *, + filters: dict[str, Any] | None, + session_id: str | None, + jwt_params: JWTParams, +) -> None: + """Refuse `scope` combined with `filters`/`session_id`, or a peer-scoped key.""" + if filters is not None: + raise ValidationException("`scope` and `filters` are mutually exclusive") + if session_id: + raise ValidationException("`scope` and `session_id` are mutually exclusive") + if jwt_params.p is not None: + raise AuthenticationException( + "`scope` requires a workspace- or admin-level key" + ) diff --git a/src/utils/search.py b/src/utils/search.py index 761b63e0..76e86fd4 100644 --- a/src/utils/search.py +++ b/src/utils/search.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import models from src.config import settings from src.dependencies import tracked_db -from src.embedding_client import embedding_client +from src.embedding_client import EmbeddingTokenLimitError, embedding_client from src.exceptions import ValidationException from src.models import session_peers_table from src.telemetry.events import EmbeddingCallPurpose @@ -82,6 +82,9 @@ async def query_external_vector_message_ids( filters: dict[str, Any] | None = None, ) -> list[str]: """Query the external vector store and return ordered message IDs.""" + if limit <= 0: + return [] + external_vector_store = get_external_vector_store() if external_vector_store is None: return [] @@ -388,7 +391,7 @@ async def search( parent_category="api", ): query_embedding = await embedding_client.embed(query) - except ValueError as e: + except EmbeddingTokenLimitError as e: raise ValidationException( f"Query exceeds maximum token limit of {settings.EMBEDDING.MAX_INPUT_TOKENS}." ) from e diff --git a/src/utils/types.py b/src/utils/types.py index 24a13ae6..0bfada75 100644 --- a/src/utils/types.py +++ b/src/utils/types.py @@ -1,4 +1,4 @@ -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable, Generator, Iterator from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass, field @@ -6,6 +6,20 @@ from typing import Any, Generic, Literal, TypeVar T = TypeVar("T") + +def walk_subclasses(cls: type[T]) -> Iterator[type[T]]: + """Yield every subclass of ``cls``, recursively.""" + # region ai + # ``type.__subclasses__()`` is direct-children-only, so a grandchild class is + # silently invisible to it. Any registry that enumerates subclasses to decide + # what to initialize or validate wants the transitive closure β€” otherwise + # subclassing a concrete class is enough to slip past the check. + # endregion + for subclass in cls.__subclasses__(): + yield subclass + yield from walk_subclasses(subclass) + + # Context variable for tracking current iteration in tool execution loop # This is used for telemetry to associate tool calls with their iteration _current_iteration: ContextVar[int] = ContextVar("current_iteration", default=0) diff --git a/src/vector_store/lancedb.py b/src/vector_store/lancedb.py index 1b4c4880..ab44c1bc 100644 --- a/src/vector_store/lancedb.py +++ b/src/vector_store/lancedb.py @@ -214,6 +214,9 @@ class LanceDBVectorStore(VectorStore): Returns: List of VectorQueryResult objects, ordered by similarity (most similar first) """ + if top_k <= 0: + return [] + table = await self._get_table(namespace) if table is None: logger.debug(f"Table {namespace} does not exist, returning empty results") diff --git a/src/vector_store/turbopuffer.py b/src/vector_store/turbopuffer.py index e7825310..214c5f8a 100644 --- a/src/vector_store/turbopuffer.py +++ b/src/vector_store/turbopuffer.py @@ -143,6 +143,9 @@ class TurbopufferVectorStore(VectorStore): Returns: List of VectorQueryResult objects, ordered by similarity (most similar first) """ + if top_k <= 0: + return [] + ns = self._get_namespace(namespace) try: diff --git a/tests/bench/runner_common.py b/tests/bench/runner_common.py index be262cb0..b150a3f8 100644 --- a/tests/bench/runner_common.py +++ b/tests/bench/runner_common.py @@ -17,21 +17,18 @@ from dataclasses import dataclass, field from datetime import datetime from logging import Logger from pathlib import Path -from typing import Any, Generic, Literal, TypeVar, cast +from typing import Any, Generic, TypeVar, cast from anthropic import AsyncAnthropic from honcho import Honcho from honcho.api_types import SessionConfiguration, SummaryConfiguration from openai import AsyncOpenAI +from src.config import REASONING_LEVELS, ReasoningLevel from src.telemetry.metrics_collector import MetricsCollector _logger = logging.getLogger(__name__) -# Valid reasoning levels for dialectic chat -ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] -REASONING_LEVELS: list[str] = ["minimal", "low", "medium", "high", "max"] - # Type variable for result types ResultT = TypeVar("ResultT") diff --git a/tests/conftest.py b/tests/conftest.py index f99859b3..090d5395 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,6 +86,8 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = ( # LLM transport tests mock providers directly and don't need database/runtime setup. "tests/utils/test_length_finish_reason.py", "tests/utils/test_clients.py", + # Session-scope SQL shape β€” asserts on compiled statements, never executes one. + "tests/crud/test_session_scope_clauses.py", # Pure JWT scope tests β€” operate on src.security directly, no DB needed. "tests/test_security.py", "tests/test_generate_jwt_script.py", @@ -604,7 +606,9 @@ def mock_openai_embeddings(request: pytest.FixtureRequest): mock_embed.side_effect = embed_side_effect - async def mock_simple_batch_embed_func(texts: list[str]) -> list[list[float]]: + async def mock_simple_batch_embed_func( + texts: list[str], **_kwargs: object + ) -> list[list[float]]: return [_content_to_embedding(text) for text in texts] mock_simple_batch_embed.side_effect = mock_simple_batch_embed_func @@ -767,6 +771,12 @@ def mock_llm_call_functions(request: pytest.FixtureRequest): patch( "src.routers.peers.agentic_chat_stream", side_effect=mock_stream ) as mock_agentic_chat_stream, + patch( + "src.routers.workspaces.workspace_chat", new_callable=AsyncMock + ) as mock_workspace_chat, + patch( + "src.routers.workspaces.workspace_chat_stream", side_effect=mock_stream + ) as mock_workspace_chat_stream, ): # Mock return values for different function types mock_short_summary.return_value = "Test short summary content" @@ -782,11 +792,20 @@ def mock_llm_call_functions(request: pytest.FixtureRequest): mock_agentic_chat.side_effect = _agentic_chat_response + async def _workspace_chat_response(*_args: object, **kwargs: object) -> str: + if kwargs.get("response_model") is not None: + return "{}" + return "Test workspace chat response" + + mock_workspace_chat.side_effect = _workspace_chat_response + yield { "short_summary": mock_short_summary, "long_summary": mock_long_summary, "agentic_chat": mock_agentic_chat, "agentic_chat_stream": mock_agentic_chat_stream, + "workspace_chat": mock_workspace_chat, + "workspace_chat_stream": mock_workspace_chat_stream, } diff --git a/tests/crud/test_document.py b/tests/crud/test_document.py index 5f03c80b..6686e688 100644 --- a/tests/crud/test_document.py +++ b/tests/crud/test_document.py @@ -1006,6 +1006,46 @@ class TestDocumentCRUD: assert documents[0].content in ["Observation 1", "Observation 2"] assert documents[1].content in ["Observation 1", "Observation 2"] + @pytest.mark.asyncio + async def test_create_observations_embeds_with_truncate_on_oversize( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """API conclusion creates must opt into truncation on oversize content.""" + test_workspace, test_peer = sample_data + test_peer2, test_session, _ = await self._setup_test_data( + db_session, test_workspace, test_peer + ) + + with patch( + "src.crud.document.embedding_client.simple_batch_embed", + new=AsyncMock(return_value=[[0.1] * 1536, [0.2] * 1536]), + ) as mock_embed: + created = await crud.create_observations( + db_session, + observations=[ + schemas.ConclusionCreate( + content="short conclusion", + observer_id=test_peer.name, + observed_id=test_peer2.name, + session_id=test_session.name, + ), + schemas.ConclusionCreate( + content="another conclusion", + observer_id=test_peer.name, + observed_id=test_peer2.name, + session_id=test_session.name, + ), + ], + workspace_name=test_workspace.name, + ) + + assert len(created) == 2 + mock_embed.assert_awaited_once_with( + ["short conclusion", "another conclusion"], on_oversize="truncate" + ) + class TestSessionPurityInvariant: """Regression tests for the explicit-document session-purity invariant. diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index 6c5e094c..9392b78e 100644 --- a/tests/crud/test_representation_manager.py +++ b/tests/crud/test_representation_manager.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -196,7 +196,7 @@ class TestRepresentationManagerSoftDelete: db_session, test_workspace, test_peer ) - base = datetime(2026, 1, 1, tzinfo=timezone.utc) + base = datetime(2026, 1, 1, tzinfo=UTC) # Three conclusions, all reinforced once, inserted oldest-first. for i in range(3): db_session.add( @@ -484,13 +484,13 @@ class TestRepresentationManagerSave: explicit=[ ExplicitObservation( content=" ", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), ExplicitObservation( content=" useful observation ", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), @@ -515,12 +515,14 @@ class TestRepresentationManagerSave: representation, message_ids=[1], session_name="session", - message_created_at=datetime.now(timezone.utc), + message_created_at=datetime.now(UTC), message_level_configuration=_resolved_config(), ) assert len(saved.created_documents) == 1 - mock_embed.assert_awaited_once_with(["useful observation"]) + mock_embed.assert_awaited_once_with( + ["useful observation"], on_oversize="truncate" + ) saved_observations = _saved_observations(mock_save) assert len(saved_observations) == 1 assert saved_observations[0].content == "useful observation" @@ -538,7 +540,7 @@ class TestRepresentationManagerSave: conclusion=" ", premises=["premise a"], source_ids=["doc-a"], - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), @@ -546,7 +548,7 @@ class TestRepresentationManagerSave: conclusion=" inferred conclusion ", premises=["premise b"], source_ids=["doc-b"], - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), @@ -571,12 +573,14 @@ class TestRepresentationManagerSave: representation, message_ids=[1], session_name="session", - message_created_at=datetime.now(timezone.utc), + message_created_at=datetime.now(UTC), message_level_configuration=_resolved_config(), ) assert len(saved.created_documents) == 1 - mock_embed.assert_awaited_once_with(["inferred conclusion"]) + mock_embed.assert_awaited_once_with( + ["inferred conclusion"], on_oversize="truncate" + ) saved_observations = _saved_observations(mock_save) assert len(saved_observations) == 1 assert isinstance(saved_observations[0], DeductiveObservation) @@ -593,13 +597,13 @@ class TestRepresentationManagerSave: explicit=[ ExplicitObservation( content="", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), ExplicitObservation( content="\n\t ", - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), message_ids=[1], session_name="session", ), @@ -622,7 +626,7 @@ class TestRepresentationManagerSave: representation, message_ids=[1], session_name="session", - message_created_at=datetime.now(timezone.utc), + message_created_at=datetime.now(UTC), message_level_configuration=_resolved_config(), ) @@ -630,6 +634,178 @@ class TestRepresentationManagerSave: mock_embed.assert_not_awaited() mock_save.assert_not_awaited() + @pytest.mark.asyncio + async def test_save_representation_strips_nul_bytes(self): + """Models emit \\u0000 escapes when transcribing shell output or Windows + paths, and Postgres rejects NUL in text columns. The stripped text must + be what gets embedded as well as what gets stored.""" + manager = RepresentationManager( + "workspace", + observer="observer", + observed="observed", + ) + representation = Representation( + explicit=[ + ExplicitObservation( + content="ran 'cat /proc/1/environ | tr '\x00' '\\n''", + created_at=datetime.now(UTC), + message_ids=[1], + session_name="session", + ), + ], + deductive=[ + DeductiveObservation( + conclusion="the key is at c:\\\x00users\\amal", + premises=["saw c:\\\x00users in the prompt"], + created_at=datetime.now(UTC), + message_ids=[1], + session_name="session", + ), + ], + ) + + with ( + patch("src.crud.representation.tracked_db", _fake_tracked_db), + patch( + "src.crud.representation.embedding_client.simple_batch_embed", + new=AsyncMock(return_value=[[0.1], [0.2]]), + ) as mock_embed, + patch.object( + manager, + "_save_representation_internal", + new=AsyncMock( + return_value=CreateDocumentsResult(created_documents=[MagicMock()]) + ), + ) as mock_save, + ): + await manager.save_representation( + representation, + message_ids=[1], + session_name="session", + message_created_at=datetime.now(UTC), + message_level_configuration=_resolved_config(), + ) + + # Deductive observations are embedded ahead of explicit ones. + mock_embed.assert_awaited_once_with( + [ + "the key is at c:\\users\\amal", + "ran 'cat /proc/1/environ | tr '' '\\n''", + ], + on_oversize="truncate", + ) + + saved_observations = _saved_observations(mock_save) + deductive = next( + obs for obs in saved_observations if isinstance(obs, DeductiveObservation) + ) + explicit = next( + obs for obs in saved_observations if isinstance(obs, ExplicitObservation) + ) + assert explicit.content == "ran 'cat /proc/1/environ | tr '' '\\n''" + assert deductive.conclusion == "the key is at c:\\users\\amal" + # premises land in internal_metadata, and jsonb rejects NUL too + assert deductive.premises == ["saw c:\\users in the prompt"] + + @pytest.mark.asyncio + async def test_save_representation_skips_observations_that_are_only_nul(self): + """str.strip() does not remove NUL, so the emptiness check has to run + after normalization or an empty document gets written.""" + manager = RepresentationManager( + "workspace", + observer="observer", + observed="observed", + ) + representation = Representation( + explicit=[ + ExplicitObservation( + content="\x00\x00", + created_at=datetime.now(UTC), + message_ids=[1], + session_name="session", + ), + ] + ) + + with ( + patch("src.crud.representation.tracked_db", _fake_tracked_db), + patch( + "src.crud.representation.embedding_client.simple_batch_embed", + new=AsyncMock(), + ) as mock_embed, + patch.object( + manager, + "_save_representation_internal", + new=AsyncMock(), + ) as mock_save, + ): + saved = await manager.save_representation( + representation, + message_ids=[1], + session_name="session", + message_created_at=datetime.now(UTC), + message_level_configuration=_resolved_config(), + ) + + assert len(saved.created_documents) == 0 + mock_embed.assert_not_awaited() + mock_save.assert_not_awaited() + + @pytest.mark.asyncio + async def test_save_representation_embeds_with_truncate_on_oversize(self): + """One oversize observation must not drop the rest of the batch.""" + manager = RepresentationManager( + "workspace", + observer="observer", + observed="observed", + ) + representation = Representation( + explicit=[ + ExplicitObservation( + content="short fact", + created_at=datetime.now(UTC), + message_ids=[1], + session_name="session", + ) + ], + deductive=[ + DeductiveObservation( + conclusion="inferred fact", + premises=["premise"], + source_ids=["doc-a"], + created_at=datetime.now(UTC), + message_ids=[1], + session_name="session", + ) + ], + ) + + with ( + patch("src.crud.representation.tracked_db", _fake_tracked_db), + patch( + "src.crud.representation.embedding_client.simple_batch_embed", + new=AsyncMock(return_value=[[0.1], [0.2]]), + ) as mock_embed, + patch.object( + manager, + "_save_representation_internal", + new=AsyncMock( + return_value=CreateDocumentsResult(created_documents=[MagicMock()]) + ), + ), + ): + await manager.save_representation( + representation, + message_ids=[1], + session_name="session", + message_created_at=datetime.now(UTC), + message_level_configuration=_resolved_config(), + ) + + mock_embed.assert_awaited_once_with( + ["inferred fact", "short fact"], on_oversize="truncate" + ) + class TestVectorQueryTopKFloor: """Regression for HONCHO-19Q / HONCHO-4Q4. @@ -698,3 +874,48 @@ class TestVectorQueryTopKFloor: top_k = mock_query.await_args.kwargs["top_k"] assert top_k >= 1, f"max_observations={max_observations} gave top_k={top_k}" assert top_k <= max_observations + + @pytest.mark.asyncio + async def test_search_messages_external_returns_empty_without_querying_on_zero_limit( + self, + ): + """Message vector search is the remaining path that still hit Turbopuffer.""" + from src.crud import message as message_crud + + with patch( + "src.crud.message.get_external_vector_store", + return_value=AsyncMock(), + ) as mock_get_store: + for limit in (0, -1): + assert ( + await message_crud._search_messages_external( # pyright: ignore[reportPrivateUsage] + "workspace", + [0.1, 0.2, 0.3], + limit, + ) + == [] + ) + + mock_get_store.assert_not_called() + + @pytest.mark.asyncio + async def test_query_external_vector_message_ids_skips_store_on_zero_limit( + self, + ): + from src.utils import search as search_utils + + with patch( + "src.utils.search.get_external_vector_store", + return_value=AsyncMock(), + ) as mock_get_store: + for limit in (0, -1): + assert ( + await search_utils.query_external_vector_message_ids( + "workspace", + [0.1, 0.2, 0.3], + limit, + ) + == [] + ) + + mock_get_store.assert_not_called() diff --git a/tests/crud/test_session.py b/tests/crud/test_session.py index 8b4b5795..2eec1f32 100644 --- a/tests/crud/test_session.py +++ b/tests/crud/test_session.py @@ -1,14 +1,340 @@ +from datetime import datetime, timezone + import pytest from nanoid import generate as generate_nanoid +from sqlalchemy import Boolean, func, select from sqlalchemy.ext.asyncio import AsyncSession from src import crud, models, schemas -from src.exceptions import ResourceNotFoundException +from src.config import settings +from src.exceptions import ObserverException, ResourceNotFoundException class TestSessionCRUD: """Test suite for session CRUD operations""" + @pytest.mark.asyncio + async def test_get_or_create_session_preserves_active_joined_at( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """Active re-adds keep joined_at and config; a genuine rejoin starts a + new window.""" + test_workspace, test_peer = sample_data + session_name = str(generate_nanoid()) + original_config = schemas.SessionPeerConfig( + observe_others=True, observe_me=False + ) + updated_config = schemas.SessionPeerConfig( + observe_others=False, observe_me=True + ) + session_peer_stmt = select( + models.SessionPeer.joined_at, + models.SessionPeer.left_at, + models.SessionPeer.configuration, + ).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers={test_peer.name: original_config} + ), + test_workspace.name, + ) + first_joined_at, first_left_at, first_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert first_left_at is None + assert first_config == original_config.model_dump() + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers={test_peer.name: updated_config} + ), + test_workspace.name, + ) + second_joined_at, second_left_at, second_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert second_joined_at == first_joined_at + assert second_left_at is None + assert second_config == original_config.model_dump() + + session_peer = ( + await db_session.execute( + select(models.SessionPeer).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + ) + ).scalar_one() + session_peer.left_at = datetime.now(timezone.utc) + await db_session.commit() + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers={test_peer.name: updated_config} + ), + test_workspace.name, + ) + rejoined_joined_at, rejoined_left_at, rejoined_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert rejoined_joined_at > second_joined_at + assert rejoined_left_at is None + assert rejoined_config == updated_config.model_dump() + + @pytest.mark.asyncio + async def test_set_peers_preserves_active_joined_at( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + ): + """PUT-peers keeps active membership windows and refreshes real rejoins.""" + test_workspace, test_peer = sample_data + session_name = str(generate_nanoid()) + original_config = schemas.SessionPeerConfig( + observe_others=True, observe_me=False + ) + updated_config = schemas.SessionPeerConfig( + observe_others=False, observe_me=True + ) + db_session.add( + models.Session(name=session_name, workspace_name=test_workspace.name) + ) + await db_session.flush() + + session_peer_stmt = select( + models.SessionPeer.joined_at, + models.SessionPeer.left_at, + models.SessionPeer.configuration, + ).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + + await crud.set_peers_for_session( + db_session, + workspace_name=test_workspace.name, + session_name=session_name, + peer_names={test_peer.name: original_config}, + ) + first_left_at, first_config = ( + await db_session.execute( + select( + models.SessionPeer.left_at, + models.SessionPeer.configuration, + ).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + ) + ).one() + assert first_left_at is None + assert first_config == original_config.model_dump() + + session_peer = ( + await db_session.execute( + select(models.SessionPeer).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.peer_name == test_peer.name, + models.SessionPeer.workspace_name == test_workspace.name, + ) + ) + ).scalar_one() + session_peer.joined_at = datetime(2020, 1, 1, tzinfo=timezone.utc) + await db_session.commit() + + await crud.set_peers_for_session( + db_session, + workspace_name=test_workspace.name, + session_name=session_name, + peer_names={test_peer.name: updated_config}, + ) + active_joined_at, active_left_at, active_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert active_joined_at == datetime(2020, 1, 1, tzinfo=timezone.utc) + assert active_left_at is None + # A replace states the desired end state, so the incoming config lands even + # though the membership window is untouched. + assert active_config == updated_config.model_dump() + + await crud.set_peers_for_session( + db_session, + workspace_name=test_workspace.name, + session_name=session_name, + peer_names={}, + ) + await crud.set_peers_for_session( + db_session, + workspace_name=test_workspace.name, + session_name=session_name, + peer_names={test_peer.name: updated_config}, + ) + rejoined_joined_at, rejoined_left_at, rejoined_config = ( + await db_session.execute(session_peer_stmt) + ).one() + assert rejoined_joined_at > active_joined_at + assert rejoined_left_at is None + assert rejoined_config == updated_config.model_dump() + + @pytest.mark.asyncio + async def test_observer_limit_counts_preserved_config( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """On the add path an already-active observer keeps its stored config, so + it still counts against the limit when re-sent as a non-observer.""" + monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2) + test_workspace, _ = sample_data + # Bound to a local: rollback below expires the ORM instance, and reloading + # it would lazy-load outside the greenlet context. + workspace_name = test_workspace.name + session_name = str(generate_nanoid()) + observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False) + bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True) + existing = [str(generate_nanoid()) for _ in range(2)] + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers=dict.fromkeys(existing, observer) + ), + workspace_name, + ) + + # Adding cannot demote an active member, so re-sending the two observers as + # non-observers leaves them observing and the third peer makes three. + with pytest.raises(ObserverException): + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, + peers={ + **dict.fromkeys(existing, bystander), + str(generate_nanoid()): observer, + }, + ), + workspace_name, + ) + + # The rejected request left nothing behind. + await db_session.rollback() + observer_count = await db_session.scalar( + select(func.count()).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.left_at.is_(None), + models.SessionPeer.configuration["observe_others"].astext.cast(Boolean), + ) + ) + assert observer_count == 2 + + @pytest.mark.asyncio + async def test_set_peers_observer_limit_counts_replaced_config( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """The replace path applies the incoming config, so demoting active + observers frees room under the limit in the same request.""" + monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2) + test_workspace, _ = sample_data + workspace_name = test_workspace.name + session_name = str(generate_nanoid()) + db_session.add(models.Session(name=session_name, workspace_name=workspace_name)) + await db_session.flush() + observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False) + bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True) + existing = [str(generate_nanoid()) for _ in range(2)] + + await crud.set_peers_for_session( + db_session, + workspace_name=workspace_name, + session_name=session_name, + peer_names=dict.fromkeys(existing, observer), + ) + + # Demoting both active observers while adding a new one leaves exactly one. + await crud.set_peers_for_session( + db_session, + workspace_name=workspace_name, + session_name=session_name, + peer_names={ + **dict.fromkeys(existing, bystander), + str(generate_nanoid()): observer, + }, + ) + observer_count = await db_session.scalar( + select(func.count()).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.left_at.is_(None), + models.SessionPeer.configuration["observe_others"].astext.cast(Boolean), + ) + ) + assert observer_count == 1 + + @pytest.mark.asyncio + async def test_observer_limit_lets_over_limit_session_take_non_observers( + self, + db_session: AsyncSession, + sample_data: tuple[models.Workspace, models.Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """A session already past the limit still accepts non-observers, so + sessions that grew over it before enforcement do not become unusable.""" + monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2) + test_workspace, _ = sample_data + workspace_name = test_workspace.name + session_name = str(generate_nanoid()) + observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False) + bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True) + + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, + peers=dict.fromkeys( + [str(generate_nanoid()) for _ in range(2)], observer + ), + ), + workspace_name, + ) + + # Now the limit is below what the session already holds. + monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 1) + await crud.get_or_create_session( + db_session, + schemas.SessionCreate( + name=session_name, peers={str(generate_nanoid()): bystander} + ), + workspace_name, + ) + + active_count = await db_session.scalar( + select(func.count()).where( + models.SessionPeer.session_name == session_name, + models.SessionPeer.workspace_name == workspace_name, + models.SessionPeer.left_at.is_(None), + ) + ) + assert active_count == 3 + @pytest.mark.asyncio async def test_get_session_peer_configuration( self, diff --git a/tests/crud/test_session_scope_clauses.py b/tests/crud/test_session_scope_clauses.py new file mode 100644 index 00000000..a4a77c60 --- /dev/null +++ b/tests/crud/test_session_scope_clauses.py @@ -0,0 +1,169 @@ +"""Observer session scope is enforced in SQL, not as a fetched name list. + +A peer's session-membership count is unbounded. Materializing it turns every +session name into its own bind parameter, and the PostgreSQL wire protocol caps +parameters at 65535 per statement, so a peer in enough sessions yields a +statement the driver refuses to serialize. `get_observation_context` applies the +scope twice in one statement, which halves that ceiling to ~32.7k sessions. + +These tests assert on compiled SQL and never execute a statement. +""" + +from typing import Any + +import pytest +from sqlalchemy import Select, select +from sqlalchemy.dialects import postgresql + +from src import models +from src.crud.message import resolve_session_scope_clauses +from src.utils.agent_tools import get_observation_context + + +def _compile(stmt: Select[Any]) -> tuple[str, dict[str, Any]]: + compiled = stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"render_postcompile": True}, + ) + return str(compiled), dict(compiled.params) + + +class _FakeResult: + def scalars(self) -> "_FakeResult": + return self + + def all(self) -> list[Any]: + return [] + + +class _CapturingDB: + """Captures statements instead of executing them.""" + + def __init__(self) -> None: + self.statements: list[Any] = [] + + async def execute(self, stmt: Any) -> _FakeResult: + self.statements.append(stmt) + return _FakeResult() + + +@pytest.mark.parametrize( + ("session_allowlist", "observer", "expect_exists", "expected_params"), + [ + # Observer only: membership becomes a correlated EXISTS, so only the + # workspace and peer are bound β€” never the session names, which is what + # keeps the parameter count from growing with membership. + (None, "observer-peer", True, ["observer-peer", "workspace"]), + # Allowlist only: caller-supplied and therefore bounded, so IN is fine. + (["s1", "s2"], None, False, ["s1", "s2"]), + # Both: the EXISTS is intersected with the bounded IN. + (["s1"], "observer-peer", True, ["observer-peer", "s1", "workspace"]), + # Neither: unrestricted, nothing filtered and nothing bound. + (None, None, False, []), + ], + ids=["observer-only", "allowlist-only", "observer-and-allowlist", "unrestricted"], +) +def test_scope_clause_shape( + session_allowlist: list[str] | None, + observer: str | None, + expect_exists: bool, + expected_params: list[str], +) -> None: + clauses, deny = resolve_session_scope_clauses( + "workspace", + None, + session_allowlist, + observer, + models.Message.session_name, + ) + + assert not deny + + sql, params = _compile(select(models.Message.public_id).where(*clauses)) + + assert ("EXISTS" in sql.upper()) is expect_exists + assert ("session_peers" in sql) is expect_exists + assert sorted(params.values()) == expected_params + + +@pytest.mark.parametrize( + ("session_name", "session_allowlist", "observer"), + [ + # An empty allowlist fails closed rather than matching everything. + (None, [], "observer-peer"), + (None, [], None), + # A pinned session the allowlist forbids fails closed. + ("s9", ["s1", "s2"], "observer-peer"), + ], + ids=[ + "empty-allowlist-with-observer", + "empty-allowlist-without-observer", + "pinned-session-not-in-allowlist", + ], +) +def test_scope_fails_closed( + session_name: str | None, + session_allowlist: list[str] | None, + observer: str | None, +) -> None: + clauses, deny = resolve_session_scope_clauses( + "workspace", + session_name, + session_allowlist, + observer, + models.Message.session_name, + ) + + assert deny + assert clauses == [] + + +@pytest.mark.asyncio +async def test_get_observation_context_scope_costs_no_per_session_parameters() -> None: + """The statement's parameter count depends on message_ids, not membership.""" + db = _CapturingDB() + message_ids = [f"msg-{i}" for i in range(5)] + + await get_observation_context( + db, # pyright: ignore[reportArgumentType] + "workspace", + None, + message_ids, + observer="observer-peer", + ) + + assert len(db.statements) == 1 + sql, params = _compile(db.statements[0]) + + # The scope must be applied to *both* the CTE and the outer select β€” a + # materialized list would have cost two parameters per session there. + # Count the subquery's FROM rather than EXISTS: the adjacency check is also + # an EXISTS, so counting those would pass with the CTE's scope missing. + assert sql.count("FROM public.session_peers") == 2 + # Both must correlate to the enclosing `messages` row. An uncorrelated + # subquery compiles just as happily and would silently drop scoping + # instead of enforcing it. + assert sql.count("session_peers.session_name = public.messages.session_name") == 2 + + # Everything bound is either a message id, the workspace, the peer, or the + # Β±1 adjacency window β€” nothing that scales with the peer's session count. + expected = set(message_ids) | {"workspace", "observer-peer", -1, 1} + assert set(params.values()) <= expected + + +@pytest.mark.asyncio +async def test_get_observation_context_denies_without_querying() -> None: + """Fail-closed scopes must not reach the database at all.""" + db = _CapturingDB() + + result = await get_observation_context( + db, # pyright: ignore[reportArgumentType] + "workspace", + None, + ["msg-1"], + observer="observer-peer", + session_allowlist=[], + ) + + assert result == [] + assert db.statements == [] diff --git a/tests/deriver/test_deriver_processing.py b/tests/deriver/test_deriver_processing.py index 6785d159..29a983b5 100644 --- a/tests/deriver/test_deriver_processing.py +++ b/tests/deriver/test_deriver_processing.py @@ -7,7 +7,9 @@ import pytest from src import crud, models from src.config import settings +from src.crud.representation import RepresentationManager from src.deriver.deriver import process_representation_tasks_batch +from src.exceptions import RepresentationSaveError from src.llm import HonchoLLMCallResponse from src.utils.representation import ( ExplicitObservationBase, @@ -70,6 +72,116 @@ class TestDeriverProcessing: assert kwargs["model_config"].stop_sequences == expected_config.stop_sequences assert "llm_settings" not in kwargs + async def test_all_observer_saves_failing_surfaces_failure(self): + """When every observer's save_representation fails, the batch must raise.""" + message = Mock( + id=1, + public_id="msg_1", + session_name="session-1", + workspace_name="workspace-1", + peer_name="alice", + content="hello", + token_count=5, + created_at=datetime.now(timezone.utc), + ) + configuration = Mock() + configuration.reasoning.enabled = True + + mock_response = HonchoLLMCallResponse( + content=PromptRepresentation( + explicit=[ + ExplicitObservationBase(content="The user has a dog named Rover") + ] + ), + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + + failing_save = AsyncMock(side_effect=RuntimeError("429 RESOURCE_EXHAUSTED")) + emitted: list[Any] = [] + with ( + patch( + "src.deriver.deriver.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ), + patch.object(RepresentationManager, "save_representation", failing_save), + patch("src.deriver.deriver.emit", side_effect=emitted.append), + pytest.raises(RepresentationSaveError, match="save_representation failed"), + ): + await process_representation_tasks_batch( + messages=[message], + message_level_configuration=configuration, + observers=["bob"], + observed="alice", + queue_item_message_ids=[1], + ) + + # Telemetry must fire *before* the raise so a total save failure is still + # visible to metrics. Guards against emit() being moved after the raise. + assert emitted, "expected telemetry to be emitted before the raised failure" + assert emitted[-1].observer_count == 0 + assert emitted[-1].failed_observer_count == 1 + + async def test_partial_observer_failure_is_processed_and_surfaced(self): + """When some observers save and one fails, the batch does NOT raise + (saved observers are kept) and the failure is visible via telemetry. + """ + message = Mock( + id=1, + public_id="msg_1", + session_name="session-1", + workspace_name="workspace-1", + peer_name="alice", + content="hello", + token_count=5, + created_at=datetime.now(timezone.utc), + ) + configuration = Mock() + configuration.reasoning.enabled = True + + mock_response = HonchoLLMCallResponse( + content=PromptRepresentation( + explicit=[ + ExplicitObservationBase(content="The user has a dog named Rover") + ] + ), + input_tokens=10, + output_tokens=5, + finish_reasons=["STOP"], + ) + + # bob succeeds, carol fails. + partial_save = AsyncMock( + side_effect=[ + crud.CreateDocumentsResult(), + RuntimeError("429 RESOURCE_EXHAUSTED"), + ] + ) + emitted: list[Any] = [] + with ( + patch( + "src.deriver.deriver.honcho_llm_call", + new_callable=AsyncMock, + return_value=mock_response, + ), + patch.object(RepresentationManager, "save_representation", partial_save), + patch("src.deriver.deriver.emit", side_effect=emitted.append), + ): + await process_representation_tasks_batch( + messages=[message], + message_level_configuration=configuration, + observers=["bob", "carol"], + observed="alice", + queue_item_message_ids=[1], + ) + + assert emitted, "expected a telemetry event to be emitted" + event = emitted[-1] + assert event.observer_count == 1 + assert event.failed_observer_count == 1 + async def test_process_representation_tasks_batch_passes_custom_instructions_into_prompt( self, ) -> None: diff --git a/tests/deriver/test_vector_reconciliation.py b/tests/deriver/test_vector_reconciliation.py index 5e2e4712..0ecd4d5c 100644 --- a/tests/deriver/test_vector_reconciliation.py +++ b/tests/deriver/test_vector_reconciliation.py @@ -551,9 +551,12 @@ class TestReEmbedding: # Mock embedding client to track batch calls batch_call_count = 0 - async def track_batch_embed(contents: list[str]) -> list[list[float]]: + async def track_batch_embed( + contents: list[str], *, on_oversize: str, **_kwargs: object + ) -> list[list[float]]: nonlocal batch_call_count batch_call_count += 1 + assert on_oversize == "truncate" return [[1.0] * 1536 for _ in contents] with patch("src.reconciler.sync_vectors.embedding_client") as mock_embed_client: diff --git a/tests/live_llm/README.md b/tests/live_llm/README.md index 442049bc..9590185e 100644 --- a/tests/live_llm/README.md +++ b/tests/live_llm/README.md @@ -63,10 +63,10 @@ export OPENROUTER_API_KEY="sk-or-v1-..." Coverage by provider: - Anthropic: structured output path, prompt caching metrics, thinking blocks, multi-turn tool replay -- OpenAI GPT-4 class: structured outputs, prompt caching -- OpenAI GPT-5 class (incl. gpt-5.x point-releases): structured outputs, prompt caching, `reasoning_effort`, `max_completion_tokens` routing +- OpenAI GPT-4 class: structured outputs, prompt caching, multi-turn tool replay (null `content` preserved) +- OpenAI GPT-5 class (incl. gpt-5.x point-releases): structured outputs, prompt caching, `reasoning_effort`, `max_completion_tokens` routing, multi-turn tool replay (null `content` preserved) - OpenAI transport β†’ OpenRouter non-reasoning models (e.g. `inception/mercury-2`): non-chat / diffusion architectures must stay on `max_tokens`, no `reasoning_effort`, tool-calling parameter-schema compatibility is the canary for exotic OR-served providers - Gemini 2.5/3.0 classes: structured outputs, cached-content reuse, thought signatures, multi-turn tool replay - Gemini 3.1 class: thinking and tool replay coverage by default; structured-output/caching coverage should only be added once Google documents support for that path -- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, and chunk-to-id mapping for every configured embedding model. `gemini-embedding-2*` is the reason this exists β€” those models collapse a list of bare strings into one document (#745), and only a live call catches it. Also covers first-class `EmbeddingModelConfig.timeout` plumbing (one representative model per transport): configured timeout lands on the SDK client, and a near-zero timeout aborts before the provider answers +- Embeddings (`test_live_embeddings.py`): single embed, batched embed, batch-vs-single alignment, chunk-to-id mapping, and oversize-truncate survival (`on_oversize="truncate"`) for every configured embedding model. `gemini-embedding-2*` is the reason this exists β€” those models collapse a list of bare strings into one document (#745), and only a live call catches it. Also covers first-class `EmbeddingModelConfig.timeout` plumbing (one representative model per transport): configured timeout lands on the SDK client, and a near-zero timeout aborts before the provider answers - OpenAI-compatible embedding providers (e.g. OpenRouter's `google/gemini-embedding-001`): the #932 surface. Those providers reject a base64 embedding request outright (HTTP 400) or answer HTTP 200 with empty data, so the whole matrix fails without `encoding_format="float"`. Real OpenAI accepts base64 happily, so only a third-party provider catches it. Note that OpenRouter load-balances across upstreams, so the base64 failure is per-attempt rather than guaranteed: a retry can land on an endpoint that accepts it. `test_live_openai_float_encoding_matches_base64` covers the other side, that the float switch must not move vectors on real OpenAI diff --git a/tests/live_llm/model_matrix.py b/tests/live_llm/model_matrix.py index 2478c6c3..3a18a84e 100644 --- a/tests/live_llm/model_matrix.py +++ b/tests/live_llm/model_matrix.py @@ -58,6 +58,7 @@ MODEL_FAMILIES: tuple[LiveModelFamily, ...] = ( default_models=("gpt-4.1",), supports_structured_output=True, supports_caching=True, + supports_tool_replay=True, docs_url="https://platform.openai.com/docs/models/gpt-4.1", ), LiveModelFamily( @@ -68,6 +69,7 @@ MODEL_FAMILIES: tuple[LiveModelFamily, ...] = ( supports_structured_output=True, supports_caching=True, supports_reasoning=True, + supports_tool_replay=True, docs_url="https://platform.openai.com/docs/models/gpt-5", ), # OpenAI-compatible transport β†’ OpenRouter-served non-reasoning models. diff --git a/tests/live_llm/test_live_embeddings.py b/tests/live_llm/test_live_embeddings.py index 6ef2b048..7c57a2e3 100644 --- a/tests/live_llm/test_live_embeddings.py +++ b/tests/live_llm/test_live_embeddings.py @@ -197,6 +197,26 @@ async def test_live_openai_float_encoding_matches_base64( ), f"{spec.id}: float encoding diverges from base64 (cosine={similarity:.8f})" +@pytest.mark.asyncio +@pytest.mark.parametrize("spec", ALL_SPECS, ids=lambda spec: spec.id) +async def test_live_batch_embed_truncates_oversize_instead_of_dropping_batch( + spec: LiveEmbeddingSpec, +) -> None: + """on_oversize='truncate' keeps one vector per input when an item exceeds the cap.""" + # Tiny cap so the oversize input stays cheap to tokenize and send. + client = make_embedding_client(spec, max_input_tokens=32) + oversize = " ".join(f"oversize-token-{index}" for index in range(200)) + assert len(client.encoding.encode(oversize)) > client.max_embedding_tokens + + texts = [BATCH_TEXTS[0], oversize, BATCH_TEXTS[1]] + embeddings = await client.simple_batch_embed(texts, on_oversize="truncate") + + assert len(embeddings) == len(texts) + assert all(len(embedding) == spec.dimensions for embedding in embeddings) + # A collapsed or dropped batch would reuse a vector or return fewer. + assert len({tuple(embedding) for embedding in embeddings}) == len(texts) + + @pytest.mark.asyncio @pytest.mark.parametrize("spec", GEMINI_SPECS, ids=lambda spec: spec.id) async def test_live_gemini_batch_embed_survives_batch_split( diff --git a/tests/live_llm/test_live_openai.py b/tests/live_llm/test_live_openai.py index 8b0544ec..b69818c6 100644 --- a/tests/live_llm/test_live_openai.py +++ b/tests/live_llm/test_live_openai.py @@ -2,10 +2,13 @@ from __future__ import annotations import pytest +from src.llm.history_adapters import OpenAIHistoryAdapter from src.llm.request_builder import execute_completion from .conftest import ( StructuredLiveResponse, + execute_local_tool, + favorite_prime_tools, make_backend, make_large_system_prompt, require_provider_key, @@ -30,6 +33,11 @@ _JSON_OBJECT_SPECS = tuple( for spec in get_live_model_specs(provider="openai") if spec.family == "openai_json_object" ) +_TOOL_REPLAY_SPECS = tuple( + spec + for spec in get_live_model_specs(provider="openai") + if spec.supports_tool_replay +) @pytest.mark.asyncio @@ -194,3 +202,83 @@ async def test_live_openai_json_object_structured_output( assert parse_calls == [] assert create_calls, "expected a chat.completions.create call" assert create_calls[0]["kwargs"]["response_format"] == {"type": "json_object"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_spec", _TOOL_REPLAY_SPECS, ids=lambda spec: spec.id) +async def test_live_openai_tool_replay_preserves_null_content( + model_spec: LiveModelSpec, +) -> None: + """Tool-call turns with provider content=null must stay null through + normalize + history replay, and the continuation must still succeed.""" + require_provider_key(model_spec) + # Leave reasoning_effort unset: gpt-5.4 rejects function tools with any + # explicit reasoning_effort other than 'none' on /v1/chat/completions. + backend, config = make_backend(model_spec) + tools = favorite_prime_tools() + adapter = OpenAIHistoryAdapter() + + initial_messages = [ + { + "role": "user", + "content": ( + "Before answering, call the get_favorite_prime tool exactly once. " + "Do not answer with plain text on this turn. " + "After you receive the tool result, answer in one sentence that " + "includes the number and the word 'prime'." + ), + } + ] + + first = await execute_completion( + backend, + config, + messages=initial_messages, + max_tokens=1024, + tools=tools, + tool_choice="required", + ) + + assert first.tool_calls, "OpenAI should issue a tool call in the first turn" + raw_message = first.raw_response.choices[0].message + raw_content = raw_message.content + if raw_content is None: + assert first.content is None + else: + assert first.content == raw_content + + assistant_message = adapter.format_assistant_tool_message(first) + assert assistant_message["content"] is ( + first.content if isinstance(first.content, str) else None + ) + if raw_content is None: + assert assistant_message["content"] is None + + tool_call = first.tool_calls[0] + tool_result = execute_local_tool(tool_call.name, tool_call.input) + replay_messages = initial_messages + [ + assistant_message, + *adapter.format_tool_results( + [ + { + "tool_id": tool_call.id, + "tool_name": tool_call.name, + "result": tool_result, + } + ] + ), + ] + + second = await execute_completion( + backend, + config, + messages=replay_messages, + max_tokens=1024, + tools=tools, + tool_choice="auto", + ) + + assert not second.tool_calls, "continuation should answer without another tool call" + assert isinstance(second.content, str) + assert "13" in second.content + assert "prime" in second.content.lower() diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index fbd8e719..b4df2e66 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -1138,12 +1138,114 @@ async def test_openai_backend_structured_with_tools_uses_create_not_parse() -> N assert "strict" not in call["tools"][0]["function"] +def _tool_call_message( + *, + content: str | None, + reasoning_details: list[Any] | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + content=content, + tool_calls=[ + SimpleNamespace( + id="call_probe", + function=SimpleNamespace( + name="search", + arguments='{"query":"honcho"}', + ), + ) + ], + reasoning_details=reasoning_details or [], + reasoning_content=None, + ) + + +def _completion_response( + message: SimpleNamespace, finish_reason: str +) -> SimpleNamespace: + return SimpleNamespace( + choices=[SimpleNamespace(finish_reason=finish_reason, message=message)], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + + +def test_openai_normalize_preserves_null_content_on_tool_call_turns() -> None: + reasoning_details = [ + { + "type": "reasoning.encrypted", + "data": "opaque", + "format": "openai-responses-v1", + "id": "binding", + "index": 0, + } + ] + response = _completion_response( + _tool_call_message(content=None, reasoning_details=reasoning_details), + "tool_calls", + ) + + result = OpenAIBackend(Mock())._normalize_response( # pyright: ignore[reportPrivateUsage] + response + ) + + assert result.content is None + assert result.tool_calls[0].id == "call_probe" + assert result.tool_calls[0].name == "search" + assert result.tool_calls[0].input == {"query": "honcho"} + assert result.reasoning_details == reasoning_details + + +def test_openai_normalize_coerces_null_content_without_tool_calls() -> None: + message = SimpleNamespace( + content=None, + tool_calls=[], + reasoning_details=[], + reasoning_content=None, + ) + response = _completion_response(message, "stop") + + result = OpenAIBackend(Mock())._normalize_response( # pyright: ignore[reportPrivateUsage] + response + ) + + assert result.content == "" + + +def test_openai_normalize_keeps_empty_string_content_on_tool_call_turns() -> None: + response = _completion_response( + _tool_call_message(content=""), + "tool_calls", + ) + + result = OpenAIBackend(Mock())._normalize_response( # pyright: ignore[reportPrivateUsage] + response + ) + + assert result.content == "" + + +def test_openai_normalize_content_override_is_authoritative() -> None: + response = _completion_response( + _tool_call_message(content=None), + "tool_calls", + ) + + result = OpenAIBackend(Mock())._normalize_response( # pyright: ignore[reportPrivateUsage] + response, content_override="override" + ) + + assert result.content == "override" + + @pytest.mark.asyncio async def test_openai_backend_structured_with_tools_skips_parsing_tool_call_turn() -> ( None ): """A tool-call turn under tools + response_format must not attempt JSON - parsing (its content is empty and _parse_or_repair raises on that).""" + parsing (provider content is null and _parse_or_repair raises on that).""" client = Mock() client.chat.completions.create = AsyncMock( return_value=SimpleNamespace( @@ -1183,7 +1285,7 @@ async def test_openai_backend_structured_with_tools_skips_parsing_tool_call_turn response_format=_StructuredResponse, ) - assert result.content == "" # raw empty text, not a parsed model + assert result.content is None # provider null, not a parsed model assert result.tool_calls[0].name == "search" assert result.tool_calls[0].input == {"query": "honcho"} diff --git a/tests/llm/test_embedding_client.py b/tests/llm/test_embedding_client.py index 9df80be1..7fd9237d 100644 --- a/tests/llm/test_embedding_client.py +++ b/tests/llm/test_embedding_client.py @@ -13,6 +13,8 @@ from src.config import ( ) from src.embedding_client import ( BatchItem, + EmbeddingClient, + EmbeddingTokenLimitError, _EmbeddingClient, # pyright: ignore[reportPrivateUsage] ) @@ -910,6 +912,131 @@ async def test_simple_batch_embed_rejects_oversized_input( await client.simple_batch_embed([too_long]) +@pytest.mark.asyncio +async def test_simple_batch_embed_truncates_oversize_when_requested( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_oversize='truncate' embeds a prefix instead of failing the batch.""" + fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4) + + class FakeOpenAIClient: + def __init__(self, *, api_key: str | None, base_url: str | None) -> None: + self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings + + monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient) + + client = _EmbeddingClient( + EmbeddingModelConfig( + transport="openai", + model="text-embedding-3-small", + api_key="test-key", + base_url=None, + ), + vector_dimensions=4, + max_input_tokens=10, + max_tokens_per_request=1000, + send_dimensions=False, + ) + + short = "hello" + too_long = ("word " * 50).strip() + assert len(client.encoding.encode(too_long)) > client.max_embedding_tokens + + out = await client.simple_batch_embed([short, too_long], on_oversize="truncate") + + assert len(out) == 2 + assert fake_embeddings.calls, "expected a provider call after truncation" + received = fake_embeddings.calls[0]["input"] + assert received[0] == short + truncated = received[1] + assert isinstance(truncated, str) + assert truncated != too_long + assert len(client.encoding.encode(truncated)) <= client.max_embedding_tokens + + +@pytest.mark.asyncio +async def test_simple_batch_embed_truncate_reencodes_until_under_cap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """decode(ids[:n]) can re-encode past n; truncate must re-verify the count.""" + fake_embeddings = FakeOpenAIEmbeddingsAPI([0.1] * 4) + + class FakeOpenAIClient: + def __init__(self, *, api_key: str | None, base_url: str | None) -> None: + self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings + + monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient) + + client = _EmbeddingClient( + EmbeddingModelConfig( + transport="openai", + model="text-embedding-3-small", + api_key="test-key", + base_url=None, + ), + vector_dimensions=4, + max_input_tokens=10, + max_tokens_per_request=1000, + send_dimensions=False, + ) + + encode_calls = {"n": 0} + + def encode(text: str) -> list[int]: + encode_calls["n"] += 1 + if text.startswith("LONG"): + # 1: original oversize; 2: still over after first slice; 3+: fits. + if encode_calls["n"] == 1: + return list(range(20)) + if encode_calls["n"] == 2: + return list(range(12)) + return list(range(8)) + return [1] + + def decode(ids: list[int]) -> str: + return "LONG" + "x" * len(ids) + + monkeypatch.setattr(client.encoding, "encode", encode) + monkeypatch.setattr(client.encoding, "decode", decode) + + out = await client.simple_batch_embed(["LONG-input"], on_oversize="truncate") + + assert len(out) == 1 + received = fake_embeddings.calls[0]["input"][0] + assert isinstance(received, str) + # The provider must see the post-loop text, which encodes to 8 (<= cap). + assert encode(received) == list(range(8)) + assert encode_calls["n"] >= 3 + + +@pytest.mark.asyncio +async def test_public_embedding_client_forwards_on_oversize( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The singleton wrapper must forward on_oversize to the inner client.""" + captured: dict[str, object] = {} + + class FakeInner: + async def simple_batch_embed( + self, + texts: list[str], + *, + on_oversize: str = "raise", + ) -> list[list[float]]: + captured["texts"] = texts + captured["on_oversize"] = on_oversize + return [[0.1]] + + wrapper = EmbeddingClient() + monkeypatch.setattr(wrapper, "_get_client", lambda: FakeInner()) + + out = await wrapper.simple_batch_embed(["hi"], on_oversize="truncate") + + assert out == [[0.1]] + assert captured["texts"] == ["hi"] + assert captured["on_oversize"] == "truncate" + + def test_prepare_chunks_returns_ordered_chunks( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1047,3 +1174,70 @@ async def test_gemini_process_batch_wraps_contents_as_content_part( assert all(isinstance(c, genai_types.Content) for c in contents) assert contents[0].parts[0].text == "hello" assert contents[1].parts[0].text == "world" + + +# --- Token-limit classification (issue #568) ------------------------------- +# +# Only genuine "content too long" conditions may raise +# EmbeddingTokenLimitError. Provider/config failures must stay plain +# ValueError so callers don't rewrite them as token-limit errors. + + +def test_embedding_token_limit_error_is_value_error() -> None: + """Subclassing ValueError keeps pre-existing broad handlers working.""" + assert issubclass(EmbeddingTokenLimitError, ValueError) + + +@pytest.mark.asyncio +async def test_embed_raises_token_limit_error_before_calling_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, fake_embeddings = _build_openai_client( + monkeypatch, + embedding=[0.1, 0.2], + model="text-embedding-3-small", + send_dimensions=False, + vector_dimensions=2, + ) + + with pytest.raises(EmbeddingTokenLimitError): + await client.embed("word " * 20_000) + + assert fake_embeddings.calls == [], "provider must not be called on oversize input" + + +@pytest.mark.asyncio +async def test_simple_batch_embed_raises_token_limit_error_before_calling_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, fake_embeddings = _build_openai_client( + monkeypatch, + embedding=[0.1, 0.2], + model="text-embedding-3-small", + send_dimensions=False, + vector_dimensions=2, + ) + + with pytest.raises(EmbeddingTokenLimitError): + await client.simple_batch_embed(["fine", "word " * 20_000]) + + assert fake_embeddings.calls == [], "provider must not be called on oversize input" + + +@pytest.mark.asyncio +async def test_provider_dimension_mismatch_is_not_a_token_limit_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A wrong-width vector is a provider/config fault, not an oversized input.""" + client, _ = _build_openai_client( + monkeypatch, + embedding=[0.1, 0.2, 0.3], # 3 wide, client expects 2 + model="text-embedding-3-small", + send_dimensions=False, + vector_dimensions=2, + ) + + with pytest.raises(ValueError) as excinfo: + await client.embed("short query") + + assert not isinstance(excinfo.value, EmbeddingTokenLimitError) diff --git a/tests/llm/test_history_adapters.py b/tests/llm/test_history_adapters.py index 6881df6a..343cee9c 100644 --- a/tests/llm/test_history_adapters.py +++ b/tests/llm/test_history_adapters.py @@ -1,3 +1,5 @@ +import pytest + from src.llm.backend import CompletionResult, ToolCallResult from src.llm.history_adapters import ( AnthropicHistoryAdapter, @@ -65,3 +67,77 @@ def test_openai_history_adapter_preserves_reasoning_details() -> None: assert message["role"] == "assistant" assert message["reasoning_details"] == [{"type": "reasoning", "content": "step 1"}] assert message["tool_calls"][0]["function"]["name"] == "search" + + +def test_openai_history_adapter_preserves_thinking_content() -> None: + adapter = OpenAIHistoryAdapter() + result = CompletionResult( + content="Calling a tool", + thinking_content="step 1", + tool_calls=[ + ToolCallResult(id="tool_1", name="search", input={"query": "honcho"}) + ], + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["reasoning_content"] == "step 1" + assert "reasoning_details" not in message + + +def test_openai_history_adapter_prefers_reasoning_details() -> None: + adapter = OpenAIHistoryAdapter() + reasoning_details = [{"type": "reasoning", "content": "step 1"}] + result = CompletionResult( + content="Calling a tool", + thinking_content="duplicate step 1", + reasoning_details=reasoning_details, + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["reasoning_details"] == reasoning_details + assert "reasoning_content" not in message + + +@pytest.mark.parametrize("thinking_content", [None, ""]) +def test_openai_history_adapter_omits_empty_thinking_content( + thinking_content: str | None, +) -> None: + adapter = OpenAIHistoryAdapter() + result = CompletionResult( + content="Calling a tool", + thinking_content=thinking_content, + ) + + message = adapter.format_assistant_tool_message(result) + + assert "reasoning_content" not in message + + +def test_openai_history_adapter_preserves_null_content_on_tool_call_turns() -> None: + adapter = OpenAIHistoryAdapter() + reasoning_details = [ + { + "type": "reasoning.encrypted", + "data": "opaque", + "format": "openai-responses-v1", + "id": "binding", + "index": 0, + } + ] + result = CompletionResult( + content=None, + reasoning_details=reasoning_details, + tool_calls=[ + ToolCallResult(id="call_probe", name="search", input={"query": "honcho"}) + ], + ) + + message = adapter.format_assistant_tool_message(result) + + assert message["content"] is None + assert message["reasoning_details"] == reasoning_details + assert message["tool_calls"][0]["id"] == "call_probe" + assert message["tool_calls"][0]["function"]["name"] == "search" + assert message["tool_calls"][0]["function"]["arguments"] == '{"query": "honcho"}' diff --git a/tests/llm/test_tool_loop_reasoning_content.py b/tests/llm/test_tool_loop_reasoning_content.py new file mode 100644 index 00000000..53b65fe2 --- /dev/null +++ b/tests/llm/test_tool_loop_reasoning_content.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any, cast +from unittest.mock import patch + +import pytest + +from src.config import ModelConfig +from src.llm import tool_loop +from src.llm.runtime import AttemptPlan +from src.llm.tool_loop import execute_tool_loop +from src.llm.types import HonchoLLMCallResponse, ProviderClient + + +def _make_plan() -> AttemptPlan: + return AttemptPlan( + provider="openai", + model="deepseek-v4-pro", + client=cast(ProviderClient, object()), + thinking_budget_tokens=None, + reasoning_effort=None, + selected_config=ModelConfig( + model="deepseek-v4-pro", + transport="openai", + ), + attempt=1, + retry_attempts=1, + is_fallback=False, + ) + + +@pytest.mark.asyncio +async def test_tool_loop_replays_reasoning_content_on_continuation() -> None: + calls: list[list[dict[str, Any]]] = [] + responses = iter( + [ + HonchoLLMCallResponse( + content="", + output_tokens=5, + finish_reasons=["tool_calls"], + tool_calls_made=[ + { + "id": "call_1", + "name": "search", + "input": {"query": "honcho"}, + } + ], + thinking_content="DeepSeek reasoning", + ), + HonchoLLMCallResponse( + content="done", + output_tokens=3, + finish_reasons=["stop"], + tool_calls_made=[], + ), + ] + ) + + async def fake_call(*_args: Any, **kwargs: Any) -> HonchoLLMCallResponse[Any]: + calls.append(deepcopy(kwargs["messages"])) + return next(responses) + + async def execute_search(_name: str, _input: dict[str, Any]) -> str: + return "result" + + with patch.object(tool_loop, "honcho_llm_call_inner", new=fake_call): + result = await execute_tool_loop( + prompt="hi", + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + tools=[ + { + "name": "search", + "description": "Search", + "input_schema": {"type": "object"}, + } + ], + tool_choice="auto", + tool_executor=execute_search, + max_tool_iterations=5, + response_model=None, + json_mode=False, + temperature=None, + stop_seqs=None, + verbosity=None, + enable_retry=False, + retry_attempts=1, + max_input_tokens=None, + get_attempt_plan=_make_plan, + before_retry_callback=lambda _retry_state: None, + stream_final=False, + telemetry=None, + ) + + assert isinstance(result, HonchoLLMCallResponse) + assert len(calls) == 2 + assert calls[1][1]["reasoning_content"] == "DeepSeek reasoning" + assert calls[1][1]["tool_calls"][0]["function"]["name"] == "search" + assert calls[1][2] == { + "role": "tool", + "tool_call_id": "call_1", + "content": "result", + } + + +@pytest.mark.asyncio +async def test_tool_loop_replays_null_assistant_content_on_continuation() -> None: + calls: list[list[dict[str, Any]]] = [] + responses = iter( + [ + HonchoLLMCallResponse( + content=None, + output_tokens=5, + finish_reasons=["tool_calls"], + tool_calls_made=[ + { + "id": "call_1", + "name": "search", + "input": {"query": "honcho"}, + } + ], + reasoning_details=[ + { + "type": "reasoning.encrypted", + "data": "opaque", + "format": "openai-responses-v1", + "id": "binding", + "index": 0, + } + ], + ), + HonchoLLMCallResponse( + content="done", + output_tokens=3, + finish_reasons=["stop"], + tool_calls_made=[], + ), + ] + ) + + async def fake_call(*_args: Any, **kwargs: Any) -> HonchoLLMCallResponse[Any]: + calls.append(deepcopy(kwargs["messages"])) + return next(responses) + + async def execute_search(_name: str, _input: dict[str, Any]) -> str: + return "result" + + with patch.object(tool_loop, "honcho_llm_call_inner", new=fake_call): + result = await execute_tool_loop( + prompt="hi", + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + tools=[ + { + "name": "search", + "description": "Search", + "input_schema": {"type": "object"}, + } + ], + tool_choice="auto", + tool_executor=execute_search, + max_tool_iterations=5, + response_model=None, + json_mode=False, + temperature=None, + stop_seqs=None, + verbosity=None, + enable_retry=False, + retry_attempts=1, + max_input_tokens=None, + get_attempt_plan=_make_plan, + before_retry_callback=lambda _retry_state: None, + stream_final=False, + telemetry=None, + ) + + assert isinstance(result, HonchoLLMCallResponse) + assert len(calls) == 2 + assert calls[1][1]["content"] is None + assert calls[1][1]["reasoning_details"][0]["data"] == "opaque" + assert calls[1][1]["tool_calls"][0]["function"]["name"] == "search" diff --git a/tests/reconciler/test_pending_backlog_gauge.py b/tests/reconciler/test_pending_backlog_gauge.py new file mode 100644 index 00000000..226a71e8 --- /dev/null +++ b/tests/reconciler/test_pending_backlog_gauge.py @@ -0,0 +1,86 @@ +"""The pending-embeddings backlog gauge must be refreshed per-replica. + +These tests pin both halves: the scheduler loop drives the refresh, and the +queue-driven reconciliation cycle does not. +""" +# region ai +# ``message_embeddings_pending`` reports a DB-global count, so it is the one gauge +# here whose value is service-wide rather than per-process. It is also zero- +# initialized at startup, which makes a missing refresh actively harmful: a replica +# that never measured the backlog would export a confident, permanently-healthy 0. +# So the count is driven from ``ReconcilerScheduler._scheduler_loop`` (runs on every +# replica, every interval), NOT from ``run_vector_reconciliation_cycle`` (runs off +# the queue behind work-unit dedup, so exactly one replica per cycle executes it). +# endregion + +import asyncio + +import pytest + +from src.reconciler import scheduler as scheduler_module +from src.reconciler import sync_vectors +from src.reconciler.scheduler import ReconcilerScheduler + + +@pytest.fixture(autouse=True) +def _reset_scheduler_singleton(): # pyright: ignore[reportUnusedFunction] + ReconcilerScheduler.reset_singleton() + yield + ReconcilerScheduler.reset_singleton() + + +async def test_scheduler_loop_refreshes_backlog_gauge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every scheduler iteration refreshes the gauge, on every replica. + + Patched at the scheduler's own reference so this asserts the call site, not + just that the function exists. + """ + calls = 0 + refreshed = asyncio.Event() + + async def _fake_refresh() -> None: + nonlocal calls + calls += 1 + refreshed.set() + + # region ai + # Patched onto the class, so it is invoked as a bound method β€” it needs the + # ``self`` parameter or the call raises TypeError, which ``_scheduler_loop`` would + # then swallow, leaving this guard silently inert. + # endregion + async def _never_enqueue(_self: object, _task: object) -> bool: + return False + + monkeypatch.setattr( + scheduler_module, "record_pending_embeddings_backlog", _fake_refresh + ) + monkeypatch.setattr( + ReconcilerScheduler, "_try_enqueue_task", _never_enqueue, raising=True + ) + + scheduler = ReconcilerScheduler() + await scheduler.start() + try: + await asyncio.wait_for(refreshed.wait(), timeout=5.0) + finally: + await scheduler.shutdown() + + assert calls >= 1, "scheduler loop never refreshed the backlog gauge" + + +def test_reconciliation_cycle_does_not_drive_the_gauge() -> None: + """The queue-driven cycle must not be the thing that sets the gauge.""" + # region ai + # If the refresh moves back into ``run_vector_reconciliation_cycle``, only the + # replica that wins the ``sync_vectors`` work unit would ever measure the backlog, + # and the zero-init would go back to lying on all the others. + # + # Structural guard: the cycle is a long DB-driven coroutine, so this inspects the + # global names it references rather than executing it. + # endregion + assert hasattr(sync_vectors, "record_pending_embeddings_backlog") + + referenced = sync_vectors.run_vector_reconciliation_cycle.__code__.co_names + assert "record_pending_embeddings_backlog" not in referenced diff --git a/tests/routes/test_scope_reads.py b/tests/routes/test_scope_reads.py index eafdecaf..23eb8d5f 100644 --- a/tests/routes/test_scope_reads.py +++ b/tests/routes/test_scope_reads.py @@ -464,6 +464,117 @@ class TestChatWithScope: assert set(kwargs["session_allowlist"]) == {session_a, session_b} +class TestWorkspaceChatWithScope: + """Workspace chat has no observer to swap: `scope` is always an allowlist.""" + + def _chat(self, client: TestClient, workspace: Workspace, body: dict[str, Any]): + return client.post( + f"/v3/workspaces/{workspace.name}/chat", + json={"query": "what do you know?", **body}, + ) + + def test_unknown_scope_404( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + assert ( + self._chat(client, workspace, {"scope": str(generate_nanoid())}).status_code + == 404 + ) + + def test_empty_scope_list_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + assert self._chat(client, workspace, {"scope": []}).status_code == 422 + + def test_scope_plus_session_id_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + assert ( + self._chat( + client, workspace, {"scope": scope_name, "session_id": "s1"} + ).status_code + == 422 + ) + + def test_peer_scoped_jwt_401( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=workspace.name, p=peer.name))}" + ) + assert self._chat(client, workspace, {"scope": scope_name}).status_code == 401 + + def test_single_scope_passes_member_allowlist( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + workspace, _ = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_name = _create_session(client, workspace.name) + _add_sessions_to_scope(client, workspace.name, scope_name, [session_name]) + + resp = self._chat(client, workspace, {"scope": scope_name}) + assert resp.status_code == 200 + + kwargs = mock_llm_call_functions["workspace_chat"].await_args.kwargs + assert kwargs["session_allowlist"] == [session_name] + + def test_scope_list_passes_union_allowlist( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + workspace, _ = sample_data + scope_a = str(generate_nanoid()) + scope_b = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_a) + _create_scope(client, workspace.name, scope_b) + session_a = _create_session(client, workspace.name) + session_b = _create_session(client, workspace.name) + _add_sessions_to_scope(client, workspace.name, scope_a, [session_a]) + _add_sessions_to_scope(client, workspace.name, scope_b, [session_b]) + + resp = self._chat(client, workspace, {"scope": [scope_a, scope_b]}) + assert resp.status_code == 200 + + kwargs = mock_llm_call_functions["workspace_chat"].await_args.kwargs + assert set(kwargs["session_allowlist"]) == {session_a, session_b} + + def test_empty_scope_fails_closed( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + workspace, _ = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + + resp = self._chat(client, workspace, {"scope": scope_name}) + assert resp.status_code == 200 + + kwargs = mock_llm_call_functions["workspace_chat"].await_args.kwargs + assert kwargs["session_allowlist"] == [] + + class TestWorkspaceSearchWithScope: def _seed_message( self, client: TestClient, workspace_name: str, session_name: str, peer: Peer diff --git a/tests/sdk/test_conclusions.py b/tests/sdk/test_conclusions.py index 0ce77338..db34bab8 100644 --- a/tests/sdk/test_conclusions.py +++ b/tests/sdk/test_conclusions.py @@ -6,7 +6,7 @@ from sdks.python.src.honcho.client import Honcho from sdks.python.src.honcho.conclusions import ( Conclusion, ConclusionCreateParams, - ConclusionScope, + ConclusionsView, WorkspaceConclusions, ) from sdks.python.src.honcho.http import NotFoundError @@ -36,7 +36,7 @@ async def test_observation_create_single( # Get observation scope for observer -> target obs_scope = observer.conclusions_of(target) - assert isinstance(obs_scope, ConclusionScope) + assert isinstance(obs_scope, ConclusionsView) # Create a single observation created = await obs_scope.aio.create( @@ -70,7 +70,7 @@ async def test_observation_create_single( # Get observation scope for observer -> target obs_scope = observer.conclusions_of(target) - assert isinstance(obs_scope, ConclusionScope) + assert isinstance(obs_scope, ConclusionsView) # Create a single observation created = obs_scope.create( @@ -608,7 +608,7 @@ async def test_self_observation_create( # Get self-observation scope obs_scope = peer.conclusions - assert isinstance(obs_scope, ConclusionScope) + assert isinstance(obs_scope, ConclusionsView) assert obs_scope.observer == peer.id assert obs_scope.observed == peer.id @@ -629,7 +629,7 @@ async def test_self_observation_create( # Get self-observation scope obs_scope = peer.conclusions - assert isinstance(obs_scope, ConclusionScope) + assert isinstance(obs_scope, ConclusionsView) assert obs_scope.observer == peer.id assert obs_scope.observed == peer.id @@ -982,7 +982,7 @@ async def test_list_rejects_reserved_scope_filter_keys( target = await honcho_client.aio.peer(id="test-obs-reserved-list-target") obs_scope = observer.conclusions_of(target) for key in reserved: - with pytest.raises(ValueError, match="managed by this conclusion scope"): + with pytest.raises(ValueError, match="managed by this conclusions view"): await obs_scope.aio.list(filters={key: "someone-else"}) # A non-reserved filter (level) is allowed through. await obs_scope.aio.list(filters={"level": "explicit"}) @@ -991,7 +991,7 @@ async def test_list_rejects_reserved_scope_filter_keys( target = honcho_client.peer(id="test-obs-reserved-list-target") obs_scope = observer.conclusions_of(target) for key in reserved: - with pytest.raises(ValueError, match="managed by this conclusion scope"): + with pytest.raises(ValueError, match="managed by this conclusions view"): obs_scope.list(filters={key: "someone-else"}) obs_scope.list(filters={"level": "explicit"}) @@ -1013,7 +1013,7 @@ async def test_query_rejects_reserved_scope_filter_keys( target = await honcho_client.aio.peer(id="test-obs-reserved-query-target") obs_scope = observer.conclusions_of(target) for key in reserved: - with pytest.raises(ValueError, match="managed by this conclusion scope"): + with pytest.raises(ValueError, match="managed by this conclusions view"): await obs_scope.aio.query("q", filters={key: "someone-else"}) # session_id is a normal filter for query (no dedicated param) β€” allowed. await obs_scope.aio.query("q", filters={"session_id": "some-session"}) @@ -1022,7 +1022,7 @@ async def test_query_rejects_reserved_scope_filter_keys( target = honcho_client.peer(id="test-obs-reserved-query-target") obs_scope = observer.conclusions_of(target) for key in reserved: - with pytest.raises(ValueError, match="managed by this conclusion scope"): + with pytest.raises(ValueError, match="managed by this conclusions view"): obs_scope.query("q", filters={key: "someone-else"}) obs_scope.query("q", filters={"session_id": "some-session"}) diff --git a/tests/sdk/test_scope_options.py b/tests/sdk/test_scope_options.py new file mode 100644 index 00000000..fddfe51d --- /dev/null +++ b/tests/sdk/test_scope_options.py @@ -0,0 +1,236 @@ +"""Unit tests for the SDK's scope / session-allowlist option handling. + +Pure logic β€” no server, no database. These pin the wire translation the server +expects, so a rename or a shape change fails here rather than as a 422 at runtime. +""" + +import sys +from pathlib import Path + +import pytest + +# Add the SDK src to the path to allow imports +sdk_src_path = Path(__file__).parent.parent.parent / "sdks" / "python" / "src" +sys.path.insert(0, str(sdk_src_path)) + +from sdks.python.src.honcho.utils.scopes import ( # noqa: E402 + MAX_SCOPES_PER_OPTION, + MAX_SESSION_ALLOWLIST_ENTRIES, + MAX_SESSIONS_PER_ADD, + resolve_scope_membership, + resolve_scope_option, + resolve_scope_session, + scope_context_fields, + scope_recall_fields, + validate_scope_id, +) + + +def context_fields(**overrides: object) -> dict[str, object]: + """Call scope_context_fields with the neutral defaults filled in.""" + kwargs: dict[str, object] = { + "scope": None, + "sessions": None, + "peer_target": "user", + "peer_perspective": None, + "limit_to_session": False, + } + kwargs.update(overrides) + return scope_context_fields(**kwargs) # pyright: ignore[reportArgumentType] + + +class TestValidateScopeId: + def test_accepts_a_plain_name(self): + assert validate_scope_id("therapy") == "therapy" + + def test_rejects_the_reserved_prefix_by_name(self): + # 'scope.therapy' violates both the prefix rule and the charset. The + # prefix message is the actionable one, so it must be the one raised. + with pytest.raises(ValueError, match="reserved prefix"): + validate_scope_id("scope.therapy") + + def test_rejects_characters_outside_the_charset(self): + with pytest.raises(ValueError, match="must match pattern"): + validate_scope_id("my scope") + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="between 1 and"): + validate_scope_id("") + + def test_rejects_a_name_that_leaves_no_room_for_the_prefix(self): + # 512 - len("scope.") is the ceiling: the server stores the name prefixed + # into a 512-character peer name. + with pytest.raises(ValueError, match="between 1 and"): + validate_scope_id("a" * 507) + + +class TestResolveScopeOption: + def test_a_single_scope_stays_a_string(self): + # The shapes are not interchangeable to the server: one scope reads that + # scope's own view, a list restricts to the union of member sessions. + assert resolve_scope_option("therapy") == "therapy" + + def test_a_sequence_becomes_a_list(self): + assert resolve_scope_option(["therapy", "work"]) == ["therapy", "work"] + + def test_rejects_an_empty_sequence(self): + with pytest.raises(ValueError, match="at least one scope"): + resolve_scope_option([]) + + def test_rejects_an_over_cap_sequence(self): + with pytest.raises(ValueError, match="at most"): + resolve_scope_option([f"s{i}" for i in range(MAX_SCOPES_PER_OPTION + 1)]) + + +class TestScopeRecallFields: + def test_neither_option_contributes_nothing(self): + assert scope_recall_fields(scope=None, sessions=None) == {} + + def test_sessions_becomes_a_session_id_filter(self): + # `sessions` is sugar. It must never reach the wire as its own key β€” + # the server rejects unknown keys with a 422. + fields = scope_recall_fields(scope=None, sessions=["a", "b"]) + assert fields == {"filters": {"session_id": ["a", "b"]}} + assert "sessions" not in fields + + def test_scope_passes_through_under_its_own_key(self): + assert scope_recall_fields(scope="therapy", sessions=None) == { + "scope": "therapy" + } + + def test_scope_and_sessions_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + scope_recall_fields(scope="therapy", sessions=["a"]) + + def test_scope_and_a_single_session_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + scope_recall_fields(scope="therapy", sessions=None, session_id="a") + + def test_sessions_composes_with_a_single_session(self): + # Unlike `scope`, an allowlist may accompany a session_id β€” the server + # only requires that the session be inside the allowlist. + assert scope_recall_fields(scope=None, sessions=["a", "b"], session_id="a") == { + "filters": {"session_id": ["a", "b"]} + } + + def test_rejects_an_empty_allowlist(self): + # An empty allowlist is fail-closed server-side (recalls nothing), which + # is never what `sessions=[]` intends. + with pytest.raises(ValueError, match="at least one session"): + scope_recall_fields(scope=None, sessions=[]) + + def test_rejects_an_over_cap_allowlist(self): + with pytest.raises(ValueError, match="at most"): + scope_recall_fields( + scope=None, + sessions=[f"s{i}" for i in range(MAX_SESSION_ALLOWLIST_ENTRIES + 1)], + ) + + def test_resolves_objects_with_an_id(self): + class FakeSession: + id: str = "session-a" + + fields = scope_recall_fields(scope=None, sessions=[FakeSession()]) # pyright: ignore[reportArgumentType] + assert fields == {"filters": {"session_id": ["session-a"]}} + + +class TestScopeContextFields: + """The context route takes these as query params, not as a `filters` body.""" + + def test_neither_option_contributes_nothing(self): + assert context_fields() == {} + + def test_scope_passes_through(self): + assert context_fields(scope="therapy") == {"scope": "therapy"} + + def test_sessions_stays_a_plain_list(self): + # Not wrapped in `filters` β€” this route reads a repeated query parameter. + assert context_fields(sessions=["a", "b"]) == {"sessions": ["a", "b"]} + + def test_scope_and_peer_perspective_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + context_fields(scope="therapy", peer_perspective="assistant") + + def test_scope_and_sessions_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + context_fields(scope="therapy", sessions=["a"]) + + def test_sessions_and_limit_to_session_are_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + context_fields(sessions=["a"], limit_to_session=True) + + @pytest.mark.parametrize("option", [{"scope": "therapy"}, {"sessions": ["a"]}]) + def test_either_option_requires_a_peer_target(self, option: dict[str, object]): + # Both only reach the representation, and there is none without a target. + # Refused rather than accepted and silently ignored. + with pytest.raises(ValueError, match="peer_target"): + context_fields(peer_target=None, **option) + + def test_limit_to_session_alone_is_untouched(self): + # The neutral case must not start emitting a scope/sessions key. + assert context_fields(limit_to_session=True) == {} + + +class TestResolveScopeMembership: + def test_resolves_ids_and_objects_in_order(self): + class FakeSession: + id: str = "session-b" + + assert resolve_scope_membership(["session-a", FakeSession()]) == [ # pyright: ignore[reportArgumentType] + "session-a", + "session-b", + ] + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="At least one session"): + resolve_scope_membership([]) + + def test_rejects_over_the_per_call_cap_rather_than_chunking(self): + with pytest.raises(ValueError, match="At most"): + resolve_scope_membership([f"s{i}" for i in range(MAX_SESSIONS_PER_ADD + 1)]) + + def test_rejects_a_malformed_id(self): + with pytest.raises(ValueError, match="must match pattern"): + resolve_scope_membership(["ok-session", "valid-session?typo"]) + + +class TestResolveScopeSession: + """Guards the ID that gets interpolated into a scope membership URL path.""" + + def test_resolves_a_plain_id(self): + assert resolve_scope_session("session-a") == "session-a" + + def test_resolves_an_object(self): + class FakeSession: + id: str = "session-a" + + assert resolve_scope_session(FakeSession()) == "session-a" # pyright: ignore[reportArgumentType] + + @pytest.mark.parametrize( + "malformed", + [ + "valid-session?typo", # would address `valid-session` + a query string + "valid-session/../other", # would climb the path + "valid session", + "", + ], + ) + def test_rejects_ids_that_would_alter_the_request_path(self, malformed: str): + # This value lands in a DELETE path. An unvalidated id silently changes + # which session is removed, and removal triggers reconciliation against + # whatever it hits. + with pytest.raises(ValueError, match="Session ID"): + resolve_scope_session(malformed) + + +def test_deprecated_conclusion_scope_aliases_still_resolve(): + """The rename keeps working for callers on the old name.""" + from sdks.python.src.honcho import ( + ConclusionScope, + ConclusionScopeAio, + ConclusionsView, + ConclusionsViewAio, + ) + + assert ConclusionScope is ConclusionsView + assert ConclusionScopeAio is ConclusionsViewAio diff --git a/tests/telemetry/test_db_connection_metrics.py b/tests/telemetry/test_db_connection_metrics.py new file mode 100644 index 00000000..c9f4cfb8 --- /dev/null +++ b/tests/telemetry/test_db_connection_metrics.py @@ -0,0 +1,94 @@ +"""Tests for the physical-DB-connection metrics. + +``db_connections_open`` (gauge) and ``db_connections_established`` (counter) are +driven by SQLAlchemy connection-lifecycle events rather than the pool object, so +they report real connections under EVERY pool class β€” including ``NullPool``, whose +pool keeps no records for the scrape-time ``db_pool_connections`` collector to read. + +Asserts two properties: +- zero-init β€” resolving a labeled child materializes it at 0, so an absent series + means a broken scrape rather than "no connections"; +- ``DBConnectionTracker`` semantics β€” increment once per connect, decrement at most + once per connection (marker-guarded, so it can't leak upward or go negative), and + the establishment counter is monotonic (closes never decrement it). +""" + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from prometheus_client import REGISTRY + +from src.db import DBConnectionTracker +from src.telemetry.prometheus.metrics import ( + db_connections_established_counter, + db_connections_open_gauge, +) + + +@pytest.fixture +def ns(monkeypatch: pytest.MonkeyPatch) -> str: + """Enable metrics under a namespace unique to this test. + + The process-global REGISTRY keeps a materialized child for the rest of the + session, so a shared namespace would let one test satisfy another's + presence/absence assertions independently of the code under test. + """ + namespace = f"test_db_conn_{uuid4().hex[:8]}" + monkeypatch.setattr("src.config.settings.METRICS.ENABLED", True) + monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", namespace) + return namespace + + +def sample(name: str, namespace: str, **labels: str) -> float | None: + """Value of a series if it exists, else None. Never materializes it.""" + return REGISTRY.get_sample_value(name, {"namespace": namespace, **labels}) + + +def test_connection_children_zero_init(ns: str) -> None: + """Resolving the labeled children materializes both series at 0.""" + db_connections_open_gauge.labels(instance_type="api") + db_connections_established_counter.labels(instance_type="api") + + assert sample("db_connections_open", ns, instance_type="api") == 0.0 + # prometheus_client appends _total to counter names + assert sample("db_connections_established_total", ns, instance_type="api") == 0.0 + + +def test_tracker_inc_dec_and_counter_monotonic(ns: str) -> None: + """connect increments both metrics; close decrements only the gauge.""" + open_child = db_connections_open_gauge.labels(instance_type="api") + established_child = db_connections_established_counter.labels(instance_type="api") + tracker = DBConnectionTracker(open_child, established_child) + + rec1, rec2 = SimpleNamespace(info={}), SimpleNamespace(info={}) + tracker.on_connect(None, rec1) + tracker.on_connect(None, rec2) + assert sample("db_connections_open", ns, instance_type="api") == 2.0 + assert sample("db_connections_established_total", ns, instance_type="api") == 2.0 + + tracker.on_close(None, rec1) + tracker.on_close(None, rec2) + assert sample("db_connections_open", ns, instance_type="api") == 0.0 + # the counter is monotonic: closes never decrement it + assert sample("db_connections_established_total", ns, instance_type="api") == 2.0 + + +def test_marker_prevents_double_dec_and_negative(ns: str) -> None: + """The ConnectionRecord marker bounds each connection to one dec.""" + open_child = db_connections_open_gauge.labels(instance_type="api") + established_child = db_connections_established_counter.labels(instance_type="api") + tracker = DBConnectionTracker(open_child, established_child) + + # a close with no matching connect must not drive the gauge negative + tracker.on_close(None, SimpleNamespace(info={})) + assert sample("db_connections_open", ns, instance_type="api") == 0.0 + + # connect, then close AND invalidate on the same record (both fire during + # invalidation cleanup): the marker ensures exactly one decrement. The third + # positional arg is invalidate's exception, absorbed by on_close's *_. + rec = SimpleNamespace(info={}) + tracker.on_connect(None, rec) + tracker.on_close(None, rec) + tracker.on_close(None, rec, ValueError("invalidated")) + assert sample("db_connections_open", ns, instance_type="api") == 0.0 diff --git a/tests/telemetry/test_metric_zero_init.py b/tests/telemetry/test_metric_zero_init.py new file mode 100644 index 00000000..e69f50df --- /dev/null +++ b/tests/telemetry/test_metric_zero_init.py @@ -0,0 +1,357 @@ +"""Tests for startup zero-initialization of bounded-label metrics. + +Asserts that: +- bounded-label counter children are materialized at 0 before any event, +- high-cardinality / impossible label combinations are deliberately NOT, +- per-process init doesn't materialize the other process's counters, +- the explicit registries stay in sync with the source of truth (drift guards). +""" +# region ai +# Reads use ``REGISTRY.get_sample_value`` (returns the value if a series exists, +# ``None`` if it does not) rather than ``counter.labels(...)``, because ``.labels`` +# would itself materialize the child and destroy the presence/absence signal. +# endregion + +from collections.abc import Iterator +from typing import cast +from uuid import uuid4 + +import pytest +from prometheus_client import REGISTRY + +from src.config import REASONING_LEVELS, settings +from src.dreamer.specialists import BaseSpecialist +from src.telemetry.events import ALL_EVENT_TYPES, HIGH_VOLUME_EVENT_TYPES +from src.telemetry.events.base import BaseEvent +from src.telemetry.prometheus.metrics import ( + _DERIVER_TOKEN_COMBOS_BY_TASK, # pyright: ignore[reportPrivateUsage] + DeriverComponents, + DeriverTaskTypes, + DialecticComponents, + TokenTypes, + prometheus_metrics, +) +from src.utils.types import walk_subclasses + + +def unique_ns(tag: str) -> str: + """A ``namespace`` label value no other test can have materialized under.""" + # region ai + # Every assertion here reads the process-global ``REGISTRY``, which keeps a child + # series for the rest of the session once anything materializes it. A shared + # namespace (several other suites pin ``"test"``) would let another test's children + # satisfy a presence assertion, or break an absence assertion, independently of + # what the initializer under test actually did. + # endregion + return f"test_metric_zero_init_{tag}_{uuid4().hex[:8]}" + + +@pytest.fixture +def metrics_enabled(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]: + """Enable metrics under a namespace unique to the requesting test.""" + ns = unique_ns("enabled") + monkeypatch.setattr("src.config.settings.METRICS.ENABLED", True) + monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", ns) + yield ns + + +def sample(name: str, **labels: str) -> float | None: + """Value of a series if it exists, else None. Never materializes it. + + Resolves the namespace from settings, so it always reads the unique one the + active test pinned. + """ + ns = cast(str, settings.METRICS.NAMESPACE) + return REGISTRY.get_sample_value(name, {"namespace": ns, **labels}) + + +# --------------------------------------------------------------------------- +# Drift guards (pure logic β€” no registry). Adding an event type / token component +# without updating the registry fails here, with a pointer to what to fix. +# --------------------------------------------------------------------------- + + +def test_all_event_types_registry_matches_subclasses(): + """ALL_EVENT_TYPES must equal every BaseEvent subclass's _event_type. + + If this fails you added/removed a BaseEvent subclass without updating + ALL_EVENT_TYPES in src/telemetry/events/__init__.py β€” its Prometheus counter + would not be zero-initialized. Update the registry. + """ + discovered = { + event_type + for cls in walk_subclasses(BaseEvent) + if (event_type := getattr(cls, "_event_type", None)) is not None + } + assert set(ALL_EVENT_TYPES) == discovered + assert len(ALL_EVENT_TYPES) == len(set(ALL_EVENT_TYPES)), "duplicate event types" + + +def test_high_volume_registry_matches_subclasses(): + """HIGH_VOLUME_EVENT_TYPES must equal the high_volume-classed subclasses.""" + discovered = { + event_type + for cls in walk_subclasses(BaseEvent) + if (event_type := getattr(cls, "_event_type", None)) is not None + and getattr(cls, "_volume_class", None) == "high_volume" + } + assert set(HIGH_VOLUME_EVENT_TYPES) == discovered + assert set(HIGH_VOLUME_EVENT_TYPES) <= set(ALL_EVENT_TYPES) + + +def test_deriver_token_combos_are_valid_and_complete(): + """Every combo uses real enum values; the union across tasks covers every + DeriverComponent; and no task enumerates an impossible pair. + + Fails if a DeriverComponent/DeriverTaskType is added without deciding which + task_type + token_type it pairs with in _DERIVER_TOKEN_COMBOS_BY_TASK. + """ + valid_token_types = {t.value for t in TokenTypes} + valid_components = {c.value for c in DeriverComponents} + valid_task_types = {t.value for t in DeriverTaskTypes} + + assert set(_DERIVER_TOKEN_COMBOS_BY_TASK) == valid_task_types + all_components: set[str] = set() + for task_type, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items(): + assert task_type in valid_task_types + for token_type, component in combos: + assert token_type in valid_token_types + assert component in valid_components + # each task enumerates fewer than its cartesian product (no impossible pairs) + assert len(combos) < len(valid_token_types) * len(valid_components) + all_components.update(comp for _, comp in combos) + + # every component is reachable via some task + assert all_components == valid_components + # previous_summary is summary-only: ingestion must NOT enumerate it + ingestion = _DERIVER_TOKEN_COMBOS_BY_TASK[DeriverTaskTypes.INGESTION.value] + assert ( + TokenTypes.INPUT.value, + DeriverComponents.PREVIOUS_SUMMARY.value, + ) not in ingestion + + +# --------------------------------------------------------------------------- +# API-process zero-init +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("metrics_enabled") +def test_api_init_materializes_event_type_children(): + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + for event_type in ALL_EVENT_TYPES: + assert sample("telemetry_events_emitted_total", type=event_type) is not None + for event_type in HIGH_VOLUME_EVENT_TYPES: + assert sample("telemetry_events_sampled_out_total", type=event_type) is not None + + +@pytest.mark.usefixtures("metrics_enabled") +def test_api_init_materializes_dialectic_and_embed(): + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + for token_type in TokenTypes: + for level in REASONING_LEVELS: + assert ( + sample( + "dialectic_tokens_processed_total", + token_type=token_type.value, + component=DialecticComponents.TOTAL.value, + reasoning_level=level, + ) + is not None + ) + assert sample("embed_now_tasks_shed_total") is not None + assert sample("embed_now_tasks_in_flight") == 0.0 # gauge, explicit .set(0) + + +@pytest.mark.usefixtures("metrics_enabled") +def test_sampled_out_excludes_ground_truth_event_types(): + """Ground-truth events can never be sampled out, so their sampled_out series + must NOT be pre-created (they'd be permanently misleading zeros).""" + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + ground_truth = set(ALL_EVENT_TYPES) - set(HIGH_VOLUME_EVENT_TYPES) + for event_type in ground_truth: + assert sample("telemetry_events_sampled_out_total", type=event_type) is None + + +# --------------------------------------------------------------------------- +# Deriver-process zero-init +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("metrics_enabled") +def test_deriver_init_materializes_token_and_backlog(): + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") + for task_type, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items(): + for token_type, component in combos: + assert ( + sample( + "deriver_tokens_processed_total", + task_type=task_type, + token_type=token_type, + component=component, + ) + is not None + ) + # region ai + # Specialist names are derived from the concrete BaseSpecialist subclasses here + # too, rather than hardcoded: a hardcoded list would keep passing when a new + # specialist is added (it only asserts presence), silently leaving it uncovered. + # endregion + specialist_names = { + name + for cls in walk_subclasses(BaseSpecialist) + if (name := getattr(cls, "name", None)) is not None + } + assert {"deduction", "induction", "card_refresh"} <= specialist_names + for specialist_name in specialist_names: + assert ( + sample( + "dreamer_tokens_processed_total", + specialist_name=specialist_name, + token_type=TokenTypes.INPUT.value, + ) + is not None + ), f"specialist {specialist_name!r} was not zero-initialized" + assert sample("message_embeddings_pending") == 0.0 # gauge zero-init + + +@pytest.mark.usefixtures("metrics_enabled") +def test_deriver_init_omits_impossible_token_combos(): + """The cartesian product includes combos that never occur (e.g. output tokens + with an input component). Those must not be materialized.""" + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") + # output tokens never pair with an input component + assert ( + sample( + "deriver_tokens_processed_total", + task_type=DeriverTaskTypes.INGESTION.value, + token_type=TokenTypes.OUTPUT.value, + component=DeriverComponents.PROMPT.value, + ) + is None + ) + # previous_summary is summary-only β€” ingestion must not materialize it + assert ( + sample( + "deriver_tokens_processed_total", + task_type=DeriverTaskTypes.INGESTION.value, + token_type=TokenTypes.INPUT.value, + component=DeriverComponents.PREVIOUS_SUMMARY.value, + ) + is None + ) + # base specialist is abstract and never emits β€” must not be materialized + assert ( + sample( + "dreamer_tokens_processed_total", + specialist_name="base", + token_type=TokenTypes.INPUT.value, + ) + is None + ) + + +# --------------------------------------------------------------------------- +# High-cardinality counters are left open, and per-process isolation holds +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("metrics_enabled") +def test_high_cardinality_counters_not_materialized(): + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") + # no endpoint/workspace_name series fabricated + assert ( + sample( + "api_requests_total", + method="GET", + endpoint="/v3/does-not-exist", + status_code="200", + ) + is None + ) + assert sample("messages_created_total", workspace_name="nope_ws") is None + + +@pytest.mark.usefixtures("metrics_enabled") +def test_api_init_does_not_touch_deriver_counters(): + """api-only init must not materialize or change a deriver-only counter. + + Delta-based (before == after) so it's robust to prior tests having + materialized the series. + """ + labels = dict( + task_type=DeriverTaskTypes.INGESTION.value, + token_type=TokenTypes.INPUT.value, + component=DeriverComponents.PROMPT.value, + ) + before = sample("deriver_tokens_processed_total", **labels) + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + after = sample("deriver_tokens_processed_total", **labels) + assert before == after + + +@pytest.mark.usefixtures("metrics_enabled") +def test_deriver_init_does_not_touch_api_counters(): + """The inverse: deriver-only init must not materialize an API-only counter. + + Without this, a deriver-startup regression could silently fabricate API + series (permanently-0 dialectic tokens on a process that never serves chat). + """ + labels = dict( + token_type=TokenTypes.INPUT.value, + component=DialecticComponents.TOTAL.value, + reasoning_level=REASONING_LEVELS[0], + ) + before = sample("dialectic_tokens_processed_total", **labels) + prometheus_metrics.initialize_bounded_metrics(instance_type="deriver") + after = sample("dialectic_tokens_processed_total", **labels) + assert before == after + # the API-process embed_now counters are equally off-limits + assert sample("embed_now_tasks_shed_total") is None + assert sample("embed_now_tasks_in_flight") is None + + +# --------------------------------------------------------------------------- +# telemetry_events_dropped: per-emitter child materialization +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("metrics_enabled") +def test_dropped_counter_children_materialized(): + prometheus_metrics.initialize_telemetry_dropped_metrics( + reasons=["buffer_full", "send_failed"] + ) + assert sample("telemetry_events_dropped_total", reason="buffer_full") is not None + assert sample("telemetry_events_dropped_total", reason="send_failed") is not None + + +def test_dropped_counter_init_noop_when_metrics_disabled( + monkeypatch: pytest.MonkeyPatch, +): + """The per-emitter initializer must no-op when metrics are disabled.""" + # region ai + # The enabled/disabled pair above and below this line exists for + # ``initialize_bounded_metrics`` (see ``test_init_noop_when_metrics_disabled``); + # without this test the sibling initializer had only the enabled half, so its + # ``METRICS.ENABLED`` guard could be deleted with the suite staying green. The + # unique namespace is what makes the absence assertion mean anything β€” the enabled + # test above materializes these same two reason values under a different one. + # endregion + monkeypatch.setattr("src.config.settings.METRICS.ENABLED", False) + monkeypatch.setattr( + "src.config.settings.METRICS.NAMESPACE", unique_ns("dropped_disabled") + ) + prometheus_metrics.initialize_telemetry_dropped_metrics( + reasons=["buffer_full", "send_failed"] + ) + assert sample("telemetry_events_dropped_total", reason="buffer_full") is None + assert sample("telemetry_events_dropped_total", reason="send_failed") is None + + +def test_init_noop_when_metrics_disabled(monkeypatch: pytest.MonkeyPatch): + """With metrics disabled, init must not fabricate series for a fresh label.""" + monkeypatch.setattr("src.config.settings.METRICS.ENABLED", False) + monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", unique_ns("disabled")) + prometheus_metrics.initialize_bounded_metrics(instance_type="api") + assert sample("telemetry_events_emitted_total", type="message.created") is None diff --git a/tests/telemetry/test_representation_v2_fields.py b/tests/telemetry/test_representation_v2_fields.py index a41bed41..6f2251ea 100644 --- a/tests/telemetry/test_representation_v2_fields.py +++ b/tests/telemetry/test_representation_v2_fields.py @@ -51,6 +51,7 @@ class TestRepresentationV2AdditiveFields: assert event.exact_dup_existing_count == 0 assert event.semantic_dup_rejected_count == 0 assert event.semantic_dup_replaced_count == 0 + assert event.failed_observer_count == 0 def test_input_tokens_semantics_preserved(self): """The downstream metering key must remain 'queued-message tokens'. @@ -161,6 +162,7 @@ class TestRepresentationV2AdditiveFields: "exact_dup_existing_count", "semantic_dup_rejected_count", "semantic_dup_replaced_count", + "failed_observer_count", ): assert field in data, f"missing field: {field}" assert data["hit_batch_token_cap"] is True diff --git a/tests/test_advanced_filters.py b/tests/test_advanced_filters.py index 3b187470..0ed12e20 100644 --- a/tests/test_advanced_filters.py +++ b/tests/test_advanced_filters.py @@ -4,7 +4,7 @@ comparison operators, and wildcards across multiple models. """ from datetime import datetime, timedelta, timezone -from typing import Any +from typing import Any, TypedDict import pytest from fastapi.testclient import TestClient @@ -13,6 +13,12 @@ from nanoid import generate as generate_nanoid from src.models import Peer, Workspace +class MessageConfig(TypedDict): + content: str + peer_id: str + metadata: dict[str, Any] + + @pytest.mark.parametrize( "filter_config,expected_peer_indices,description", [ @@ -235,6 +241,83 @@ async def test_comparison_operators_filters( ), f"Unexpected message '{message_config['content']}' found in results for {description}" +@pytest.mark.asyncio +async def test_nested_metadata_ne_includes_missing_and_empty_metadata( + client: TestClient, + sample_data: tuple[Workspace, Peer], +): + """`ne` on a nested metadata key must not silently drop rows where the + key is absent. Under SQL's three-valued logic, comparing NULL (a missing + key or empty metadata) with `<>` yields NULL, which excludes the row β€” + the filter builds and executes cleanly either way, so this has to be + checked by counting the rows actually returned. + """ + test_workspace, test_peer = sample_data + + session_id = str(generate_nanoid()) + session_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={"id": session_id, "peer_names": {test_peer.name: {}}}, + ) + assert session_response.status_code == 201 + + message_configs: list[MessageConfig] = [ + { + "content": "High priority, score 10", + "peer_id": test_peer.name, + "metadata": {"priority": "high", "score": 10}, + }, + { + "content": "Low priority, score 5", + "peer_id": test_peer.name, + "metadata": {"priority": "low", "score": 5}, + }, + { + "content": "Metadata present, no priority or score key", + "peer_id": test_peer.name, + "metadata": {"other": "value"}, + }, + { + "content": "Empty metadata", + "peer_id": test_peer.name, + "metadata": {}, + }, + ] + messages_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages", + json={"messages": message_configs}, + ) + assert messages_response.status_code == 201 + + def list_contents(filter_config: dict[str, Any]) -> list[str]: + response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages/list", + json={"filters": filter_config}, + ) + assert response.status_code == 200 + return [item["content"] for item in response.json()["items"]] + + # String value: excludes only the row where priority actually equals "high" + string_ne_contents = list_contents({"metadata": {"priority": {"ne": "high"}}}) + assert sorted(string_ne_contents) == sorted( + [ + message_configs[1]["content"], + message_configs[2]["content"], + message_configs[3]["content"], + ] + ) + + # Numeric value: excludes only the row where score actually equals 5 + numeric_ne_contents = list_contents({"metadata": {"score": {"ne": 5}}}) + assert sorted(numeric_ne_contents) == sorted( + [ + message_configs[0]["content"], + message_configs[2]["content"], + message_configs[3]["content"], + ] + ) + + @pytest.mark.asyncio async def test_bare_list_membership_sugar( client: TestClient, diff --git a/tests/test_cache_key_namespace.py b/tests/test_cache_key_namespace.py new file mode 100644 index 00000000..14d2e7cc --- /dev/null +++ b/tests/test_cache_key_namespace.py @@ -0,0 +1,62 @@ +"""The namespace hash tag must survive both ways a cache key gets built. + +cashews format-substitutes `prefix=`, direct construction does not, so the two +need different spellings of the same tag. If they ever diverge, a write and its +invalidation land on different keys and nothing raises -- the cache just serves +stale rows. These tests are what fails instead. +""" + +import pytest +from redis.crc import key_slot + +from src.cache.client import ( + cache, + cache_key_namespace, + cache_prefix_namespace, + get_cache_namespace, +) +from src.crud.session import SESSION_CACHE_KEY_TEMPLATE, session_cache_key + + +def test_both_spellings_render_the_same_tag(): + ns = get_cache_namespace() + assert cache_key_namespace() == "{" + ns + "}" + # Doubled braces collapse to single ones when cashews formats the prefix. + assert cache_prefix_namespace().format() == cache_key_namespace() + + +@pytest.mark.asyncio +async def test_decorator_key_matches_helper_key(): + """The key the decorator writes is the key the helper computes.""" + + @cache( + key=SESSION_CACHE_KEY_TEMPLATE, + prefix=cache_prefix_namespace(), + ttl="60s", + ) + async def get_session(workspace_name: str, session_name: str) -> str: + # The names matter: cashews fills the key template from them. + return f"{workspace_name}/{session_name}" + + await get_session(workspace_name="w1", session_name="s1") + + written = [k async for k in cache.scan("*")] + assert session_cache_key("w1", "s1") in written + + +def test_one_namespace_hashes_to_one_slot(): + """Every key an instance writes shares a Redis Cluster slot.""" + keys = [ + session_cache_key("w1", "s1"), + session_cache_key("w2", "s2"), + f"{cache_key_namespace()}:lock:v2:anything", + ] + assert len({key_slot(k.encode()) for k in keys}) == 1 + + +def test_namespaces_hash_independently(): + """Tagging must not collapse the whole fleet onto one shard.""" + slots = { + key_slot(("{" + n + "}:v2:workspace:w").encode()) for n in ("a1", "b2", "c3") + } + assert len(slots) > 1 diff --git a/tests/test_dialectic_prompts.py b/tests/test_dialectic_prompts.py new file mode 100644 index 00000000..d926d0bc --- /dev/null +++ b/tests/test_dialectic_prompts.py @@ -0,0 +1,94 @@ +"""Contracts for dialectic system prompts vs the tool loadouts they describe.""" + +import re + +from src.dialectic.core import DialecticAgent +from src.dialectic.prompts import ( + PAIR_PROMPT_TOOLS, + WORKSPACE_PROMPT_TOOLS, + agent_system_prompt, + workspace_agent_system_prompt, +) +from src.dialectic.workspace import WorkspaceDialecticAgent +from src.utils.agent_tools import ( + DIALECTIC_TOOLS, + DIALECTIC_TOOLS_MINIMAL, + TOOLS, + WORKSPACE_DIALECTIC_TOOLS, + WORKSPACE_TOOLS_MINIMAL, +) + +_ALL_TOOL_NAMES = {spec["name"] for spec in TOOLS.values()} + + +def _loadout_names(tools: list[dict[str, object]]) -> set[str]: + return {name for tool in tools if isinstance((name := tool.get("name")), str)} + + +def _mentioned_tools(text: str) -> set[str]: + return { + match + for match in re.findall(r"`([a-z_][a-z0-9_]*)`", text) + if match in _ALL_TOOL_NAMES + } + + +def _tools_catalog(prompt: str) -> str: + start = prompt.index("## TOOLS") + rest = prompt[start:] + next_heading = rest.find("\n## ", 1) + return rest if next_heading == -1 else rest[:next_heading] + + +class TestPromptLoadouts: + def test_pair_docs_match_dialectic_tools(self) -> None: + assert _loadout_names(DIALECTIC_TOOLS) == PAIR_PROMPT_TOOLS + + def test_workspace_docs_match_workspace_tools(self) -> None: + assert _loadout_names(WORKSPACE_DIALECTIC_TOOLS) == WORKSPACE_PROMPT_TOOLS + + def test_catalog_lists_only_offered_workspace_tools(self) -> None: + offered = _loadout_names(WORKSPACE_TOOLS_MINIMAL) + catalog = _tools_catalog(workspace_agent_system_prompt(offered)) + assert _mentioned_tools(catalog) == offered + + +class TestPairAgentPrompt: + def test_teaches_honcho_world_without_workspace_sibling(self) -> None: + prompt = agent_system_prompt("alice", "alice", None, None).lower() + assert "workspace dialectic" not in prompt + assert "peer-level" not in prompt + for term in ("honcho", "peer", "session", "message", "conclusion"): + assert term in prompt + + def test_does_not_offer_removed_write_tools(self) -> None: + prompt = agent_system_prompt("alice", "alice", None, None) + assert "create_observations_deductive" not in prompt + assert "create_observations" not in prompt + + def test_agent_lists_selected_tools(self) -> None: + agent = DialecticAgent( + workspace_name="w", + session_name=None, + observer="alice", + observed="alice", + reasoning_level="minimal", + ) + offered = _loadout_names(DIALECTIC_TOOLS_MINIMAL) + catalog = _tools_catalog(agent.messages[0]["content"]) + assert _mentioned_tools(catalog) == offered + assert agent.messages[0]["content"] == agent_system_prompt( + "alice", "alice", None, None, available_tools=offered + ) + + +class TestWorkspaceAgentPrompt: + def test_minimal_agent_matches_filtered_prompt(self) -> None: + agent = WorkspaceDialecticAgent(workspace_name="w", reasoning_level="minimal") + offered = _loadout_names(WORKSPACE_TOOLS_MINIMAL) + prompt = agent.messages[0]["content"] + assert prompt == workspace_agent_system_prompt(offered) + catalog = _tools_catalog(prompt) + assert _mentioned_tools(catalog) == offered + assert "get_peer_card" not in catalog + assert "get_reasoning_chain" not in catalog diff --git a/tests/test_schema_validations.py b/tests/test_schema_validations.py index 97b8b64a..e3a285f2 100644 --- a/tests/test_schema_validations.py +++ b/tests/test_schema_validations.py @@ -5,9 +5,11 @@ from pydantic import ValidationError from src.config import settings from src.schemas import ( + DialecticOptions, DocumentCreate, DocumentMetadata, MessageCreate, + ObservationInput, PeerCreate, ReasoningConfiguration, ResolvedConfiguration, @@ -275,3 +277,68 @@ class TestReasoningCustomInstructionsValidation: configuration = ReasoningConfiguration(custom_instructions=custom_instructions) assert configuration.custom_instructions == custom_instructions + + +class TestNulByteSanitization: + """Postgres rejects NUL (0x00) in text columns and in jsonb strings. + + Models emit these as `\\u0000` escapes in tool-call arguments, which the + JSON parser decodes into real NUL bytes, so model-generated text needs the + same treatment as user-supplied input. + """ + + def test_document_content_strips_nul(self): + document = DocumentCreate( + content="the key is at c:\\\x00users\\amal", + metadata=DocumentMetadata(message_ids=[1], message_created_at="2026-08-28"), + embedding=[0.1], + ) + + assert document.content == "the key is at c:\\users\\amal" + + def test_all_nul_document_content_is_rejected_not_emptied(self): + """The validator runs before `min_length`, so content that is nothing + but NUL fails validation rather than being stored as an empty string.""" + with pytest.raises(ValidationError): + DocumentCreate( + content="\x00\x00", + metadata=DocumentMetadata( + message_ids=[1], message_created_at="2026-08-28" + ), + embedding=[0.1], + ) + + def test_message_content_strips_nul(self): + message = MessageCreate(peer_id="peer", content="before\x00after") + + assert message.content == "beforeafter" + + def test_metadata_strips_nul_at_every_depth(self): + message = MessageCreate( + peer_id="peer", + content="hi", + metadata={"a\x00b": {"c": ["d\x00e", 1]}}, + ) + + assert message.metadata == {"ab": {"c": ["de", 1]}} + + def test_observation_content_strips_nul(self): + observation = ObservationInput(content="before\x00after") + + assert observation.content == "beforeafter" + + def test_all_nul_observation_content_is_rejected_not_emptied(self): + """Sanitization runs before `min_length`, so an all-NUL observation is + reported back to the model as a validation failure rather than saved + as an empty document.""" + with pytest.raises(ValidationError): + ObservationInput(content="\x00\x00") + + def test_all_nul_query_is_rejected_not_emptied(self): + """`NulStripped` runs before the field's own constraints, so a query + that is nothing but NUL fails `min_length` instead of reaching the + dialectic as an empty prompt.""" + options = DialecticOptions.model_validate({"query": "before\x00after"}) + assert options.query == "beforeafter" + with pytest.raises(ValidationError): + DialecticOptions.model_validate({"query": "\x00"}) diff --git a/tests/test_search.py b/tests/test_search.py index 84f3ffa3..b4b69c43 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -4,9 +4,10 @@ import datetime import pytest from nanoid import generate as generate_nanoid +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession -from src import crud, models +from src import crud, models, schemas from src.utils.search import search @@ -704,3 +705,71 @@ async def test_grep_messages_observer_scoping_left_session_still_visible( matched_ids = [m.public_id for matches, _ in results for m in matches] assert msg_during.public_id in matched_ids assert msg_after.public_id in matched_ids + + +@pytest.mark.asyncio +async def test_peer_perspective_search_after_active_readd( + db_session: AsyncSession, +): + """Active re-add keeps existing messages visible; a genuine rejoin starts a + new window.""" + workspace = models.Workspace(name=generate_nanoid()) + peer1 = models.Peer(name="peer1", workspace_name=workspace.name) + peer2 = models.Peer(name="peer2", workspace_name=workspace.name) + session = models.Session(name="session1", workspace_name=workspace.name) + db_session.add_all([workspace, peer1, peer2, session]) + await db_session.flush() + + past_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + hours=1 + ) + await db_session.execute( + models.session_peers_table.insert().values( + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer1.name, + joined_at=past_time, + left_at=None, + ) + ) + msg_old = models.Message( + content="old persistent message", + session_name=session.name, + peer_name=peer2.name, + workspace_name=workspace.name, + seq_in_session=1, + created_at=past_time + datetime.timedelta(minutes=1), + ) + db_session.add(msg_old) + await db_session.commit() + + session_create = schemas.SessionCreate( + name=session.name, + peers={peer1.name: schemas.SessionPeerConfig()}, + ) + await crud.get_or_create_session(db_session, session_create, workspace.name) + results = await search( + "persistent", + filters={"peer_perspective": peer1.name, "workspace_id": workspace.name}, + limit=10, + ) + assert msg_old.public_id in [m.public_id for m in results] + + await db_session.execute( + update(models.SessionPeer) + .where( + models.SessionPeer.session_name == session.name, + models.SessionPeer.peer_name == peer1.name, + models.SessionPeer.workspace_name == workspace.name, + ) + .values(left_at=datetime.datetime.now(datetime.timezone.utc)) + ) + await db_session.commit() + + await crud.get_or_create_session(db_session, session_create, workspace.name) + results = await search( + "persistent", + filters={"peer_perspective": peer1.name, "workspace_id": workspace.name}, + limit=10, + ) + assert msg_old.public_id not in [m.public_id for m in results] diff --git a/tests/test_security.py b/tests/test_security.py index 1785be8b..23725692 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,4 +1,4 @@ -"""Auth scope tests β€” DEV-1736 regression coverage. +"""Auth scope tests β€” regression coverage. Prior to this fix `auth()` walked the route's declared scope first and fell through to a workspace check, so a `{w, p}` token authorized any peer in `w`. diff --git a/tests/test_workspace_chat.py b/tests/test_workspace_chat.py new file mode 100644 index 00000000..daed20e8 --- /dev/null +++ b/tests/test_workspace_chat.py @@ -0,0 +1,1255 @@ +"""Integration tests for the workspace-level chat feature. + +Tests cover: +- Route-level: POST /workspaces/{workspace_id}/chat endpoint +- Tool handlers: workspace-specific tool handlers and executor +""" + +import asyncio +import json +from collections.abc import Callable +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any + +import pytest +from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession + +from src import crud, models +from src.dialectic.chat import workspace_chat, workspace_chat_stream +from src.models import Peer, Workspace +from src.utils.agent_tools import ( + ToolContext, + _handle_get_observation_context, # pyright: ignore[reportPrivateUsage] + _handle_get_peer_card_by_name, # pyright: ignore[reportPrivateUsage] + _handle_get_reasoning_chain, # pyright: ignore[reportPrivateUsage] + _handle_get_workspace_stats, # pyright: ignore[reportPrivateUsage] + _handle_search_memory_workspace, # pyright: ignore[reportPrivateUsage] + create_workspace_tool_executor, +) +from src.utils.scopes import SCOPE_KIND, scope_peer_name + +# ============================================================================= +# Fixtures +# ============================================================================= + + +def _tool_text(result: object) -> str: + """Unwrap ToolResult (today's handler contract) or pass through str.""" + content = getattr(result, "content", None) + return content if isinstance(content, str) else str(result) + + +@pytest.fixture +async def workspace_test_data( + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +) -> Any: + """Create comprehensive test data with multiple peers and observations. + + Sets up a workspace with: + - 3 peers (peer1 observes peer2, peer1 observes peer3) + - 1 session with messages from all peers + - Documents (observations) across different peer pairs + """ + workspace, peer1 = sample_data + + # Create additional peers + peer2 = models.Peer(name=str(generate_nanoid()), workspace_name=workspace.name) + peer3 = models.Peer(name=str(generate_nanoid()), workspace_name=workspace.name) + db_session.add_all([peer2, peer3]) + await db_session.flush() + + # Create session + session = models.Session(name=str(generate_nanoid()), workspace_name=workspace.name) + db_session.add(session) + await db_session.flush() + + # Create collections (peer1 observes peer2, peer1 observes peer3) + collection1 = models.Collection( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + ) + collection2 = models.Collection( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer3.name, + ) + db_session.add_all([collection1, collection2]) + await db_session.flush() + + # Create messages + now = datetime.now(timezone.utc) + messages: list[models.Message] = [] + for i in range(6): + peer_name = [peer1.name, peer2.name, peer3.name][i % 3] + msg = models.Message( + workspace_name=workspace.name, + session_name=session.name, + peer_name=peer_name, + content=f"Test message {i} from {peer_name}", + seq_in_session=i + 1, + token_count=10, + created_at=now - timedelta(minutes=6 - i), + ) + db_session.add(msg) + messages.append(msg) + await db_session.flush() + for msg in messages: + await db_session.refresh(msg) + + # Create documents for peer1->peer2 observations + docs_peer2: list[models.Document] = [] + for content in [ + "User likes coffee and programming", + "User works remotely from home", + ]: + doc = models.Document( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + content=content, + embedding=[0.1] * 1536, + session_name=session.name, + level="explicit", + metadata={ + "message_ids": [messages[0].id], + "message_created_at": str(messages[0].created_at), + }, + ) + db_session.add(doc) + docs_peer2.append(doc) + + # Create documents for peer1->peer3 observations + docs_peer3: list[models.Document] = [] + for content in [ + "User prefers mornings for deep work", + "User enjoys hiking on weekends", + ]: + doc = models.Document( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer3.name, + content=content, + embedding=[0.2] * 1536, + session_name=session.name, + level="explicit", + metadata={ + "message_ids": [messages[1].id], + "message_created_at": str(messages[1].created_at), + }, + ) + db_session.add(doc) + docs_peer3.append(doc) + + await db_session.flush() + for doc in docs_peer2 + docs_peer3: + await db_session.refresh(doc) + + # Commit so data is visible to independent tracked_db sessions used by + # workspace-level tool handlers. + await db_session.commit() + + yield workspace, peer1, peer2, peer3, session, messages, docs_peer2, docs_peer3 + + await db_session.rollback() + + +@pytest.fixture +def make_workspace_ctx( + workspace_test_data: Any, +) -> Callable[..., ToolContext]: + """Factory fixture to create ToolContext.""" + workspace, *_ = workspace_test_data + shared_lock = asyncio.Lock() + + def _make_ctx( + *, + session_name: str | None = None, + include_observation_ids: bool = True, + session_allowlist: list[str] | None = None, + ) -> ToolContext: + return ToolContext( + observer="", + observed="", + current_messages=None, + workspace_name=workspace.name, + session_name=session_name, + include_observation_ids=include_observation_ids, + history_token_limit=8192, + db_lock=shared_lock, + session_allowlist=session_allowlist, + ) + + return _make_ctx + + +# ============================================================================= +# Route Tests: POST /workspaces/{workspace_id}/chat +# ============================================================================= + + +class TestWorkspaceChatEndpoint: + """Tests for the workspace chat API endpoint.""" + + def test_workspace_chat_basic( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Basic non-streaming workspace chat returns DialecticResponse.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "What do you know about the peers in this workspace?", + "stream": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert "content" in data + assert data["content"] == "Test workspace chat response" + + def test_workspace_chat_with_session_id( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Workspace chat accepts optional session_id parameter.""" + test_workspace, _ = sample_data + session_id = str(generate_nanoid()) + + # Create a session first + create_response = client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={"name": session_id}, + ) + assert create_response.status_code in (200, 201) + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "Tell me about recent conversations", + "session_id": session_id, + "stream": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert "content" in data + + def test_workspace_chat_with_reasoning_level( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Workspace chat accepts reasoning_level parameter.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "Analyze common themes across all peers", + "stream": False, + "reasoning_level": "low", + }, + ) + assert response.status_code == 200 + data = response.json() + assert "content" in data + + def test_workspace_chat_streaming( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Streaming workspace chat returns SSE-formatted events.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "What patterns do you see across the workspace?", + "stream": True, + }, + ) + assert response.status_code == 200 + assert "text/event-stream" in response.headers.get("content-type", "") + + # Parse SSE events + events: list[Any] = [] + for line in response.text.strip().split("\n\n"): + if line.startswith("data: "): + event_data = json.loads(line[6:]) + events.append(event_data) + + # Should have content events and a final done event + assert len(events) >= 2 + content_events = [e for e in events if not e.get("done")] + done_events = [e for e in events if e.get("done")] + assert len(content_events) >= 1 + assert len(done_events) == 1 + + # Content events should have delta.content + for event in content_events: + assert "delta" in event + assert "content" in event["delta"] + + def test_workspace_chat_empty_query_rejected( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Empty query should be rejected by validation.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "", + "stream": False, + }, + ) + assert response.status_code == 422 + + def test_workspace_chat_missing_query_rejected( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Missing query field should be rejected.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={"stream": False}, + ) + assert response.status_code == 422 + + def test_workspace_chat_null_content_response( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + """When workspace_chat returns None, response content should be None.""" + test_workspace, _ = sample_data + mock_llm_call_functions["workspace_chat"].side_effect = None + mock_llm_call_functions["workspace_chat"].return_value = None + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={ + "query": "Some query", + "stream": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["content"] is None + + def test_workspace_chat_defaults( + self, + client: Any, + sample_data: tuple[Workspace, Peer], + ): + """Endpoint works with only the required query field.""" + test_workspace, _ = sample_data + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/chat", + json={"query": "Hello workspace"}, + ) + assert response.status_code == 200 + data = response.json() + assert "content" in data + + +@pytest.mark.asyncio +async def test_workspace_chat_releases_preflight_session_before_agent_answer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + active_sessions = 0 + + @asynccontextmanager + async def fake_tracked_db(_: str | None = None, **_kwargs: Any): + nonlocal active_sessions + active_sessions += 1 + try: + yield object() + finally: + active_sessions -= 1 + + async def fake_get_session(*args: Any, **kwargs: Any) -> object: + _ = (args, kwargs) + assert active_sessions == 1 + return SimpleNamespace(id="session-id") + + async def fake_answer(_self: Any, query: str, **_kwargs: Any) -> str: + assert query == "What changed?" + assert active_sessions == 0 + return "ok" + + async def fake_get_workspace(*args: Any, **kwargs: Any) -> object: + _ = (args, kwargs) + assert active_sessions == 1 + return SimpleNamespace(name="workspace") + + monkeypatch.setattr("src.dialectic.chat.tracked_db", fake_tracked_db) + monkeypatch.setattr("src.dialectic.chat.crud.get_workspace", fake_get_workspace) + monkeypatch.setattr("src.dialectic.chat.crud.get_session", fake_get_session) + monkeypatch.setattr( + "src.dialectic.chat.WorkspaceDialecticAgent.answer", fake_answer + ) + + result = await workspace_chat("workspace", "session", "What changed?") + + assert result == "ok" + + +@pytest.mark.asyncio +async def test_workspace_chat_stream_releases_preflight_session_before_stream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + active_sessions = 0 + + @asynccontextmanager + async def fake_tracked_db(_: str | None = None, **_kwargs: Any): + nonlocal active_sessions + active_sessions += 1 + try: + yield object() + finally: + active_sessions -= 1 + + async def fake_get_session(*args: Any, **kwargs: Any) -> object: + _ = (args, kwargs) + assert active_sessions == 1 + return SimpleNamespace(id="session-id") + + async def fake_answer_stream(_self: Any, query: str, **_kwargs: Any): + assert query == "Stream it" + assert active_sessions == 0 + yield "chunk-1" + assert active_sessions == 0 + yield "chunk-2" + + async def fake_get_workspace(*args: Any, **kwargs: Any) -> object: + _ = (args, kwargs) + assert active_sessions == 1 + return SimpleNamespace(name="workspace") + + monkeypatch.setattr("src.dialectic.chat.tracked_db", fake_tracked_db) + monkeypatch.setattr("src.dialectic.chat.crud.get_workspace", fake_get_workspace) + monkeypatch.setattr("src.dialectic.chat.crud.get_session", fake_get_session) + monkeypatch.setattr( + "src.dialectic.chat.WorkspaceDialecticAgent.answer_stream", + fake_answer_stream, + ) + + chunks = [ + chunk + async for chunk in workspace_chat_stream("workspace", "session", "Stream it") + ] + + assert chunks == ["chunk-1", "chunk-2"] + + +# ============================================================================= +# Tool Handler Tests: Workspace-Specific Handlers +# ============================================================================= + + +@pytest.mark.asyncio +class TestSearchMemoryWorkspace: + """Tests for _handle_search_memory_workspace (representation-scoped).""" + + async def test_requires_observer_and_observed( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when observer/observed params are missing.""" + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace(ctx, {"query": "coffee preferences"}) + ) + assert "ERROR" in result + assert "observer" in result + + async def test_missing_observer_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when only observed is provided.""" + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, {"query": "test", "observed": "someone"} + ) + ) + assert "ERROR" in result + + async def test_missing_observed_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when only observer is provided.""" + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, {"query": "test", "observer": "someone"} + ) + ) + assert "ERROR" in result + + async def test_returns_observations_for_specific_pair( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + ): + """Returns observations scoped to a specific observer/observed pair.""" + monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False) + _, peer1, peer2, _, _, _, _, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, + { + "query": "coffee preferences", + "observer": peer1.name, + "observed": peer2.name, + }, + ) + ) + + assert "Found" in result + assert "observations" in result.lower() + # Should be scoped to peer1->peer2 + assert f"{peer1.name}->{peer2.name}" in result + + async def test_does_not_return_observations_from_other_pairs( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + ): + """Does not leak observations from other peer pairs.""" + monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False) + _, peer1, _, peer3, _, _, _, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, + { + "query": "coffee", + "observer": peer1.name, + "observed": peer3.name, + }, + ) + ) + + # peer3 observations are about hiking/mornings, not coffee + # Should either find the hiking/mornings ones or none + assert isinstance(result, str) + + async def test_falls_back_to_message_search( + self, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """Falls back to message search when no observations exist for the pair.""" + workspace, _ = sample_data + + session = models.Session( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + db_session.add(session) + await db_session.flush() + + observer = models.Peer( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + observed = models.Peer( + name=str(generate_nanoid()), workspace_name=workspace.name + ) + db_session.add_all([observer, observed]) + await db_session.flush() + + msg = models.Message( + workspace_name=workspace.name, + session_name=session.name, + peer_name=observed.name, + content="I really like programming in Python", + seq_in_session=1, + token_count=10, + created_at=datetime.now(timezone.utc), + ) + db_session.add(msg) + await db_session.flush() + + ctx = ToolContext( + observer="", + observed="", + current_messages=None, + workspace_name=workspace.name, + session_name=session.name, + include_observation_ids=False, + history_token_limit=8192, + db_lock=asyncio.Lock(), + ) + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, + { + "query": "programming", + "observer": observer.name, + "observed": observed.name, + }, + ) + ) + + assert isinstance(result, str) + assert "No observations" in result or "Found" in result + + async def test_respects_top_k( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + ): + """Respects the top_k parameter, capped at 40.""" + monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False) + _, peer1, peer2, _, _, _, _, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = _tool_text( + await _handle_search_memory_workspace( + ctx, + { + "query": "test", + "top_k": 2, + "observer": peer1.name, + "observed": peer2.name, + }, + ) + ) + + assert isinstance(result, str) + + +@pytest.mark.asyncio +class TestGetWorkspaceStats: + """Tests for _handle_get_workspace_stats.""" + + async def test_returns_stats( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns workspace statistics.""" + _ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_workspace_stats(ctx, {}) + + assert "Workspace stats" in result + assert "Peers: 3" in result + assert "Sessions: 1" in result + assert "Messages: 6" in result + assert "Date range" in result + + async def test_lists_most_active_peers( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Includes the most active peers with message counts.""" + _, peer1, peer2, peer3, *_ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_workspace_stats(ctx, {}) + + assert "Most active peers" in result + assert peer1.name in result + assert peer2.name in result + assert peer3.name in result + assert "messages" in result + + async def test_empty_workspace( + self, + db_session: AsyncSession, + ): + """Returns zero counts for an empty workspace.""" + workspace = models.Workspace(name=str(generate_nanoid())) + db_session.add(workspace) + await db_session.flush() + + ctx = ToolContext( + observer="", + observed="", + current_messages=None, + workspace_name=workspace.name, + session_name=None, + include_observation_ids=False, + history_token_limit=8192, + db_lock=asyncio.Lock(), + ) + + result = await _handle_get_workspace_stats(ctx, {}) + + assert "Peers: 0" in result + assert "Messages: 0" in result + + async def test_excludes_scope_peers( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + workspace, *_ = workspace_test_data + db_session.add( + models.Peer( + name=scope_peer_name("therapy"), + workspace_name=workspace.name, + internal_metadata={"kind": SCOPE_KIND}, + configuration={"observe_me": False}, + ) + ) + await db_session.commit() + + result = await _handle_get_workspace_stats(make_workspace_ctx(), {}) + + assert "Peers: 3" in result + assert "scope.therapy" not in result + + async def test_empty_session_allowlist_is_zero( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + _ = workspace_test_data + result = await _handle_get_workspace_stats( + make_workspace_ctx(session_allowlist=[]), {} + ) + + assert "Peers: 0" in result + assert "Sessions: 0" in result + assert "Messages: 0" in result + + +@pytest.mark.asyncio +class TestGetPeerCardByName: + """Tests for _handle_get_peer_card_by_name.""" + + async def test_returns_peer_card( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns peer card when it exists.""" + workspace, peer1, peer2, *_ = workspace_test_data + + # Create a peer card + await crud.set_peer_card( + db_session, + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + peer_card=["Name: Alice", "Location: NYC"], + ) + + ctx = make_workspace_ctx() + result = await _handle_get_peer_card_by_name( + ctx, {"observer": peer1.name, "observed": peer2.name} + ) + + assert "Peer card" in result + assert "Name: Alice" in result + assert "Location: NYC" in result + + async def test_returns_not_found( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns appropriate message when peer card doesn't exist.""" + _, peer1, peer2, *_ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_peer_card_by_name( + ctx, {"observer": peer1.name, "observed": peer2.name} + ) + + assert "No peer card" in result + + async def test_session_allowlist_refuses( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """A peer card is a cross-session aggregate, so a scoped query must not + get one β€” otherwise `scope` leaks facts derived outside its sessions.""" + workspace, peer1, peer2, _peer3, session, *_ = workspace_test_data + + await crud.set_peer_card( + db_session, + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + peer_card=["Secret: derived from an out-of-scope session"], + ) + + ctx = make_workspace_ctx(session_allowlist=[session.name]) + result = await _handle_get_peer_card_by_name( + ctx, {"observer": peer1.name, "observed": peer2.name} + ) + + assert "Secret" not in result + assert "unavailable for session-scoped queries" in result + + async def test_unknown_peer_is_answered_not_raised( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """The agent supplies peer names from its own routing, so a name that + doesn't exist is an expected turn, not an unhandled exception.""" + _, peer1, *_ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_peer_card_by_name( + ctx, {"observer": "no-such-peer", "observed": peer1.name} + ) + + assert "No peer named 'no-such-peer'" in result + + async def test_missing_params_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when observer/observed params are missing.""" + ctx = make_workspace_ctx() + + result = await _handle_get_peer_card_by_name(ctx, {}) + + assert "ERROR" in result + + async def test_missing_observer_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when only observed is provided.""" + ctx = make_workspace_ctx() + + result = await _handle_get_peer_card_by_name(ctx, {"observed": "someone"}) + + assert "ERROR" in result + + +@pytest.mark.asyncio +class TestGetObservationContextWorkspace: + """Tests for get_observation_context under the workspace executor. + + The workspace loadout routes this straight to the shared handler: its + observer="" sentinel already normalizes to None ("no perspective + scoping") at the crud boundary.""" + + async def test_retrieves_messages_by_id( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Retrieves messages by their public IDs.""" + _, _, _, _, _, messages, _, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_observation_context( + ctx, {"message_ids": [messages[0].public_id]} + ) + + assert "Retrieved" in result or "No messages found" in result + + async def test_nonexistent_message_ids( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns appropriate message for nonexistent IDs.""" + ctx = make_workspace_ctx() + + result = await _handle_get_observation_context( + ctx, {"message_ids": ["nonexistent_id"]} + ) + + assert "No messages found" in result + + async def test_respects_session_scope( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Session-scoped context lookup should not leak snippets from other sessions.""" + workspace, _peer1, peer2, _peer3, session, messages, *_ = workspace_test_data + + other_session = models.Session( + name=str(generate_nanoid()), + workspace_name=workspace.name, + ) + db_session.add(other_session) + await db_session.flush() + + leaked_message = models.Message( + workspace_name=workspace.name, + session_name=other_session.name, + peer_name=peer2.name, + content="LEAKED_FROM_OTHER_SESSION", + seq_in_session=messages[0].seq_in_session, + token_count=10, + created_at=datetime.now(timezone.utc), + ) + db_session.add(leaked_message) + await db_session.commit() + + ctx = make_workspace_ctx(session_name=session.name) + result = await _handle_get_observation_context( + ctx, {"message_ids": [messages[0].public_id]} + ) + + assert "LEAKED_FROM_OTHER_SESSION" not in result + assert messages[0].content in result + + +@pytest.mark.asyncio +class TestGetReasoningChainWorkspace: + """Tests for _handle_get_reasoning_chain.""" + + async def test_returns_observation_chain( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns an observation and its chain.""" + _, _, _, _, _, _, docs_peer2, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_reasoning_chain( + ctx, {"observation_id": docs_peer2[0].id} + ) + + assert "Observation" in result + assert docs_peer2[0].content in result + + async def test_nonexistent_observation_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error for nonexistent observation ID.""" + ctx = make_workspace_ctx() + + result = await _handle_get_reasoning_chain( + ctx, {"observation_id": "nonexistent_id"} + ) + + assert "ERROR" in result + + async def test_missing_observation_id_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + ): + """Returns error when observation_id is missing.""" + ctx = make_workspace_ctx() + + result = await _handle_get_reasoning_chain(ctx, {}) + + assert "ERROR" in result + + async def test_invalid_direction_returns_error( + self, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Returns error for invalid direction parameter.""" + _, _, _, _, _, _, docs_peer2, _ = workspace_test_data + ctx = make_workspace_ctx() + + result = await _handle_get_reasoning_chain( + ctx, + {"observation_id": docs_peer2[0].id, "direction": "invalid"}, + ) + + assert "ERROR" in result + + async def test_deductive_observation_shows_premises( + self, + db_session: AsyncSession, + make_workspace_ctx: Callable[..., ToolContext], + workspace_test_data: Any, + ): + """Deductive observation shows premises in chain.""" + workspace, peer1, peer2, _, _, _, docs_peer2, _ = workspace_test_data + + # Create a deductive document with source_ids + deductive_doc = models.Document( + workspace_name=workspace.name, + observer=peer1.name, + observed=peer2.name, + content="User is probably a morning person who codes", + embedding=[0.3] * 1536, + level="deductive", + source_ids=[docs_peer2[0].id, docs_peer2[1].id], + ) + db_session.add(deductive_doc) + await db_session.commit() + await db_session.refresh(deductive_doc) + + ctx = make_workspace_ctx() + result = await _handle_get_reasoning_chain( + ctx, {"observation_id": deductive_doc.id, "direction": "premises"} + ) + + assert "Observation" in result + assert "Premises" in result + + +# ============================================================================= +# Tool Executor Tests +# ============================================================================= + + +@pytest.mark.asyncio +class TestWorkspaceToolExecutor: + """Tests for create_workspace_tool_executor.""" + + async def test_returns_callable( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """create_workspace_tool_executor returns an async callable.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + assert callable(executor) + + async def test_routes_workspace_tools( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """Workspace-specific tools are routed to workspace handlers.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + stats_result = await executor("get_workspace_stats", {}) + assert isinstance(stats_result, str) + assert "Workspace stats" in stats_result + assert "Most active peers" in stats_result + + async def test_falls_through_to_standard_handlers( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """Non-workspace tools fall through to standard handlers.""" + workspace, _, _, _, session, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + session_name=session.name, + ) + + # grep_messages is a standard handler, should fall through + result = await executor("grep_messages", {"text": "Test message"}) + + assert isinstance(result, str) + + async def test_unknown_tool_returns_error( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """Unknown tool name returns error.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + result = await executor("nonexistent_tool", {}) + + assert "Unknown tool" in result + + async def test_handles_exceptions_gracefully( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """Executor returns error strings instead of raising exceptions.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + # Missing required observer/observed/query parameters + result = await executor("search_memory", {}) + + assert isinstance(result, str) + assert "ERROR" in result + + async def test_get_peer_card_via_executor( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """get_peer_card routes through workspace handler with params.""" + workspace, peer1, peer2, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + + result = await executor( + "get_peer_card", + {"observer": peer1.name, "observed": peer2.name}, + ) + + assert isinstance(result, str) + # Should be from workspace handler (accepts observer/observed params) + assert "peer card" in result.lower() or "No peer card" in result + + +# ============================================================================= +# Regression: workspace-flat message visibility without a pinned session +# ============================================================================= + + +@pytest.mark.asyncio +class TestWorkspaceMessageToolsUnpinned: + """The workspace executor's observer='' sentinel must read as + 'no perspective scoping' (None) at the crud boundary. Under #882's + resolve_session_scope, an empty STRING is looked up as a real peer with + no session memberships and denies every result β€” so these tests run the + message tools with NO session_name, the primary workspace-chat shape.""" + + async def test_grep_messages_finds_content_without_session( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + result = await executor("grep_messages", {"text": "Test message"}) + + assert isinstance(result, str) + assert "No messages found" not in result + assert "Test message" in result + + async def test_date_range_finds_content_without_session( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + ) + result = await executor("get_messages_by_date_range", {"limit": 10}) + + assert isinstance(result, str) + assert "Found" in result + assert "No messages found" not in result + + async def test_session_allowlist_is_honored_when_set( + self, + db_session: AsyncSession, # pyright: ignore[reportUnusedParameter] + workspace_test_data: Any, + ): + """An allowlist naming no real session yields no results.""" + workspace, *_ = workspace_test_data + + executor = await create_workspace_tool_executor( + workspace_name=workspace.name, + session_allowlist=["no-such-session"], + ) + result = await executor("grep_messages", {"text": "Test message"}) + + assert isinstance(result, str) + assert "No messages found" in result + + +@pytest.mark.asyncio +async def test_workspace_prefetch_failure_degrades_to_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prefetch errors must not fail the request (parity with the base + agent's try/except): the agent proceeds with no prefetched block.""" + from src.dialectic.workspace import WorkspaceDialecticAgent + + async def boom(*args: Any, **kwargs: Any) -> Any: + _ = (args, kwargs) + raise RuntimeError("stats query exploded") + + monkeypatch.setattr("src.dialectic.workspace.crud.get_workspace_stats", boom) + + agent = WorkspaceDialecticAgent(workspace_name="w") + result = await agent._prefetch_relevant_observations("q") # pyright: ignore[reportPrivateUsage] + + assert result is None + + +class TestWorkspaceChatPrompt: + def test_teaches_honcho_world_without_sibling_agent(self) -> None: + from src.dialectic.prompts import workspace_agent_system_prompt + + prompt = workspace_agent_system_prompt().lower() + assert "peer-level" not in prompt + assert "unlike a peer" not in prompt + for term in ( + "honcho", + "workspace", + "peer", + "session", + "message", + "conclusion", + "observer", + "observed", + ): + assert term in prompt + + def test_agent_uses_workspace_prompt_and_prefetch_heading(self) -> None: + from src.dialectic.prompts import workspace_agent_system_prompt + from src.dialectic.workspace import WorkspaceDialecticAgent + + agent = WorkspaceDialecticAgent(workspace_name="w") + offered = { + name + for tool in agent._select_tools() # pyright: ignore[reportPrivateUsage] + if isinstance((name := tool.get("name")), str) + } + assert agent.messages[0]["content"] == workspace_agent_system_prompt(offered) + assert agent._prefetch_heading() == "Workspace overview (prefetched)" # pyright: ignore[reportPrivateUsage] diff --git a/tests/unified/README.md b/tests/unified/README.md index 0ef1d987..01f53ebb 100644 --- a/tests/unified/README.md +++ b/tests/unified/README.md @@ -48,19 +48,21 @@ Tests are defined in JSON files. A test definition consists of a name, optional 4. **Querying & Assertions**: * `query`: Perform an action and assert on the result. - * `target`: "chat", "get_context", "get_peer_card", "get_representation" - * `scope`: confine the read to a scope (or, for chat/representation, to - the union of several). Valid for "chat", "get_representation" and - "get_context"; the latter takes a single scope and requires - `observed_peer_id`. + * `target`: "chat", "get_context", "get_peer_card", "get_representation", + "workspace_chat" + * `scope`: confine the read to a scope (or, for chat/representation/ + workspace_chat, to the union of several). Valid for "chat", + "get_representation", "get_context", and "workspace_chat"; get_context + takes a single scope and requires `observed_peer_id`. ### Raw HTTP vs the SDK -Most steps drive the Honcho Python SDK. `create_scope` and any query carrying -`scope` go over raw HTTP instead, because the published SDK trails the API and -exposes neither. Calling the API directly also tests the contract the SDK is -generated from, so a wrong status code or response shape surfaces here rather -than being masked by client-side validation. +Most steps drive the Honcho Python SDK. `create_scope` and scoped `chat` / +`get_representation` / `get_context` queries go over raw HTTP instead, because +the published SDK trails the API and exposes neither. Scoped `workspace_chat` +uses the SDK `scope` argument. Calling the API directly also tests the contract +the SDK is generated from, so a wrong status code or response shape surfaces +here rather than being masked by client-side validation. ### Assertions diff --git a/tests/unified/run.py b/tests/unified/run.py index c0848471..5ab4fb79 100644 --- a/tests/unified/run.py +++ b/tests/unified/run.py @@ -57,7 +57,9 @@ async def main(): tests_dir=test_dir, honcho_port=args.port, api_port=args.api_port ) - await runner.run() + # Non-zero on any failed or unrunnable test, so CI fails on results. + if await runner.run(): + sys.exit(1) if __name__ == "__main__": diff --git a/tests/unified/runner.py b/tests/unified/runner.py index 6d78b06b..c9e9d2e3 100644 --- a/tests/unified/runner.py +++ b/tests/unified/runner.py @@ -386,6 +386,17 @@ class UnifiedTestExecutor: raise TimeoutError("Deriver queue did not empty within timeout") async def perform_query(self, step: QueryAction) -> Any: + if step.target == "workspace_chat": + if step.input is None: + raise ValueError("input required for workspace_chat") + return await self.client.aio.chat( + step.input, + session=step.session_id, + reasoning_level=step.reasoning_level, + response_format=step.response_format, + scope=step.scope, + ) + if step.scope is not None: return await self._perform_scoped_query(step) @@ -626,7 +637,8 @@ class UnifiedTestRunner: AsyncAnthropic(api_key=self.api_key) if self.api_key else None ) - async def run(self): + async def run(self) -> int: + """Run the suite and return the number of tests that did not pass.""" try: # 1. Start Harness logger.info("Starting Honcho Harness...") @@ -784,6 +796,8 @@ class UnifiedTestRunner: await send_discord_message(discord_webhook_url, message) + return failed_count + finally: # 7. Cleanup logger.info("Cleaning up harness...") @@ -798,4 +812,4 @@ if __name__ == "__main__": args = parser.parse_args() runner = UnifiedTestRunner(Path(args.test_dir)) - asyncio.run(runner.run()) + sys.exit(1 if asyncio.run(runner.run()) else 0) diff --git a/tests/unified/schema.py b/tests/unified/schema.py index b0fa84b4..31e30946 100644 --- a/tests/unified/schema.py +++ b/tests/unified/schema.py @@ -149,7 +149,13 @@ class JsonMatchAssertion(Assertion): class QueryAction(TestStep): step_type: Literal["query"] = "query" - target: Literal["chat", "get_context", "get_peer_card", "get_representation"] + target: Literal[ + "chat", + "get_context", + "get_peer_card", + "get_representation", + "workspace_chat", + ] session_id: str | None = None @@ -168,9 +174,9 @@ class QueryAction(TestStep): # for chat - optional JSON Schema the response must conform to response_format: dict[str, Any] | None = None - # Confine the read to one scope (observer swap) or to the union of several - # scopes' member sessions. Forces the raw-HTTP path, since the SDK has no - # `scope` parameter. Valid for chat, get_representation and get_context. + # Confine the read to one scope (observer swap on peer chat) or to the + # union of several scopes' member sessions. Peer-chat/representation/ + # context go over raw HTTP; workspace_chat uses the SDK `scope` argument. scope: str | list[str] | None = None assertions: list[ diff --git a/tests/unified/test_cases/peer_isolation_test.json b/tests/unified/test_cases/peer_isolation_test.json new file mode 100644 index 00000000..2c87fced --- /dev/null +++ b/tests/unified/test_cases/peer_isolation_test.json @@ -0,0 +1,148 @@ +{ + "description": "Test that peer Z cannot access information from a session between X and Y that Z was not part of. This validates session membership scoping - peers should only see messages from sessions they participated in.", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "xy_private_session", + "peer_configs": { + "peer_x": { + "observe_me": true, + "observe_others": true + }, + "peer_y": { + "observe_me": true, + "observe_others": true + }, + "agent": { + "observe_me": false, + "observe_others": true + } + } + }, + { + "step_type": "add_messages", + "session_id": "xy_private_session", + "messages": [ + { + "peer_id": "peer_x", + "content": "Hey peer_y, I need to tell you something important. My secret password is 'strawberry_unicorn_42' - please remember this for later." + }, + { + "peer_id": "agent", + "content": "Got it, I'll remember that." + }, + { + "peer_id": "peer_y", + "content": "Thanks for sharing that, peer_x. I've noted your password: strawberry_unicorn_42. I'll keep it safe." + }, + { + "peer_id": "peer_x", + "content": "Perfect. Also, I'm planning a surprise party for peer_z next Friday at 7pm at the Blue Whale restaurant downtown. Don't tell peer_z!" + }, + { + "peer_id": "peer_y", + "content": "Amazing! I'll be there. The surprise party at Blue Whale will be great. My lips are sealed!" + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "create_session", + "session_id": "z_separate_session", + "peer_configs": { + "peer_z": { + "observe_me": true, + "observe_others": false + }, + "agent": { + "observe_me": false, + "observe_others": true + } + } + }, + { + "step_type": "add_messages", + "session_id": "z_separate_session", + "messages": [ + { + "peer_id": "peer_z", + "content": "Hi! I'm peer_z. I like going to restaurants and attending parties." + }, + { + "peer_id": "agent", + "content": "That's nice! Do you have any upcoming plans?" + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "query", + "target": "chat", + "session_id": "z_separate_session", + "observer_peer_id": "agent", + "observed_peer_id": "peer_z", + "input": "What is peer_x's secret password?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "not_contains", + "text": "strawberry_unicorn_42", + "case_sensitive": false + }, + { + "assertion_type": "llm_judge", + "prompt": "Does the response indicate that the agent DOES NOT KNOW the password, or that the information is not available? The response should NOT reveal the password 'strawberry_unicorn_42'. Return true if the agent correctly says they don't know or the info isn't available.", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "target": "chat", + "session_id": "z_separate_session", + "observer_peer_id": "agent", + "observed_peer_id": "peer_z", + "input": "Is there a surprise party being planned? If so, where and when?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "not_contains", + "text": "Blue Whale", + "case_sensitive": false + }, + { + "assertion_type": "not_contains", + "text": "Friday at 7", + "case_sensitive": false + }, + { + "assertion_type": "llm_judge", + "prompt": "Does the response indicate that the agent DOES NOT KNOW about any surprise party details? The response should NOT mention the Blue Whale restaurant or Friday at 7pm. Return true if the agent correctly says they don't have this information.", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What did peer_x tell peer_y about their password?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "The workspace chat should be able to answer this question since it can access information from all sessions. Does the response mention the password 'strawberry_unicorn_42' or indicate that peer_x shared a password with peer_y? Return true if the workspace-level query CAN see this information.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/workspace_chat_cross_peer.json b/tests/unified/test_cases/workspace_chat_cross_peer.json new file mode 100644 index 00000000..3d474c1d --- /dev/null +++ b/tests/unified/test_cases/workspace_chat_cross_peer.json @@ -0,0 +1,88 @@ +{ + "description": "Test that workspace-level chat can synthesize information across multiple peers in a single response. Three peers have distinct attributes; workspace chat should be able to compare or list them.", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "ws_cross_session", + "peer_configs": { + "dan": { + "observe_me": true, + "observe_others": false + }, + "emma": { + "observe_me": true, + "observe_others": false + }, + "frank": { + "observe_me": true, + "observe_others": false + }, + "agent": { + "observe_me": false, + "observe_others": false + } + } + }, + { + "step_type": "add_messages", + "session_id": "ws_cross_session", + "messages": [ + { + "peer_id": "dan", + "content": "I'm a software engineer specializing in Rust and systems programming. I work at a startup building distributed databases." + }, + { + "peer_id": "agent", + "content": "Interesting! What kind of databases?" + }, + { + "peer_id": "dan", + "content": "We're building a distributed time-series database optimized for IoT sensor data." + }, + { + "peer_id": "emma", + "content": "I'm a marine biologist studying coral reef ecosystems in the Great Barrier Reef. I've been doing field research there for 3 years." + }, + { + "peer_id": "agent", + "content": "That must be fascinating work. What's your focus?" + }, + { + "peer_id": "emma", + "content": "I'm specifically studying the impact of ocean temperature changes on coral bleaching patterns." + }, + { + "peer_id": "frank", + "content": "I'm a pastry chef at a Michelin-starred restaurant in Lyon, France. My specialty is chocolate souffles." + }, + { + "peer_id": "agent", + "content": "That's impressive! How did you get into pastry?" + }, + { + "peer_id": "frank", + "content": "I trained at Le Cordon Bleu in Paris and then apprenticed under Chef Pierre Herme for two years." + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What professions do the people in this workspace have? List each person and what they do.", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention at least 2 of the 3 peers (Dan, Emma, Frank) along with their professions? Dan is a software engineer, Emma is a marine biologist, and Frank is a pastry chef. The response should identify at least 2 of these 3 profession/person pairs correctly.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/workspace_chat_from_messages.json b/tests/unified/test_cases/workspace_chat_from_messages.json new file mode 100644 index 00000000..2fe417bd --- /dev/null +++ b/tests/unified/test_cases/workspace_chat_from_messages.json @@ -0,0 +1,61 @@ +{ + "description": "Test that workspace-level chat can fall back to searching messages directly. Deriver is disabled so no observations are created, forcing the agent to find information from raw message history.", + "workspace_config": { + "reasoning": { + "enabled": false + } + }, + "steps": [ + { + "step_type": "create_session", + "session_id": "ws_msg_session", + "peer_configs": { + "carol": { + "observe_me": true, + "observe_others": false + }, + "agent": { + "observe_me": false, + "observe_others": false + } + } + }, + { + "step_type": "add_messages", + "session_id": "ws_msg_session", + "messages": [ + { + "peer_id": "carol", + "content": "I just finished writing my third novel. It's a mystery set in 1920s Paris called 'The Montmartre Cipher'." + }, + { + "peer_id": "agent", + "content": "Congratulations! That's a great achievement. What inspired the setting?" + }, + { + "peer_id": "carol", + "content": "I lived in Paris for two years and fell in love with the history of Montmartre. The artists and writers who gathered there in the 1920s were fascinating." + } + ] + }, + { + "step_type": "wait", + "duration": 2, + "target": "queue_empty" + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What can you tell me about Carol's writing?", + "session_id": "ws_msg_session", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response reference Carol writing novels or a book? It should mention something about her being a writer/author or her novel. Mentioning 'The Montmartre Cipher' or Paris or mystery is a bonus but not required. The key point is that the system found information about Carol's writing from the message history.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/workspace_chat_from_observations.json b/tests/unified/test_cases/workspace_chat_from_observations.json new file mode 100644 index 00000000..f40eae04 --- /dev/null +++ b/tests/unified/test_cases/workspace_chat_from_observations.json @@ -0,0 +1,85 @@ +{ + "description": "Test that workspace-level chat can gather information about peers from their global representations (observer==observed). Two peers share distinct facts; after deriver processes them, workspace chat should retrieve info about each peer.", + "workspace_config": {}, + "steps": [ + { + "step_type": "create_session", + "session_id": "ws_obs_session", + "peer_configs": { + "alice": { + "observe_me": true, + "observe_others": false + }, + "bob": { + "observe_me": true, + "observe_others": false + }, + "agent": { + "observe_me": false, + "observe_others": false + } + } + }, + { + "step_type": "add_messages", + "session_id": "ws_obs_session", + "messages": [ + { + "peer_id": "alice", + "content": "I'm a professional violinist and I perform with the Chicago Symphony Orchestra every Friday evening." + }, + { + "peer_id": "agent", + "content": "That's wonderful! How long have you been playing?" + }, + { + "peer_id": "alice", + "content": "I've been playing violin since I was 5 years old, so about 25 years now." + }, + { + "peer_id": "bob", + "content": "I just got back from a scuba diving trip in Belize. I'm a certified rescue diver." + }, + { + "peer_id": "agent", + "content": "That sounds amazing! Do you dive often?" + }, + { + "peer_id": "bob", + "content": "Yes, I try to go diving at least twice a month. My favorite dive site is the Great Blue Hole." + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What do you know about Alice's musical background?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention that Alice is a violinist or plays violin? It should reference her connection to music/violin performance. The specific detail about the Chicago Symphony Orchestra is a bonus but not required.", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "What are Bob's hobbies?", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention that Bob is into scuba diving or diving? It should reference his diving hobby. The specific detail about Belize or the Great Blue Hole is a bonus but not required.", + "pass_if": true + } + ] + } + ] +} diff --git a/tests/unified/test_cases/workspace_chat_scope.json b/tests/unified/test_cases/workspace_chat_scope.json new file mode 100644 index 00000000..bf5c7243 --- /dev/null +++ b/tests/unified/test_cases/workspace_chat_scope.json @@ -0,0 +1,97 @@ +{ + "description": "Workspace chat with `scope` only recalls the scope's member sessions.", + "workspace_config": { + "reasoning": { + "enabled": true + } + }, + "steps": [ + { + "step_type": "create_session", + "session_id": "in_scope", + "peer_configs": { + "alice": { + "observe_me": true, + "observe_others": false + } + } + }, + { + "step_type": "create_session", + "session_id": "out_of_scope", + "peer_configs": { + "bob": { + "observe_me": true, + "observe_others": false + } + } + }, + { + "step_type": "create_scope", + "scope_id": "therapy", + "session_ids": ["in_scope"] + }, + { + "step_type": "add_messages", + "session_id": "in_scope", + "messages": [ + { + "peer_id": "alice", + "content": "My favorite tea is jasmine green tea from Hangzhou." + } + ] + }, + { + "step_type": "add_messages", + "session_id": "out_of_scope", + "messages": [ + { + "peer_id": "bob", + "content": "The vault code is 7491-orange-lantern." + } + ] + }, + { + "step_type": "wait", + "duration": 2, + "target": "queue_empty" + }, + { + "step_type": "query", + "target": "workspace_chat", + "scope": "therapy", + "input": "What facts do you know about people in this workspace? Mention any codes or secrets if you have them.", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "not_contains", + "text": "7491-orange-lantern", + "case_sensitive": false + }, + { + "assertion_type": "not_contains", + "text": "scope.therapy", + "case_sensitive": false + }, + { + "assertion_type": "llm_judge", + "prompt": "Does the response mention Alice's jasmine tea (or that Alice likes tea), and does it NOT mention a vault code or 7491? Return true only if both are true.", + "pass_if": true + } + ] + }, + { + "step_type": "query", + "target": "workspace_chat", + "input": "List every peer in this workspace and how many messages each has.", + "reasoning_level": "low", + "assertions": [ + { + "assertion_type": "not_contains", + "text": "scope.therapy", + "case_sensitive": false + } + ] + } + ] +} diff --git a/tests/unified/test_schema.py b/tests/unified/test_schema.py new file mode 100644 index 00000000..250ff314 --- /dev/null +++ b/tests/unified/test_schema.py @@ -0,0 +1,15 @@ +"""Assert every unified JSON case still parses against the schema.""" + +import json +from pathlib import Path + +import pytest + +_CASES = sorted(Path(__file__).parent.joinpath("test_cases").glob("*.json")) + + +@pytest.mark.parametrize("path", _CASES, ids=lambda p: p.name) +def test_unified_case_parses(path: Path) -> None: + from tests.unified.schema import TestDefinition + + TestDefinition(**json.loads(path.read_text())) diff --git a/tests/utils/test_agent_tools.py b/tests/utils/test_agent_tools.py index 34857ec4..321cf178 100644 --- a/tests/utils/test_agent_tools.py +++ b/tests/utils/test_agent_tools.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Callable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock @@ -19,6 +19,7 @@ from src.utils.agent_tools import ( PEER_CARD_ALLOWED_PREFIXES, ObservationsCreatedResult, ToolContext, + _bounded_int, # pyright: ignore[reportPrivateUsage] _handle_create_observations, # pyright: ignore[reportPrivateUsage] _handle_delete_observations, # pyright: ignore[reportPrivateUsage] _handle_extract_preferences, # pyright: ignore[reportPrivateUsage] @@ -81,7 +82,7 @@ async def tool_test_data( await db_session.flush() # Create messages in the session - now = datetime.now(timezone.utc) + now = datetime.now(UTC) messages: list[models.Message] = [] for i in range(5): peer_name = peer2.name if i % 2 == 0 else peer1.name @@ -338,7 +339,9 @@ class TestCreateObservations: """If batch embedding fails but individual embeds succeed, all observations are created.""" workspace, peer1, peer2, session, _, _ = tool_test_data - async def fail_batch_embed(_texts: list[str]) -> list[list[float]]: + async def fail_batch_embed( + _texts: list[str], **_kwargs: object + ) -> list[list[float]]: raise RuntimeError("embedding provider timeout") async def succeed_single_embed(_content: str) -> list[float]: @@ -381,7 +384,7 @@ class TestCreateObservations: session_name=session.name, workspace_name=workspace.name, message_ids=[], - message_created_at=str(datetime.now(timezone.utc)), + message_created_at=str(datetime.now(UTC)), ) assert isinstance(result, ObservationsCreatedResult) @@ -397,7 +400,9 @@ class TestCreateObservations: """If batch embedding fails and some individual embeds also fail, only successful ones are created.""" workspace, peer1, peer2, session, _, _ = tool_test_data - async def fail_batch_embed(_texts: list[str]) -> list[list[float]]: + async def fail_batch_embed( + _texts: list[str], **_kwargs: object + ) -> list[list[float]]: raise RuntimeError("embedding provider timeout") async def embed_per_observation(content: str) -> list[float]: @@ -442,7 +447,7 @@ class TestCreateObservations: session_name=session.name, workspace_name=workspace.name, message_ids=[], - message_created_at=str(datetime.now(timezone.utc)), + message_created_at=str(datetime.now(UTC)), ) assert isinstance(result, ObservationsCreatedResult) @@ -462,7 +467,9 @@ class TestCreateObservations: workspace, peer1, peer2, session, _, _ = tool_test_data created_documents: list[Any] = [] - async def fake_batch_embed(texts: list[str]) -> list[list[float]]: + async def fake_batch_embed( + texts: list[str], **_kwargs: object + ) -> list[list[float]]: assert texts == ["trimmed observation"] return [[0.4, 0.5, 0.6]] @@ -499,7 +506,7 @@ class TestCreateObservations: session_name=session.name, workspace_name=workspace.name, message_ids=[], - message_created_at=str(datetime.now(timezone.utc)), + message_created_at=str(datetime.now(UTC)), ) assert isinstance(result, ObservationsCreatedResult) @@ -508,6 +515,60 @@ class TestCreateObservations: assert len(created_documents) == 1 assert created_documents[0].content == "trimmed observation" + async def test_create_observations_embeds_with_truncate_on_oversize( + self, + tool_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + ): + """Storage path must opt into truncation so one long obs cannot drop the batch.""" + workspace, peer1, peer2, session, _, _ = tool_test_data + captured: dict[str, object] = {} + + async def fake_batch_embed( + texts: list[str], *, on_oversize: str = "raise", **_kwargs: object + ) -> list[list[float]]: + captured["texts"] = texts + captured["on_oversize"] = on_oversize + return [[0.1] for _ in texts] + + async def fake_create_documents( + _db: AsyncSession, + documents: list[Any], + workspace_name: str, + *, + observer: str, + observed: str, + deduplicate: bool = False, + ) -> crud.CreateDocumentsResult: + _ = (workspace_name, observer, observed, deduplicate) + return crud.CreateDocumentsResult(created_documents=documents) + + monkeypatch.setattr( + "src.utils.agent_tools.embedding_client.simple_batch_embed", + fake_batch_embed, + ) + monkeypatch.setattr( + "src.utils.agent_tools.crud.create_documents", fake_create_documents + ) + + result = await create_observations( + observations=[ + schemas.ObservationInput(content="short fact", level="explicit"), + schemas.ObservationInput(content="long fact", level="explicit"), + ], + observer=peer1.name, + observed=peer2.name, + session_name=session.name, + workspace_name=workspace.name, + message_ids=[], + message_created_at=str(datetime.now(UTC)), + ) + + assert isinstance(result, ObservationsCreatedResult) + assert result.created_count == 2 + assert captured["on_oversize"] == "truncate" + assert captured["texts"] == ["short fact", "long fact"] + async def test_create_observations_skips_all_blank_content( self, tool_test_data: Any, @@ -536,7 +597,7 @@ class TestCreateObservations: session_name=session.name, workspace_name=workspace.name, message_ids=[], - message_created_at=str(datetime.now(timezone.utc)), + message_created_at=str(datetime.now(UTC)), ) assert isinstance(result, ObservationsCreatedResult) @@ -545,6 +606,62 @@ class TestCreateObservations: batch_embed.assert_not_awaited() create_documents.assert_not_awaited() + @pytest.mark.parametrize("deduplicate_setting", [True, False]) + async def test_create_observations_honors_deduplicate_setting( + self, + tool_test_data: Any, + monkeypatch: pytest.MonkeyPatch, + deduplicate_setting: bool, + ): + """create_observations forwards settings.DERIVER.DEDUPLICATE to create_documents. + + Guards against reintroducing a hardcoded deduplicate=True, which made + DERIVER_DEDUPLICATE=false unable to disable dedup on this path (#989). + """ + workspace, peer1, peer2, session, _, _ = tool_test_data + monkeypatch.setattr(settings.DERIVER, "DEDUPLICATE", deduplicate_setting) + + captured: dict[str, Any] = {} + + async def fake_batch_embed(texts: list[str]) -> list[list[float]]: + return [[0.1, 0.2, 0.3] for _ in texts] + + async def fake_create_documents( + _db: AsyncSession, + documents: list[Any], + workspace_name: str, + *, + observer: str, + observed: str, + deduplicate: bool = False, + ) -> crud.CreateDocumentsResult: + _ = (workspace_name, observer, observed) + captured["deduplicate"] = deduplicate + return crud.CreateDocumentsResult(created_documents=documents) + + monkeypatch.setattr( + "src.utils.agent_tools.embedding_client.simple_batch_embed", + fake_batch_embed, + ) + monkeypatch.setattr( + "src.utils.agent_tools.crud.create_documents", fake_create_documents + ) + + result = await create_observations( + observations=[ + schemas.ObservationInput(content="An observation", level="explicit"), + ], + observer=peer1.name, + observed=peer2.name, + session_name=session.name, + workspace_name=workspace.name, + message_ids=[], + message_created_at=str(datetime.now(UTC)), + ) + + assert isinstance(result, ObservationsCreatedResult) + assert captured["deduplicate"] is deduplicate_setting + class TestNormalizeObservationId: """Unit tests for _normalize_observation_id.""" @@ -818,7 +935,7 @@ class TestSearchMemory: content="Relevant fallback message", seq_in_session=1, token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) return [([msg], [msg])] @@ -840,6 +957,21 @@ class TestSearchMemory: assert query_embeddings[0] == fallback_embeddings[0] +class TestBoundedInt: + """Unit tests for tool-input clamping.""" + + def test_floors_nonpositive_to_one(self) -> None: + assert _bounded_int(0, 10, hi=20) == 1 + assert _bounded_int(-5, 10, hi=20) == 1 + + def test_caps_at_hi(self) -> None: + assert _bounded_int(100, 10, hi=20) == 20 + + def test_falls_back_on_bad_input(self) -> None: + assert _bounded_int("Infinity", 10, hi=20) == 10 + assert _bounded_int(None, 10, hi=20) == 10 + + @pytest.mark.asyncio class TestSearchMessages: """Tests for _handle_search_messages.""" @@ -859,6 +991,39 @@ class TestSearchMessages: assert isinstance(result, str | ToolResult) + async def test_limit_zero_is_floored_to_one( + self, + make_tool_context: Callable[..., ToolContext], + monkeypatch: pytest.MonkeyPatch, + ): + """LLM-supplied limit=0 must not reach the vector store as top_k=0.""" + ctx = make_tool_context() + seen_limits: list[int] = [] + + async def fake_embed(query: str) -> list[float]: + _ = query + return [0.1, 0.2, 0.3] + + async def fake_search_messages( + workspace_name: str, + session_name: str | None, + query: str, + limit: int = 10, + **_kwargs: Any, + ) -> list[Any]: + _ = (workspace_name, session_name, query) + seen_limits.append(limit) + return [] + + monkeypatch.setattr("src.utils.agent_tools.embedding_client.embed", fake_embed) + monkeypatch.setattr( + "src.utils.agent_tools.crud.search_messages", fake_search_messages + ) + + await _handle_search_messages(ctx, {"query": "anything", "limit": 0}) + + assert seen_limits == [1] + @pytest.mark.asyncio class TestGrepMessages: @@ -936,7 +1101,7 @@ class TestSearchMessagesTemporal: content="Relevant temporal fallback message", seq_in_session=1, token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) return [([msg], [msg])] @@ -971,7 +1136,7 @@ class TestGetMessagesByDateRange: ctx = make_tool_context() # Get messages from today - today = datetime.now(timezone.utc).date().isoformat() + today = datetime.now(UTC).date().isoformat() result = await _handle_get_messages_by_date_range( ctx, {"after_date": today, "limit": 10} ) @@ -1560,7 +1725,7 @@ class TestExtractPreferences: content="I prefer brief responses and always include code examples", seq_in_session=100, token_count=20, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) db_session.add(preference_msg) await db_session.flush() @@ -1609,7 +1774,7 @@ class TestExtractPreferences: content=f"Relevant from {query}", seq_in_session=1, token_count=5, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) return [([msg], [])] diff --git a/tests/utils/test_filter.py b/tests/utils/test_filter.py index 68a438db..a6b84a42 100644 --- a/tests/utils/test_filter.py +++ b/tests/utils/test_filter.py @@ -228,6 +228,20 @@ def test_ne_is_null_safe(): assert "IS DISTINCT FROM" in where +def test_nested_metadata_ne_string_is_null_safe(): + """`ne` on a JSONB metadata key went through the operator map as plain <>, + unlike the scalar path, so a row missing that key was silently dropped.""" + where = _where(Document, {"metadata": {"priority": {"ne": "high"}}}) + assert "IS DISTINCT FROM" in where + assert "!=" not in where + + +def test_nested_metadata_ne_numeric_is_null_safe(): + where = _where(Document, {"metadata": {"score": {"ne": 5}}}) + assert "IS DISTINCT FROM" in where + assert "!=" not in where + + def test_not_is_null_safe_over_a_compound_condition(): """Negation has to survive nesting, not just single comparisons.""" where = _where( diff --git a/tests/utils/test_sanitization.py b/tests/utils/test_sanitization.py new file mode 100644 index 00000000..dc84647f --- /dev/null +++ b/tests/utils/test_sanitization.py @@ -0,0 +1,38 @@ +from typing import Any + +import pytest + +from src.utils.sanitization import strip_nul + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param("before\x00after", "beforeafter", id="string"), + pytest.param("no nul here", "no nul here", id="string-unchanged"), + pytest.param("\x00\x00", "", id="string-all-nul"), + pytest.param(["a\x00b", "c"], ["ab", "c"], id="list"), + pytest.param({"k\x00": "v\x00"}, {"k": "v"}, id="dict-key-and-value"), + pytest.param( + {"a": [{"b": "c\x00d"}]}, + {"a": [{"b": "cd"}]}, + id="nested", + ), + # Optional fields are passed in without a guard, so None has to survive. + pytest.param(None, None, id="none"), + pytest.param(7, 7, id="int"), + pytest.param(True, True, id="bool"), + pytest.param([], [], id="empty-list"), + ], +) +def test_strip_nul(value: Any, expected: Any) -> None: + assert strip_nul(value) == expected + + +def test_strip_nul_does_not_mutate_its_argument() -> None: + original = {"a": ["b\x00c"]} + + stripped = strip_nul(original) + + assert stripped == {"a": ["bc"]} + assert original == {"a": ["b\x00c"]} diff --git a/tests/vector_store/test_lancedb.py b/tests/vector_store/test_lancedb.py index 9b52708a..0d36dc56 100644 --- a/tests/vector_store/test_lancedb.py +++ b/tests/vector_store/test_lancedb.py @@ -170,3 +170,24 @@ async def test_query_filters_by_max_distance(store: LanceDBVectorStore) -> None: ) assert [r.id for r in results] == ["vec_close"] + + +@pytest.mark.asyncio +async def test_query_returns_empty_without_opening_table_on_nonpositive_top_k( + store: LanceDBVectorStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + get_table = AsyncMock(return_value=MagicMock()) + monkeypatch.setattr(store, "_get_table", get_table) + + for top_k in (0, -1): + assert ( + await store.query( + "honcho.msg.test", + [0.1, 0.2, 0.3, 0.4], + top_k=top_k, + ) + == [] + ) + + get_table.assert_not_awaited() diff --git a/tests/vector_store/test_turbopuffer.py b/tests/vector_store/test_turbopuffer.py index cdae44d5..f7ada717 100644 --- a/tests/vector_store/test_turbopuffer.py +++ b/tests/vector_store/test_turbopuffer.py @@ -141,3 +141,26 @@ async def test_query_can_skip_attributes( namespace_mock.query.assert_awaited_once() assert namespace_mock.query.await_args.kwargs["include_attributes"] is False + + +@pytest.mark.asyncio +async def test_query_returns_empty_without_calling_api_on_nonpositive_top_k( + store: TurbopufferVectorStore, +) -> None: + """Turbopuffer rejects top_k < 1; never hit the network with a bad value.""" + namespace_mock = MagicMock() + namespace_mock.query = AsyncMock() + store._get_namespace = MagicMock(return_value=namespace_mock) # pyright: ignore[reportPrivateUsage] + + for top_k in (0, -1): + assert ( + await store.query( + "honcho.msg.test", + [0.1, 0.2, 0.3, 0.4], + top_k=top_k, + ) + == [] + ) + + store._get_namespace.assert_not_called() # pyright: ignore[reportPrivateUsage] + namespace_mock.query.assert_not_awaited() diff --git a/uv.lock b/uv.lock index 82f85e04..0e84663f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", -] +requires-python = ">=3.13" [options] -exclude-newer = "2026-08-07T23:49:08.393963Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P5D" [manifest] @@ -42,40 +37,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, @@ -135,7 +96,6 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -199,22 +159,12 @@ version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -305,31 +255,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, @@ -381,38 +306,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, @@ -503,36 +396,6 @@ version = "7.13.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, @@ -596,11 +459,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - [[package]] name = "cryptography" version = "48.0.0" @@ -652,12 +510,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] [[package]] @@ -806,38 +658,6 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, @@ -962,26 +782,6 @@ version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, - { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, - { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, - { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, - { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, - { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, - { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, @@ -1024,7 +824,7 @@ wheels = [ [[package]] name = "honcho" -version = "3.0.12" +version = "3.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -1138,12 +938,11 @@ dev = [ [[package]] name = "honcho-ai" -version = "2.3.0" +version = "2.4.0" source = { editable = "sdks/python" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] [package.optional-dependencies] @@ -1170,7 +969,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }] [[package]] name = "honcho-cli" -version = "0.1.2" +version = "0.1.4" source = { editable = "honcho-cli" } dependencies = [ { name = "click" }, @@ -1217,20 +1016,6 @@ version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, @@ -1335,34 +1120,6 @@ version = "0.14.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, - { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, - { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, - { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, @@ -1409,14 +1166,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, ] [[package]] @@ -1481,7 +1230,6 @@ dependencies = [ { name = "deprecation" }, { name = "lance-namespace" }, { name = "numpy" }, - { name = "overrides", marker = "python_full_version < '3.12'" }, { name = "packaging" }, { name = "pyarrow" }, { name = "pydantic" }, @@ -1545,28 +1293,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, @@ -1628,42 +1354,6 @@ version = "6.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, @@ -1779,28 +1469,6 @@ version = "2.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, - { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, - { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, - { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, - { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, - { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, - { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, @@ -1843,13 +1511,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, - { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] [[package]] @@ -1959,36 +1620,6 @@ version = "3.11.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, - { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, - { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, - { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, - { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, - { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, - { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, - { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, - { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, - { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, - { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, - { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, - { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, @@ -2021,15 +1652,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] -[[package]] -name = "overrides" -version = "7.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -2084,28 +1706,6 @@ version = "12.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, @@ -2156,13 +1756,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] @@ -2214,40 +1807,6 @@ version = "0.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, - { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, - { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, - { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, - { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, - { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, - { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, - { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, - { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, @@ -2339,7 +1898,6 @@ name = "psycopg" version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } @@ -2357,28 +1915,6 @@ name = "psycopg-binary" version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, - { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, - { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, - { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, - { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, - { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, - { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, - { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, - { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, - { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, - { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, @@ -2433,20 +1969,6 @@ version = "24.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, - { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, - { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, - { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, @@ -2504,44 +2026,6 @@ version = "1.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673, upload-time = "2025-12-06T13:22:53.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331, upload-time = "2025-12-06T13:22:54.197Z" }, - { url = "https://files.pythonhosted.org/packages/64/15/8d60b9ec5e658185fc2ee3333e01a6e30d717cf677b24f47cbb3a859d13c/pybase64-1.4.3-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95a57cccf106352a72ed8bc8198f6820b16cc7d55aa3867a16dea7011ae7c218", size = 71370, upload-time = "2025-12-06T13:22:55.517Z" }, - { url = "https://files.pythonhosted.org/packages/ac/29/a3e5c1667cc8c38d025a4636855de0fc117fc62e2afeb033a3c6f12c6a22/pybase64-1.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cd1c47dfceb9c7bd3de210fb4e65904053ed2d7c9dce6d107f041ff6fbd7e21", size = 59834, upload-time = "2025-12-06T13:22:56.682Z" }, - { url = "https://files.pythonhosted.org/packages/a9/00/8ffcf9810bd23f3984698be161cf7edba656fd639b818039a7be1d6405d4/pybase64-1.4.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9fe9922698f3e2f72874b26890d53a051c431d942701bb3a37aae94da0b12107", size = 56652, upload-time = "2025-12-06T13:22:57.724Z" }, - { url = "https://files.pythonhosted.org/packages/81/62/379e347797cdea4ab686375945bc77ad8d039c688c0d4d0cfb09d247beb9/pybase64-1.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:af5f4bd29c86b59bb4375e0491d16ec8a67548fa99c54763aaedaf0b4b5a6632", size = 59382, upload-time = "2025-12-06T13:22:58.758Z" }, - { url = "https://files.pythonhosted.org/packages/c6/f2/9338ffe2f487086f26a2c8ca175acb3baa86fce0a756ff5670a0822bb877/pybase64-1.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c302f6ca7465262908131411226e02100f488f531bb5e64cb901aa3f439bccd9", size = 59990, upload-time = "2025-12-06T13:23:01.007Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a4/85a6142b65b4df8625b337727aa81dc199642de3d09677804141df6ee312/pybase64-1.4.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2f3f439fa4d7fde164ebbbb41968db7d66b064450ab6017c6c95cef0afa2b349", size = 54923, upload-time = "2025-12-06T13:23:02.369Z" }, - { url = "https://files.pythonhosted.org/packages/ac/00/e40215d25624012bf5b7416ca37f168cb75f6dd15acdb91ea1f2ea4dc4e7/pybase64-1.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a23c6866551043f8b681a5e1e0d59469148b2920a3b4fc42b1275f25ea4217a", size = 58664, upload-time = "2025-12-06T13:23:03.378Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/d7e19a63e795c13837f2356268d95dc79d1180e756f57ced742a1e52fdeb/pybase64-1.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:56e6526f8565642abc5f84338cc131ce298a8ccab696b19bdf76fa6d7dc592ef", size = 52338, upload-time = "2025-12-06T13:23:04.458Z" }, - { url = "https://files.pythonhosted.org/packages/f2/32/3c746d7a310b69bdd9df77ffc85c41b80bce00a774717596f869b0d4a20e/pybase64-1.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6a792a8b9d866ffa413c9687d9b611553203753987a3a582d68cbc51cf23da45", size = 68993, upload-time = "2025-12-06T13:23:05.526Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b3/63cec68f9d6f6e4c0b438d14e5f1ef536a5fe63ce14b70733ac5e31d7ab8/pybase64-1.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62ad29a5026bb22cfcd1ca484ec34b0a5ced56ddba38ceecd9359b2818c9c4f9", size = 58055, upload-time = "2025-12-06T13:23:06.931Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cb/7acf7c3c06f9692093c07f109668725dc37fb9a3df0fa912b50add645195/pybase64-1.4.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11b9d1d2d32ec358c02214363b8fc3651f6be7dd84d880ecd597a6206a80e121", size = 54430, upload-time = "2025-12-06T13:23:07.936Z" }, - { url = "https://files.pythonhosted.org/packages/33/39/4eb33ff35d173bfff4002e184ce8907f5d0a42d958d61cd9058ef3570179/pybase64-1.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0aebaa7f238caa0a0d373616016e2040c6c879ebce3ba7ab3c59029920f13640", size = 56272, upload-time = "2025-12-06T13:23:09.253Z" }, - { url = "https://files.pythonhosted.org/packages/19/97/a76d65c375a254e65b730c6f56bf528feca91305da32eceab8bcc08591e6/pybase64-1.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e504682b20c63c2b0c000e5f98a80ea867f8d97642e042a5a39818e44ba4d599", size = 70904, upload-time = "2025-12-06T13:23:10.336Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2c/8338b6d3da3c265002839e92af0a80d6db88385c313c73f103dfb800c857/pybase64-1.4.3-cp311-cp311-win32.whl", hash = "sha256:e9a8b81984e3c6fb1db9e1614341b0a2d98c0033d693d90c726677db1ffa3a4c", size = 33639, upload-time = "2025-12-06T13:23:11.9Z" }, - { url = "https://files.pythonhosted.org/packages/39/dc/32efdf2f5927e5449cc341c266a1bbc5fecd5319a8807d9c5405f76e6d02/pybase64-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:a90a8fa16a901fabf20de824d7acce07586e6127dc2333f1de05f73b1f848319", size = 35797, upload-time = "2025-12-06T13:23:13.174Z" }, - { url = "https://files.pythonhosted.org/packages/da/59/eda4f9cb0cbce5a45f0cd06131e710674f8123a4d570772c5b9694f88559/pybase64-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:61d87de5bc94d143622e94390ec3e11b9c1d4644fe9be3a81068ab0f91056f59", size = 31160, upload-time = "2025-12-06T13:23:15.696Z" }, - { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167, upload-time = "2025-12-06T13:23:16.821Z" }, - { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673, upload-time = "2025-12-06T13:23:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210, upload-time = "2025-12-06T13:23:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599, upload-time = "2025-12-06T13:23:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922, upload-time = "2025-12-06T13:23:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712, upload-time = "2025-12-06T13:23:22.77Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300, upload-time = "2025-12-06T13:23:24.543Z" }, - { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278, upload-time = "2025-12-06T13:23:25.608Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817, upload-time = "2025-12-06T13:23:26.633Z" }, - { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611, upload-time = "2025-12-06T13:23:27.684Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404, upload-time = "2025-12-06T13:23:28.714Z" }, - { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817, upload-time = "2025-12-06T13:23:30.079Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854, upload-time = "2025-12-06T13:23:31.17Z" }, - { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333, upload-time = "2025-12-06T13:23:32.422Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492, upload-time = "2025-12-06T13:23:33.515Z" }, - { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974, upload-time = "2025-12-06T13:23:36.27Z" }, - { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686, upload-time = "2025-12-06T13:23:37.848Z" }, - { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833, upload-time = "2025-12-06T13:23:38.877Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185, upload-time = "2025-12-06T13:23:39.908Z" }, { url = "https://files.pythonhosted.org/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1", size = 33901, upload-time = "2025-12-06T13:23:40.951Z" }, { url = "https://files.pythonhosted.org/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2", size = 40807, upload-time = "2025-12-06T13:23:42.006Z" }, { url = "https://files.pythonhosted.org/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d", size = 30932, upload-time = "2025-12-06T13:23:43.348Z" }, @@ -2628,22 +2112,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab", size = 34513, upload-time = "2025-12-06T13:25:24.592Z" }, { url = "https://files.pythonhosted.org/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76", size = 36702, upload-time = "2025-12-06T13:25:25.72Z" }, { url = "https://files.pythonhosted.org/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9", size = 31904, upload-time = "2025-12-06T13:25:26.826Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/545fd4935a0e1ddd7147f557bf8157c73eecec9cffd523382fa7af2557de/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_10_9_x86_64.whl", hash = "sha256:d27c1dfdb0c59a5e758e7a98bd78eaca5983c22f4a811a36f4f980d245df4611", size = 38393, upload-time = "2025-12-06T13:26:19.535Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ca/ae7a96be9ddc96030d4e9dffc43635d4e136b12058b387fd47eb8301b60f/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0f1a0c51d6f159511e3431b73c25db31095ee36c394e26a4349e067c62f434e5", size = 32109, upload-time = "2025-12-06T13:26:20.72Z" }, - { url = "https://files.pythonhosted.org/packages/bf/44/d4b7adc7bf4fd5b52d8d099121760c450a52c390223806b873f0b6a2d551/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a492518f3078a4e3faaef310697d21df9c6bc71908cebc8c2f6fbfa16d7d6b1f", size = 43227, upload-time = "2025-12-06T13:26:21.845Z" }, - { url = "https://files.pythonhosted.org/packages/08/86/2ba2d8734ef7939debeb52cf9952e457ba7aa226cae5c0e6dd631f9b851f/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae1a0f47784fd16df90d8acc32011c8d5fcdd9ab392c9ec49543e5f6a9c43a4", size = 35804, upload-time = "2025-12-06T13:26:23.149Z" }, - { url = "https://files.pythonhosted.org/packages/4f/5b/19c725dc3aaa6281f2ce3ea4c1628d154a40dd99657d1381995f8096768b/pybase64-1.4.3-graalpy311-graalpy242_311_native-win_amd64.whl", hash = "sha256:03cea70676ffbd39a1ab7930a2d24c625b416cacc9d401599b1d29415a43ab6a", size = 35880, upload-time = "2025-12-06T13:26:24.663Z" }, - { url = "https://files.pythonhosted.org/packages/17/45/92322aec1b6979e789b5710f73c59f2172bc37c8ce835305434796824b7b/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:2baaa092f3475f3a9c87ac5198023918ea8b6c125f4c930752ab2cbe3cd1d520", size = 38746, upload-time = "2025-12-06T13:26:25.869Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/f1a07402870388fdfc2ecec0c718111189732f7d0f2d7fe1386e19e8fad0/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:cde13c0764b1af07a631729f26df019070dad759981d6975527b7e8ecb465b6c", size = 32573, upload-time = "2025-12-06T13:26:27.792Z" }, - { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939, upload-time = "2025-12-06T13:26:41.014Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466, upload-time = "2025-12-06T13:26:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681, upload-time = "2025-12-06T13:26:43.782Z" }, - { url = "https://files.pythonhosted.org/packages/12/d7/6610f34a8972415fab3bb4704c174a1cc477bffbc3c36e526428d0f3957d/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af6d0d3a691911cc4c9a625f3ddcd3af720738c21be3d5c72de05629139d393", size = 41294, upload-time = "2025-12-06T13:26:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/64/25/ed24400948a6c974ab1374a233cb7e8af0a5373cea0dd8a944627d17c34a/pybase64-1.4.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfc8c49a28322d82242088378f8542ce97459866ba73150b062a7073e82629d", size = 35447, upload-time = "2025-12-06T13:26:46.098Z" }, - { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134, upload-time = "2025-12-06T13:26:47.35Z" }, ] [[package]] @@ -2679,36 +2147,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, @@ -2754,22 +2192,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -2868,7 +2290,6 @@ version = "1.3.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/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -2880,7 +2301,7 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -2963,25 +2384,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, @@ -3016,9 +2418,6 @@ wheels = [ name = "redis" version = "7.4.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, @@ -3030,38 +2429,6 @@ version = "2026.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/7a/617356cbecdb452812a5d42f720d6d5096b360d4a4c1073af700ea140ad2/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", size = 489415, upload-time = "2026-04-03T20:53:11.645Z" }, - { url = "https://files.pythonhosted.org/packages/20/e6/bf057227144d02e3ba758b66649e87531d744dda5f3254f48660f18ae9d8/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", size = 291205, upload-time = "2026-04-03T20:53:13.289Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3b/637181b787dd1a820ba1c712cee2b4144cd84a32dc776ca067b12b2d70c8/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", size = 289225, upload-time = "2026-04-03T20:53:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/05/21/bac05d806ed02cd4b39d9c8e5b5f9a2998c94c3a351b7792e80671fa5315/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", size = 792434, upload-time = "2026-04-03T20:53:17.414Z" }, - { url = "https://files.pythonhosted.org/packages/d9/17/c65d1d8ae90b772d5758eb4014e1e011bb2db353fc4455432e6cc9100df7/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", size = 861730, upload-time = "2026-04-03T20:53:18.903Z" }, - { url = "https://files.pythonhosted.org/packages/ad/64/933321aa082a2c6ee2785f22776143ba89840189c20d3b6b1d12b6aae16b/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", size = 906495, upload-time = "2026-04-03T20:53:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/01/ea/4c8d306e9c36ac22417336b1e02e7b358152c34dc379673f2d331143725f/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", size = 799810, upload-time = "2026-04-03T20:53:22.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/ce/7605048f00e1379eba89d610c7d644d8f695dc9b26d3b6ecfa3132b872ff/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", size = 774242, upload-time = "2026-04-03T20:53:25.015Z" }, - { url = "https://files.pythonhosted.org/packages/e9/77/283e0d5023fde22cd9e86190d6d9beb21590a452b195ffe00274de470691/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", size = 781257, upload-time = "2026-04-03T20:53:26.918Z" }, - { url = "https://files.pythonhosted.org/packages/8b/fb/7f3b772be101373c8626ed34c5d727dcbb8abd42a7b1219bc25fd9a3cc04/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", size = 854490, upload-time = "2026-04-03T20:53:29.065Z" }, - { url = "https://files.pythonhosted.org/packages/85/30/56547b80f34f4dd2986e1cdd63b1712932f63b6c4ce2f79c50a6cd79d1c2/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", size = 763544, upload-time = "2026-04-03T20:53:30.917Z" }, - { url = "https://files.pythonhosted.org/packages/ac/2f/ce060fdfea8eff34a8997603532e44cdb7d1f35e3bc253612a8707a90538/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", size = 844442, upload-time = "2026-04-03T20:53:32.463Z" }, - { url = "https://files.pythonhosted.org/packages/e5/44/810cb113096a1dacbe82789fbfab2823f79d19b7f1271acecb7009ba9b88/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", size = 789162, upload-time = "2026-04-03T20:53:34.039Z" }, - { url = "https://files.pythonhosted.org/packages/20/96/9647dd7f2ecf6d9ce1fb04dfdb66910d094e10d8fe53e9c15096d8aa0bd2/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", size = 266227, upload-time = "2026-04-03T20:53:35.601Z" }, - { url = "https://files.pythonhosted.org/packages/33/80/74e13262460530c3097ff343a17de9a34d040a5dc4de9cf3a8241faab51c/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", size = 278399, upload-time = "2026-04-03T20:53:37.021Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/39f19f47f19dcefa3403f09d13562ca1c0fd07ab54db2bc03148f3f6b46a/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", size = 270473, upload-time = "2026-04-03T20:53:38.633Z" }, - { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, - { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, - { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, - { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, - { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, - { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, - { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, - { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, - { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, - { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, - { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, - { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, @@ -3219,18 +2586,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, - { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, - { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, - { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, - { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, - { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, - { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, - { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, @@ -3266,26 +2621,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, - { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, - { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, - { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, - { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, - { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, - { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, @@ -3389,20 +2724,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" }, - { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" }, - { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" }, - { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" }, - { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, @@ -3450,7 +2771,6 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ @@ -3494,20 +2814,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, - { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, - { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, @@ -3538,60 +2844,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] -[[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 = "tqdm" version = "4.67.3" @@ -3708,18 +2960,6 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, @@ -3764,32 +3004,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, @@ -3836,10 +3050,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] [[package]] @@ -3848,24 +3058,6 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, @@ -3893,11 +3085,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] @@ -3907,26 +3094,6 @@ version = "1.17.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, @@ -3971,42 +3138,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, - { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, - { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, - { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, - { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, - { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },