Hermes adapter
protagine-hermes is one wheel with two Hermes plugins:
protagine(general): turn capture, the guard,/mind, and the self, people, opinions, memory and reminder toolsprotagine-memory(memory provider): per-turn recall through/v1/host/context/assemble, turn sync when the general plugin is absent, and the owner's commitment and affect writes
Both run on stock Hermes (hermes-agent >= 0.21.3, < 0.22) with no patches.
Install
pipx install protagine && protagine init && hermes gateway restart
protagine init installs protagine-hermes into the Python of the hermes
executable, writes the config keys below and creates the protagine-act
worker profile. protagine upgrade repeats the adapter install when the
version changed.
plugins:
enabled: [protagine]
hook_callback_timeout: 0 # callbacks run inline; overlapping fires are not dropped
protagine:
sidecar_url: http://127.0.0.1:7777
key_file: ~/.protagine/api.key # one line, mode 600
memory:
provider: protagine-memory
kanban:
dispatch_in_gateway: true # the plugin's heartbeat is the dispatch tick
skills:
external_dirs: [~/.protagine/skills] # Protagine's own skills (with faculties.skills on); nothing else writes there
security:
protected_instruction_extra_patterns: [protagine.yaml, identity.yaml, api.key]
Every request to the sidecar carries Authorization: Bearer <key>. The key is bound to the contacts and
channels it may write as (CREDENTIALS.md): it writes a turn as the owner only from the
platforms protagine.yaml credentials.api-key.owner lists (by default WhatsApp, SMS, RCS and voice).
Seams used
| Need | Stock seam |
|---|---|
| Who is speaking | pre_llm_call (session_id, platform, sender_id, user_message), kept in a bounded session map |
| Turn capture | post_llm_call enqueues one row in <hermes_home>/state/protagine-turn-outbox.sqlite3; the body thread delivers it to POST /v1/host/turns/sync |
| Recall | memory provider prefetch |
| Guard | pre_tool_call; a block directive fails closed, a floor match becomes an approve directive (Hermes' human-approval gate, which fails closed in a worker) |
| Heartbeat | on_kanban_dispatch_tick wakes the body thread; stock fires it from dispatch_once in the one process holding the dispatcher lock, so it also marks this process as the board's writer |
| Pause | agent.estop.is_engaged() (hermes pause): the body creates and sends nothing while it holds |
| DM lookup | the stock session rows (hermes_state_registry, list_gateway_sessions): the DM chat the gateway has had with a sender, for handles that name a user rather than a chat (Discord) |
| Durable work | hermes_cli.kanban_db.create_task(idempotency_key="mind:<id>", assignee="protagine-act", ...); an existing non-archived task with the key is returned, never duplicated |
| Messages | tools.send_message_tool.send_message_tool({"action": "send", "target", "message"}), text verbatim |
| Task outcomes | the mind:* task's status plus its latest task_runs row (kanban_db.latest_run) |
| Owner commands | register_command("mind"): read-only in chat plus off; pre_gateway_dispatch drops a /mind whose sender is not the owner, since the command handler gets no sender |
| Tools | register_tool; handlers receive session_id, which the session map resolves to a sender |
| Reminders | stock cron: a one-shot job whose id is the only thing the plugin keeps |
| Skill loads | on_skill_lifecycle: a loaded fact for a protagine-* skill is queued in the turn outbox as a skill_use row, which the body delivers to POST /v1/mind/skills/used; every other skill and action is Hermes' own |
| Skills index | skills.external_dirs lists <instance>/skills; when /v1/mind/state reports a new skills.generation, the body calls agent.prompt_builder.clear_skills_system_prompt_cache() so the next session lists the change (stock caches the index per process with no file times in its key); without that function a change shows after a restart |
| Secrets | a second pre_gateway_dispatch seals a gateway message's secrets (and the quoted message and backfill that come with it) before Hermes sees them; register_middleware("tool_request") serves the owner's sealed values to his own calls (into a send's or a write's words, or a command's or code's environment for that call) or hides them in a worker's; a second pre_tool_call holds a call whose value waits for the owner or cannot be used there; post_tool_call removes a call's private value files unless the program that took one still runs; register_redaction_patterns and agent.redact.register_vault_redaction_value keep used values out of model-facing output; protagine_secret (see Secrets) |
Owner and guest
The owner is the sender whose handle is listed in identity.yaml
(owner.handles.<platform>) or whose resolved contact is owner.contact_id.
Turns without a sender on the CLI, cron or API lanes are the owner's own for
the plugin's tools; a cron run (platform: cron) is a stored prompt rather than the
owner typing, so it cannot answer an ask, rate, change a permission or
forget. In memory a turn is the owner's only when it arrives on a platform the key
is granted the owner on: every turn names its platform (the sender's, the lane
itself: cli, cron, api, or the platform the memory provider was initialized
on when no session binds one). A turn the sidecar refuses (owner_not_granted) is
never sent again as another contact: the adapter logs it and counts it as a failed
capture (the outbox row keeps the refusal and /mind status counts it; the memory
provider's diagnostics count attribution_refusals). Grant the CLI with
protagine init --owner-platform ... or protagine credentials set api-key --owner-platform ... to keep a CLI conversation as the owner's. Any other sender is a guest: session_search is blocked for guests,
delivering cron jobs need a recipient whose may_contact is not never,
every mutation through protagine_self, protagine_people and
protagine_memory_forget is refused, protagine_people who without a name is
refused (a guest may ask who one named person is, never list the contacts), and
the plugin drops a guest's /mind at pre_gateway_dispatch, because Hermes
hands the command itself no sender.
Mind-originated runs
A kanban worker spawned on the protagine-act profile is a mind-originated
run (the dispatcher sets HERMES_KANBAN_TASK and HERMES_PROFILE). In it:
kanban_createonly withassignee: protagine-act, nomind:key, no project override and no workspace outsideHERMES_KANBAN_WORKSPACEwrite_fileandpatchonly insideHERMES_KANBAN_WORKSPACE, every V4A header target includedmind.deny.toolsandmind.deny.textfromprotagine.yamlblock; floor patterns (money, irreversible deletion, credentials, bulk messaging) ask- messaging tools and delivering cron jobs need a permitted recipient: the
plugin resolves a job's effective
deliverandfailure_delivercontacts (a stored job's when an update does not resend them) and hands them to the sidecar asrecipients, which appliesmay_contactand the message budgets to each of them exactly as it does to asend_messagerecipient - with
mind.enabled: falseorautonomy: off, every effectful tool is blocked
The guard asks POST /v1/mind/guard {tool, args, session, run, task_id, owner, contact_id, platform, sender_id, recipients} with a 2 s timeout when the
sidecar serves the mind routes and reads {allow, reason} (ask: true becomes
Hermes' approval gate in the owner's and the mind's runs); silence blocks effectful tools, a 404 falls back
to the rules above. Read-only tools never wait on the sidecar.
In a contact's (non-owner) session nothing becomes Hermes' approval gate: in a gateway that gate posts its
prompt to the session's own chat and waits for /approve or a bare "yes" from it, so the contact would be
the one approving. There an ask verdict is refused, and every refusal is the plugin's one final answer
(retry: false), worded for a reply the contact reads. The final response is delivered to the session's
own chat and nowhere else: the chat the gateway bound for the turn (a group's chat in a group), or, with no
gateway, the sender's direct chat. A message to that chat is answered with "your final response is
delivered to this conversation as your reply": the reply already is that message. Anything else, another
handle of the sender's and a direct message to the sender from a group included, is a recipient for the
sidecar's verdict; a target no contact is known at is answered "nothing was sent: no contact is known at
that target".
The body: the mind's effects on Hermes
The body thread runs in every Hermes process, but only the one that owns the
kanban dispatcher writes to the board: stock fires on_kanban_dispatch_tick
from dispatch_once in the process holding the singleton dispatcher lock, and
the body runs steps 2 to 5 only after it has seen that tick. A CLI session, a
gateway that lost the lock and a kanban worker drain the turn outbox only; the
first two also read GET /v1/mind/state on every tick for the skills
generation (step 1), since Hermes caches the skills index per process. While
hermes pause holds (the stock ESTOP sentinel) steps 2 and 3 wait as well. Once
the sidecar serves /v1/mind/*, the body does this on every tick (about every
60 s, and at once when the dispatch tick or a captured turn wakes it):
GET /v1/mind/state. Withenabled: falsethere, ormind.enabled: falseinprotagine.yaml, steps 2 and 3 are skipped and every unstartedmind:*task is archived; running workers end on their own. None of this needs a model endpoint. Whenskills.generationmoved since the last tick (the sidecar wrote or removed one of its skills), Hermes' skills prompt cache is cleared; the first value a process sees is only recorded.GET /v1/mind/dispatch→ for eachkind: taskintention onekanban_db.create_task(title, body, assignee="protagine-act", idempotency_key=dedup_key or "mind:<id>", workspace_kind="scratch", created_by="protagine", priority, max_runtime_seconds, max_retries, goal_mode, goal_max_turns), thenPOST /v1/mind/dispatch/{id}/bound {hermes_ref, hermes_kind: "kanban", status, bound_at}. A lost ack or a restart creates no second task: the key returns the existing one, and a task with the key that was archived in between (stock's lookup skips archived tasks) is bound and settled ascancelledrather than recreated. Messages and notices never travel through dispatch. Runtime and retries are the task kind's (mind.budgets.task_types, elsetask_max_runtime_s/task_max_retries), and fall back to that flat pair when a payload carries none.GET /v1/mind/outbox→ for each message, in order: skip it if the body's own ledger has begun it before;POST /v1/mind/outbox/{id}/sending {target, at}(any non-2xx: not ours); recordsendinglocally; send verbatim withsend_message_tool; record the result;POST /v1/mind/outbox/{id}/sent {result: sent|failed|uncertain, error, hermes_ref, at}.successfrom the tool issent; an error raised before the platform call (unknown target, unconfigured platform, no home channel) isfailed;Send failed: ...or an exception isuncertain. A process that stops betweensendingandsentreports that messageuncertainat the next start and never sends it, whatever the sidecar lists. The target is the message'starget(platform:chat_id), elserecipient.platform+recipient.chat_id|address|handle, else, for the owner (recipient_is_owner, the owner contact or no recipient), the first handle inidentity.yamlowner.handles(the[{platform, id}]listprotagine initwrites, or a{platform: [ids]}mapping), else the first of the sidecar'srecipient_handles({gateway, address}, primary first). A handle names a sender; where that is not a chat (a Discord user id) the target is the DM session stock has recorded with that user, and the handle itself when none has been seen yet. The sidecar answerssendingwith 409 for a message it already handed out, and marks a claim nobody settleduncertainafter ten minutes. A claim that names no target (target: null, the body resolved no handle) is also 409 (no_target) and is not a claim: the message stays ready, neverfailed, and the next pull offers it again withrecipient_handlesread afresh, so a handle added later lets it go out.- Reconciliation: for every
mind:*task,GET /v1/mind/why/{id}. A 404, or a lifecyclestatusinproposed | asked | denied | expired | cancelled | dropped, means no dispatched intention owns the task and it is archived (the ask backstop of architecture 7.7). Otherwise, when the task isdone | blocked | review | archivedor its latest run has ended, onePOST /v1/mind/outcome {id, hermes_ref, hermes_kind, status, outcome: done|blocked|failed|cancelled|uncertain, final, summary, error, verified: "hermes_failure"|null (the sidecar honours the claim only for a failure with a reason, and never takesownerorcheckfrom the body), run: {id, outcome, status, profile, started_at, ended_at}, block_kind, consecutive_failures, completed_at, observed_at}per state change (a failed run that Hermes requeues is onefailed,final: falseoutcome; a task parkedblockedwith its failed run onceconsecutive_failuresreachedmax_retriesisfailed,final: true; akanban_blockisblocked; a 404 archives the task). The body remembers what it posted in<hermes_home>/state/protagine-body.sqlite3so an outcome is posted once. POST /v1/mind/observations {observed_at, board, body: {pid, profile, started_at, interval_s, ticks, mind_ticks, last_tick_at, last_mind_tick_at, last_pull_at, stale}, counts: {status: n}, stale_tasks, blocked_tasks, goals, mind_tasks}when the board changed and at least every 5 minutes. Task entries carryid, title, status, assignee, created_by, created_at, started_at, age_s, idle_s, block_kind, goal; a stale task is an open non-mind task idle formind.stale_task_hours(default 72); goals are kanban goal-mode tasks;mind_tasksaddintention_idand the latest run.
Every GET /v1/mind/dispatch is the body's heartbeat: a sidecar whose last
pull is older than 5 minutes stops deliberating (architecture 3.1).
protagine_hermes.tick() runs one full tick on the caller's thread: POST /v1/mind/tick (the sidecar forms intentions now) and then the body pass above.
The paired benchmark's body tick calls it before Hermes cron and kanban
dispatch, with PROTAGINE_BODY_THREAD=0 so the body's own thread stays
parked and nothing lands between two observed ticks; a gateway never needs
either.
Asks
An ask lives only in the sidecar and creates nothing in Hermes until approved.
The owner answers with protagine mind yes|no <code>, or in chat by typing
the code ("yes K7F"): the model calls protagine_self yes/no and the
plugin sends POST /v1/mind/decide {code, answer, session_id, contact_id, message} only when the session's sender is the owner, the turn is not a kanban
worker, and the owner's own message for that turn contains the code as a whole
word; the sidecar checks the contact and the message again and answers
{ok, id, status, ...} (404: no open ask with that code, 403: not the owner). A code the owner's message
does not carry is never a guess away: with no ask open, or none typed, the answer is final; a miscopied code
on a message that carries an open ask's code names that code.
A guest, a worker whose task body quotes the code, or a page injected into an
owner session cannot approve. protagine_self rate {id, verdict} posts
POST /v1/mind/rate for the owner only. log and why are the owner's too,
and a guest's state says only whether the mind is on and at what level: the
log and the asks name what the owner asked about other people.
People
protagine_people is the model's view of the people store over /v1/mind/people
(the sidecar side is sidecar/protagine/api/routers/people.py):
| Operation | Who | Route |
|---|---|---|
who {contact_id} |
everyone (listing: the owner) | GET /v1/mind/people?q= (a name, handle or id; empty lists the newest, for the owner only) |
inspect {contact_id} |
everyone | GET /v1/mind/people/{who} |
propose_link {contact_id, handle: gateway:address} |
everyone | POST /v1/mind/people/link: a candidate the owner confirms as an ask |
set_permission {contact_id, permission} |
owner | POST /v1/mind/people/{who}/permission {may_contact: never|ask|auto} |
set_cadence {contact_id, minutes} |
owner | POST /v1/mind/people/{who}/cadence {minutes} (0 clears) |
merge {contact_id, drop} |
owner | POST /v1/mind/people/merge {keep, drop}: drop folds into contact_id; handles, sources, comms and affect follow the person |
A guest sees who someone is (contact_id, display_name, trust_tier) and
nothing else: the plugin names the guest as the viewer (contact_id) on its
reads and the sidecar answers with only those fields, and only for the one
person the reference names exactly (no listing, no partial matches, no 404
candidates), so a guest cannot browse the contact list. The owner also sees
may_contact, cadence_minutes, last_interaction_at, the per-contact
digest and the handles. The three mutations are refused in the plugin
outside the owner's own interactive session (a guest, a kanban worker, a cron
run) and are sent with the owner as contact_id, which the sidecar checks
again (403 not_owner). protagine people who|inspect|permit|cadence|merge|link|proposals
is the same interface from the CLI. may_contact is raised nowhere else; a
contact's opt-out ("STOP", "don't text me", ...) only lowers it to never.
protagine_self also carries the agent's recorded opinions (one tool, not a
separate one, so the tool schemas stay within their budget): opinions with an
optional query and why <number> read GET /v1/mind/opinions with the session's
contact, so a guest sees only everyone-audience views (an intention id is never all
digits, so why on any other id is still the owner's record); withdraw and
reconsider <number> with the owner's reason post
POST /v1/mind/opinions/{id}/withdraw|reconsider from the owner's own interactive
session only, never a guest, a kanban worker or a cron run (docs/OPINIONS.md). The
guard treats the tool as read-only.
Secrets
A secret that arrives in conversation is sealed into the sidecar's store and a reference stands in its place
(docs/SECRETS.md). The plugin's half (secrets.py):
pre_gateway_dispatch, after the/mindgate: when the plugin's copy of the detector (byte for byte the sidecar's) finds a value in a gateway message, it postsPOST /v1/secrets/seal {text, owner | contact_id}and returns{"action": "rewrite", "text"}; Hermes, its transcript and the model see the reference. The owner is named as such (the sidecar scopes the value to its owner), anyone else by their resolved contact or, when no contact has the handle,handle:<platform>:<id>(never created). The event's other words that Hermes puts in front of the model are sealed in place the same way: the quoted message (reply_to_text, in its author's scope) and a channel's backfill (channel_context, in nobody's). Without the sidecar each value is withheld; a value the sidecar returns as it came passes only in a span its answer marks as released.secrets.capture.ingress: falseturns this off; a message with no detected value is never sent there. The section is validated as the sidecar validates it: a malformed one leaves every rule on.tool_requestmiddleware: in the owner's own session a call carrying a reference has the value served (POST /v1/secrets/resolve {purpose: fill, lane: owner, own_refs, action, destination}, 32 references a request, more in turns) when the reference was sealed from the owner's own words in his latest message in that same chat (own_refs: what the gateway hook sealed from his text, never from a quote, a backfill, an expanded file or another chat); otherwise the sidecar forms a one-tap ask bound to the digest of the complete call, the plugin puts it to the owner in his chat directly, and the secondpre_tool_callhook holds the call with its words. A value goes as text only into a send's or a write's words (message,content,new_stringand the like) and into a waiting process's input as the whole of it. A terminal command or Python code never carries a value: it names$PROTAGINE_SECRET_<id>(the value) or$PROTAGINE_SECRET_<id>_FILE(a private 0600 file holding it), and the middleware hands the command to a subshell that sets both from the file (bind_shell) or runs the code with both in the interpreter's environment until it ends (bind_python); the command or code is never read or changed (a command follows the lines that set its values as it was written, so Hermes still reads its words, asudoincluded; code goes as one string literal). A reference written into a command or code is refused with the variable to name, and a command or code naming a variable the plugin did not set for it is held. Values reach commands and code on the local backend only. The program that takes a file records its process and removes the file when it ends;post_tool_callremoves a call's files when no program holds them, and a sweep removes those whose program is gone or that none took within an hour.write_fileandpatchin his session are served without an ask; a reference where no value goes (a patch'sold_string) holds the call. In a mind run a worker's message to anyone but the owner has every detected value and reference replaced by "(a private value, withheld)".- The body resolves an outbox message's references just before it claims and sends it (
purpose: send, in batches of 32); the sidecar allows only the owner's own values, to the owner or in his dictated words, and the body hides any other for a contact. When any reference is not settled for now (the sidecar unreachable or without an answer for it, the key unavailable) the message waits for a later tick: nothing is claimed or sent. protagine_secret(list,use,reveal,release,forget) works in the owner's own direct session only; a guest, a cron run and a worker are refused.revealsends the value to the owner's own chat itself (Hermes would mask it in a reply) and tells the model only that it did; in a session with no chat it writes it to a private file (0600, removed within half an hour) and tells the model only where it is.- A delivered turn-outbox row keeps its turn id and envelope digest and drops its payload; a rejected one
(
last_error"rejected: ...") keeps it in the private outbox file, the turn's only copy, for repair.
Memory provider
prefetch assembles context for the turn's participant. A guest request sets
audience: viewer, and the sidecar returns a guest only that contact's scoped
sections, never an owner-only one. A kanban worker's prefetch names its task
(context.metadata.kanban_task): its task body already carries its lessons, so
the sidecar adds no turn lesson and logs no lesson use for it. A scheduled job's
prefetch (Hermes builds its agent with platform cron) says so
(context.metadata.run: cron): a job's prompt is not the owner's turn, so the
sidecar leaves the owner's status block out of it. Hermes waits
for the whole prefetch, so each one has one deadline from its start (Hermes'
external prefetch bound less the provider's 0.5 s margin): the participant's
resolution, the assembly and the Current Time refresh all end by it, each
bounded in wall-clock time as a whole (an HTTP client's timeout bounds each
read, so an answer arriving in slow chunks would outlast it): past the
deadline the provider stops waiting and closes that request's connection. The
assembly request names what is left (wait_ms), the sidecar's recall spends
only that (docs/RECALL-HYBRID.md, Recall latency), and the Current Time refresh
gets what the assembly leaves (every refresh while sections are held back
included); with nothing left the turn carries the provider's own clock line. The scoping
fails closed: anyone the sidecar cannot show to be the owner, a caller without
the key included, gets the contact-scoped set. Recall includes this session's
own earlier turns: Hermes compacts sessions in place, rebuilds agents after an
idle eviction or a restart and compacts on detached agents, so no provider
instance knows whether the model still reads them verbatim. The provider's
direct tools are offered on the owner's own lane only: a guest session or a
channel with no sender binding gets none of them, and a call that still
arrives, like protagine_memory_search for a turn with no resolved
participant, is answered once with {"unavailable": true, "retry": false, "reason": ...} rather than an error the model retries. sync_turn is active
only when the general plugin is not enabled; otherwise the outbox owns capture.
on_pre_compress writes a checkpoint through the same outbox before Hermes
compresses a session.
Hermes appends each turn's recalled context to that turn's user message and
replays it byte for byte on every later turn (the api_content sidecar). The
provider keeps a fingerprint of each context section it showed, per session, and
a section unchanged since the session's last shown turn is one line in the new
turn's context: unchanged since your last turn: <section>. Only the new turn
changes; earlier turns are never touched, so the cached prompt prefix holds.
Current Time is always sent. A prefetch counts as shown only once its turn
completed (sync_turn, called whoever writes the turn), only when Hermes waited
for it (its 8 s external prefetch bound) and only when it went inline whole
(under Hermes' spill cap). The fingerprints reset on every session switch
(including /undo), before compression (on_pre_compress) and on restart; the
gateway's detached compaction evicts the live agent, whose provider starts
empty.
The context never outgrows Hermes' inline cap (hooks.output_spill.max_chars,
10,000 by default), which would replace the whole of it with a head, a tail
and a file path. The provider names its inline allowance (inline_chars, the
cap less 256 for the clock it adds) and the sections it will send as one line
(shown, their fingerprints); the sidecar's per-turn selection fits the
context to that allowance, and the turn's resident blocks are fitted to the
same budget: a hard limit per block, fresh state admitted first, whole lines kept
and a marker for what was held back, and a Held back this turn section
naming any block that did not fit (sidecar/protagine/memory/blocks.py,
architecture 4.1). Against a sidecar that fits nothing (1.10) the provider
holds whole sections back itself, fresh state last, and names each one. With
context.blocks: false the sidecar reports {"blocks": false} and the
provider holds nothing back either: the owner chose the 1.10 loader.
Tests
tests/hermes_adapter runs against stock Hermes installed in the same
environment plus a fake sidecar; see tests/hermes_adapter/conftest.py.
Metadata
Release files for protagine-hermes 1.11.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| protagine_hermes-1.11.0.tar.gz | 142.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| protagine_hermes-1.11.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 266.6 kB
Release files / protagine_hermes-1.11.0.tar.gz
| Download URL | protagine_hermes-1.11.0.tar.gz |
|---|---|
| Size | 142.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6a913757fe567a12fd94c16b5f92bf944109a0e4bd7a2473d6d9674f3336202f
|
|
BLAKE2b-256 checksum How to use checksums |
bc73922c9e6a6c80bbf70f24b95f9f17f18d214835295bc0646e97339a2a2f55
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / protagine_hermes-1.11.0-py3-none-any.whl
| Download URL | protagine_hermes-1.11.0-py3-none-any.whl |
|---|---|
| Size | 123.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a1f0a5fa61827a8eaffd03737effb19bf432d78daac5ba398c221b84f618c2e9
|
|
BLAKE2b-256 checksum How to use checksums |
eea5c447b66c311f989ad71634a55cf2896728086c7066b06bb3f73ae287a23a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|