kirby-ai
Kirby's interface to kirby-medialib's generation endpoints. Published to PyPI as kirby-ai and to the ProductBinder index; it carries no keys — seats are named here, provider keys live in medialib.
One client, replacing four independently written medialib clients in kirby-api.
Seats
A call site names a seat, never a model:
client.generate(seat="combat-pick", prompt="...")
A seat is a row in kirby-medialib, not a file this package reads (medialib-api
spec 2026-09-16): medialib resolves the provider and model server-side, so
the client only ever sends the seat's name. seeds/seats.dev.json (local stack,
Ollama-backed) and seeds/seats.prod.json (Hetzner, cloud providers only) are
the seeds for that table, applied by medialib-api/scripts/seed_seats.py; they
are not consulted at request time. The two files define the same seat names.
{
"tenant": "kirby",
"seats": {
"combat-pick": {"provider": "ollama", "model": "kirby-mythos:latest"},
"combat-review": {"provider": "ollama", "model": "kirby-mythos:latest"},
"campaign-prose": {"provider": "ollama", "model": "kirby:latest", "vision": true}
}
}
generate() has no model or provider argument, and a test asserts their
absence. The wire body carries seat and nothing that names a provider.
Overriding a seat means adding a seat -- in medialib, now, not here.
There are no defaults. An unnamed seat raises. A tool that silently picks a model has signed a claim on your behalf — and Kirby has the receipt: a provider pinned inside one module sent a local-model fight to a metered API for four months with nothing in the log to say so.
Choosing an action
ModelChooser fills the seat kirby_combat.loop.Chooser leaves open: the
engine renders the Phase as a Brief, this sends it, and takes back an
action_id. One pass.
DeliberatingChooser adds a second — propose → review → execute or
re-propose — because "on the menu" is a legality test, not a sanity test.
The O.K. Corral keeps producing legal absurdities: a marshal who cannot see
an enemy spending his Phase shooting a boarding house, five men with
revolvers trying to level a saloon they could not dent. A reviewer holding
the proposal catches those without anyone having to anticipate them as a
rule.
DeliberatingChooser(client, propose_seat="combat-pick",
review_seat="combat-review", rounds=1)
rounds counts re-proposals: 0 is a single pass and behaves exactly like
ModelChooser, so the two can be compared on one benchmark by changing one
number. The reviewer never picks — it approves, or sends the proposal back
with a stated objection, and only the objection travels back. A reviewer
that cannot answer never vetoes: a dead network, prose instead of JSON, or
an unrecognised verdict all resolve to execute, recorded in notes.
Two seats, not one, because reviewing is not the same job as proposing and must not become the same model by accident.
A model's answer can fail to reach any chooser — a dead transport, prose
instead of JSON, an id off the menu — and by default every one of these
quietly substitutes FirstLegalChooser and records why in .notes, so a
benchmark keeps running rather than dying on one malformed reply. Pass
fallback="raise" to opt out of that: ModelChooser, DeliberatingChooser
and RoleCouncil all raise kirby_ai.NoChoice instead of substituting,
carrying the actor, the offered action_ids, and the reason the model could
not be honoured. This is for a harness that must be able to say "I don't
know" rather than act on an unreviewed silent standby — a caller reading
.notes after the fact is not the same as one deciding, per Phase, whether
to proceed. The default (fallback=None, or any chooser instance) is
unchanged.
Intent
A seat that decides from scratch every Phase has no memory: it closes to
melee, then shoots, then closes again, and a reader watching the log cannot
tell a change of mind from a change of circumstance. IntentChooser wraps
another chooser and gives each actor a standing plan — an objective and a
target, formed from what it actually chose — kept across Phases until
something happens worth rethinking for: the target goes down or out of sight,
the actor takes a hit worth a quarter of its current STUN, its stance changes,
nothing on the menu serves the plan any more, or the commitment runs out. The
commitment is measured in turns, not Phases, and how many is a property of
the fighter: the role the engine classifies it as, moved by the psychological
complications the player paid points for.
from kirby_ai import IntentChooser, ModelChooser
seat = ModelChooser(client, seat="combat-pick")
chooser = IntentChooser(seat, commitment_turns=2)
The plan advises, it does not decide. The inner seat still picks; the live
plan reaches that seat through the engine — Brief.render(extra_doctrine=...)
puts it in the engine's own doctrine section, under the engine's heading and
the engine's suppression, so doctrine=False and KIRBY_BRIEF_NO_DOCTRINE
remove the plan along with everything else advisory. Nothing here splices text
into a rendered page.
That advisory shape is the one deliberate difference from the kirby-api layer
this was ported from, where a live plan continued without a model call at
all: the intent layer picked the serving action itself. The price is one
model call per Phase where the api made none while a plan held — for a fight
of any length that is most of the calls, so IntentChooser today buys
coherence and costs money, where the api's bought both. Restoring the saving is
a follow-on (continue_without_call), not something this layer decides on its
own.
IntentChooser installs itself on the inner chooser: it sets
inner.doctrine_lines, chaining any source already there rather than replacing
it. That is a side effect on an object the caller owns — a wrapper is built
after the thing it wraps, so there is no other moment to do it — and two
IntentChoosers over one seat would stack their lines.
chooser.renders_intent says whether the plan is reaching the page at all.
fallback="raise" passes straight through: a harness that asked not to be
guessed for still hears NoChoice.
What the port changed
| dropped | the intent table and its event lane (api rows), the character_tactic_profile base, the construct damage-overlay id translation, the per-combatant morale_state and with it all rage suppression, and the Tactics/INT notice roll (a Chooser rolls no dice — notice is the caller's callable and, given none, nothing is noticed) |
| added | throw and throw_object now imply an objective (ENGAGE_MELEE and KEEP_RANGE); the api's kind map omitted both although its preference table already served them. A STUNNED actor now abandons its plan: is_significant_hit is asked with stunned=, folded off the engine's event log by statuses_for, which the api read from its own rows |
| rungs | health is kirby_combat.classify_health, the engine's one door. The 0.25 significant-hit fraction stays here: it is planning policy and no rule fixes it |
Doctrine
TacticsDoctrine puts the engine's own tactic catalogue on the page as
offers a seat can return. It takes a PhaseSituation and gives back ranked
lines shaped "<kind> -> [<action_id>] against <name>"; install it with ModelChooser(..., doctrine_lines=TacticsDoctrine())
and they reach the reader through Brief.render(extra_doctrine=...), in the
engine's own doctrine section, under the engine's suppression. Nothing here
splices text into a rendered page.
from kirby_ai import ModelChooser, TacticsDoctrine
seat = ModelChooser(client, seat="combat-pick", doctrine_lines=TacticsDoctrine())
What it calls in the engine: tactics_for (the catalogue, ranked — every
tactic, its Plan and its Basis are kirby-combat's), PhaseSituation. tactical_situation and ordered_menu, and Brief.render(extra_doctrine=...)
for delivery. The matching rule is TacticChooser's, step for step: steps are
tried in order, an offer of the right kind at a different man does not count,
and a tactic the menu cannot serve is skipped. Advice that contradicts the
engine's own deterministic seat would be a contradiction on one page.
Why there is anything to add, and only that. Brief.doctrine already
renders name -> kind: summary. A kind is not returnable — the seat must
answer with an action_id — and sixteen of the catalogue's tactics name a
target_id that never reaches the page, so with two enemies in front of you
the engine's line says "close and strike" and does not say who. These lines are
continuations of the engine's, directly below them under the same heading:
they add the offer and the man and never restate the tactic's name, summary
or rationale. Two tactics wanting one offer say it once.
test_every_tactic_is_named_once_on_the_page renders the real page through
Brief.render(extra_doctrine=...) and counts, and
test_it_advises_what_the_engines_own_seat_would_take pins the first line to
TacticChooser().choose() on the same Phase.
A tactic that raises is an engine defect and arrives as one. Nothing here catches; a caught exception would read as "no doctrine applies", forever.
What the port changed
| not ported | the catalogue itself. All twenty of the api's tactics are already in kirby_combat/tactics/catalog/ — terrain-aware ones included, and repaired there (smash_cover named a man where the offer is keyed by a wall; take_cover_when_hurt kept its own copy of the health ladder). A second copy here would be a second answer. Covered by the api's tests/combat/test_tactics_catalog.py (all of it — smash_cover / fight_from_cover / keep_range / close_and_strike / exploit_hazards) and test_tactics.py::test_bait_enraged_*, ::test_exploit_susceptibility_*, ::test_focus_fire_*, ::test_presence_attack_*, now the engine's tests/test_tactic_catalogue.py |
| dropped | llm_selector.py — a rules library asking a service which tactic to use and falling back to priority on any error. The engine deleted it deliberately (select_tactic: choosing among what applies is the consumer's job), and the consumer is the seat, which picks an offer rather than a tactic name. Covered by test_tactics.py::test_select_tactic_with_llm_mock, ::test_select_tactic_llm_fallback_on_error, ::test_compare_selectors_runs_each_model, ::test_compare_selectors_handles_per_model_failure |
| dropped | the deterministic priority selector (test_tactics.py::test_select_tactic_deterministic_priority_when_no_llm) — that is tactics_for, in the engine |
| dropped | the role split and its rows — TacticProfileRow / TacticDefinitionRow, per-entry weights, offensive/defensive lists on StepSituation. Rows are kirby-api's and the api's tables go with the harness. Covered by test_tactics_role_split.py (all of it) |
| dropped | seven tactic definitions that existed only as seeded rows, with no class behind them and no engine counterpart by name: finish_dying, target_stunned, defensive_recover, separate_grouped, silence_caster, lure_to_cover (alembic 20260504_0010_tactic_catalog_expansion) and slam_velocity_attack (20260504_0011). Advice is only worth rendering if something decides it applies, and nothing in code ever did for these. Covered by tests/combat/test_situation_builder.py::test_build_situation_active_profile_resolves, which names target_stunned, silence_caster and separate_grouped in its comment and asserts only that the original four profile entries still land; finish_dying, defensive_recover, lure_to_cover and slam_velocity_attack had no api test at all. lure_to_cover and separate_grouped are positional advice the engine's catalogue lacks — listed under engine gaps, deliberately not implemented here |
| dropped | the tactic's Basis, from the rendered line. The engine's own doctrine line is where a tactic explains itself; quoting its citation down here was one more restatement |
The calculator seat
EVChooser is a Chooser that prices every offer and takes the best: P(hit)
x the STUN the engine's resolver gets through, averaged over every roll the
dice can produce. It is kirby-api's sim_ai.py expected-value pick, ported.
from kirby_ai import EVChooser
seat = EVChooser() # .picks records the number behind every decision
No fallback. A Phase where nothing can be priced raises NoChoice rather
than taking the first legal offer. A calculator that quietly stops calculating
is worse than one that stops, because the fight goes on looking decided.
The to-hit number is the engine's. chance_to_hit asks resolve_to_hit
(or resolve_mental_to_hit for a mental attack) once per 3d6 total, 3
through 18, and weights each answer by how often that total comes up. Both
functions are public and pure — they read their inputs, spend no END, emit no
event and touch no session — and both settle hit-or-miss independently of the
dice handed in. So Combat Skill Levels, the No Range Modifier Advantage (6E1
p.346) and mental combat's range-free roll (6E1 p.105) are the engine's
answers. The seat hands over the distance; what it costs is not its to say.
What it calls in the engine: resolve_to_hit / resolve_mental_to_hit,
compute_defense (which also knows Armor Piercing, defense items and AVAD),
compute_damage, killing_damage / normal_damage with the neutral body —
the same four damage primitives kirby_combat.critique asks, for the same
reason — and PhaseSituation.ordered_menu so scenery sorts last.
The probabilities are counted, not typed. chance_of_rolling(n) is
P(3d6 <= n) enumerated off the die: 0.5 at 10, because half of 216 outcomes are
10 or under. That distribution is the only thing here the engine does not own.
The killing ½d6 STUN multiplier is rolled through the engine the same way, once
per face.
Ties and futility. Equal values keep the first offer in ordered_menu — no
die is rolled to separate them — and a menu where every attack prices at zero
raises NoChoice rather than returning one: critique would file every one of
those picks as futile.
What the port changed
| dropped | the 17-entry 3d6 table — derived from the die instead (api test_sim_ai.py::test_hit_probability_known_values) |
| dropped | the to-hit target number itself. The api computed ocv + 11 - dcv and so did this module's first draft, which omitted CSLs, charged a No Range Modifier power the full range penalty and charged mental attacks a penalty they never pay. resolve_to_hit / resolve_mental_to_hit answer all three (::test_expected_damage_no_abort_basic covered the api's version) |
| dropped | _avg_damage's 3.5-a-die and its 1.83 killing multiplier, and _defense_for's pick-a-pool-by-type — the engine's resolver answers both (::test_expected_damage_no_abort_basic, ::test_md_reduces_mental_blast_damage) |
| dropped | the SPD_PHASES chart — kirby_combat.tables.segments_for_spd (::test_abort_at_segment_12_wraps_to_next_turn) |
| dropped | DODGE_DCV_BONUS / SET_OCV_BONUS — Dodge.dcv_bonus and actions/set_action.py (::test_expected_damage_with_dodge_lower_than_no_abort) |
| dropped | CoverFeature / cover_at / cover_ocv_penalty — scene/cover.py computes cover from the real map and cover_ocv_modifier prices it (::test_cover_at_picks_best_within_adjacency, ::test_cover_at_returns_empty_when_no_adjacent_cover, ::test_cover_ocv_penalty_thresholds) |
| dropped | distance / step_toward / step_away — scene/placement.move_toward and scene/geometry.py, which also know what is in the way (::test_distance_3d, ::test_step_toward_clamps_at_destination, ::test_step_away_moves_directly_away) |
| dropped | int_roll and its MASTERFUL/SOLID/MUDDLED/BAD-CALL tiers — a Chooser rolls no dice, and the roll target is the engine's (hero_view's skill roll target, kirby_cost.engine.rolls). Same call IntentChooser made about the notice roll (::test_int_roll_target_calculation, ::test_int_roll_tier_thresholds) |
| dropped | maybe_set's 0.30 hit-probability threshold — an invented number with no page behind it, and a Set is an offer on the menu like any other (::test_maybe_set_triggers_at_low_hit_probability, ::test_maybe_set_skipped_when_hit_chance_already_high, ::test_maybe_set_skipped_if_already_setting) |
| dropped | the whole abort seat — pick_best_defense, maybe_abort_to_defense, AbortDecision, the owned-martial-maneuver reading, and with it Block's full negation on a successful block. An abort is reactive and the Chooser protocol decides a Phase; there is nowhere to seat it yet (see the gap below). Covered by ::test_pick_best_defense_*, ::test_abort_*, ::test_expected_damage_with_block_full_negation_on_success, ::test_dodge_does_nothing_against_mental_attack, ::test_block_returns_inf_against_mental_attack |
| dropped | _DISABLING_THREAT_STUN — hand-set "threat-equivalent STUN" for Entangle, Drain, Flash, Mind Control and the rest, with no page behind any of them. Nothing here invents a number, so a 0-STUN disabling attack prices at 0 and this seat will never pick one. That is a real behaviour change and the gap below is where it belongs (::test_is_disabling_attack_recognises_common_xmlids, ::test_disabling_threat_stun_values, ::test_expected_damage_for_entangle_returns_threat_stun_not_zero, ::test_pick_best_defense_aborts_against_entangle, ::test_flashdef_reduces_flash_threat) |
| dropped | the six-xmlid "is this mental" list — the engine puts damage_type / defense_type on the power (::test_pick_best_defense_returns_none_for_mental_attack) |
| added | the distance. The api's EV took an ocv_modifier from its caller and never measured; the Phase knows how far everyone is, so distances_m is handed to resolve_to_hit, which decides what it costs. A shot priced without it is priced at point blank from across the street. Pinned by test_the_engines_range_rule_is_honoured |
Engine gaps this found
- No probability primitive. kirby-combat resolves a roll, but nothing there
can say what a roll is worth before it is made, and nothing can say what an
attack is expected to do —
critiqueprices only the luckiest roll.chance_to_hitandexpected_stunare priced-before-rolled and belong with the rules; asking the engine 16 times per offer is what a consumer has to do without them. - No mental / physical to-hit dispatch.
actions/base.pycallsresolve_to_hit; nothing routes a mental power toresolve_mental_to_hit. So a consumer picks the door offdamage_type/defense_type, which is a rule-shaped question it should not be answering. - No public tactic-matching door.
TacticChooser.chooseis the only place that turns aPlaninto an offer on the menu; anything else that wants the same answer copies it, asTacticsDoctrine._offer_fordoes (pinned by a parity test). The engine has corrected that logic twice already. - No public "who does this action_id mean" door.
critique._target_ofis private, soEVChooser._targetis a second copy that will not follow the engine when it widens the lookup. - Two positional tactics the catalogue lacks. kirby-api seeded
lure_to_cover("if cover is within ½ Move, take it before attacking") andseparate_grouped("when enemies cluster, scatter them with high-knockback attacks") as rows, with no code behind them. Neither has an engine counterpart; both are advice about ground the engine already models (scene/cover.py, knockback), and both belong inkirby_combat/tactics/ catalog/if they are wanted at all — never here. - No threat equivalence for status attacks. An Entangle, a Flash or a Mind Control does 0 STUN and ends fights. Until the engine can price one, any expected-value seat will ignore them.
- No reactive seat. Aborting to a Dodge or a Block is a decision, made
between Phases, and the
Chooserprotocol has no room for it. The api's defender-side EV picker has nowhere to go until there is one.
Narrating a fight
Narrator speaks one line in a fighter's voice over the events of a Phase —
or says nothing, which is most Phases. The events are the engine's own; the
policy is kirby-api's, ported.
from kirby_ai import Narrator
narrator = Narrator(client, seat="combat-narrate", significance=0.5)
line = narrator.narrate(events, situation) # str | None
Nothing here stores anything. narrate returns the text and the harness
writes it — in kirby-api, to its own combat_narration table keyed by
(session_id, sequence). There is no narration event: the engine's
CombatEvent union is closed and narration is not a state change.
One Narrator per fight. The throttle (one line per fighter per Turn) is
state on the narrator. A harness that builds a fresh Narrator per request
loses that history and every significant moment speaks again; keep one per
session for the fight's life, as with IntentChooser. Also note the PASS
door is ported verbatim: a reply that is literally "Pass!" is read as the
character choosing silence.
The gate is pure and runs first. Each event is priced on a 0–1 dial and the
Phase is silent unless something clears significance. The moments, most
dramatic first: the fight ending (1.0), a man going down (0.9), a man left at
death's door (0.8), a barrier destroyed (0.7), the scene turning on somebody
(0.6), a man visibly badly hurt (0.6), a barrier going up (0.4). A wound is
also priced as the fraction of the man it was taken from, so twelve STUN off a
thug is a moment and twelve off a brick is not. None of these numbers is a
rule — no page of 6E says when a man speaks — so they are policy here, and
significance overrides all of them at once.
Vitals are read from VitalsChanged, which is where they now are: the engine's
A7 work made the change itself an event, so a fight replayed from its log is
narrated the same way as the fight that ran. Where the man stands after the
blow comes from classify_health, the engine's one door, rather than from a
before/after pair this package snapshotted.
One line per man per turn, kept on the narrator rather than queried back out of rows, with the api's single exception: the fight-ending line is never eaten because the winner spoke earlier in the turn.
None is a decision, never a failure. It means exactly three things: below
significance, already spoke this turn, or the character chose silence (the
seat's PASS). A seat that errors, or answers with nothing at all, raises —
this returns no placeholder text.
What the port changed
| dropped | the combat_event row it wrote, EventRepository, the sequence bump and the savepoint around it, and the SOLILOQUY_EVENT_TYPE lane on the log. The harness stores the text (test_soliloquy.py::test_maybe_narrate_emits_event_row, ::test_rewind_survives_interleaved_soliloquy_rows) |
| dropped | the throttle's query — _turn_boundary_seq walking SegmentAdvanced rows backwards for the last from_segment == 12, and _spoke_this_turn over soliloquy rows. PhaseSituation.turn is the boundary and the narrator remembers who spoke (::test_throttle_one_soliloquy_per_actor_per_turn) |
| dropped | the five before/after snapshot arguments the driver took around _resolve (combat_started_before, target_stun_before, target_health_before/after, construct_effects_fired) — every one of them existed because the state change was not in the log. It is now (::test_ko_when_stun_crosses_zero, ::test_low_health_crossing_when_class_worsens) |
| dropped | first_blood. It asks whether this fight has drawn blood before, which one Phase's events cannot answer and which no engine event records (::test_first_blood_on_first_damaging_hit, ::test_no_first_blood_when_combat_already_started, ::test_no_first_blood_without_damage) |
| dropped | drowning_start and drowning_already_narrated — the api's per-segment construct effects with outcome == "suffocating", and the once-per-dunk memory read off its own rows. The engine's nearest event is EnvironmentalTriggered, which is scored as a hazard without knowing that drowning is one long moment (::test_drowning_start_first_suffocating_tick, ::test_no_drowning_when_already_suffocating, ::test_no_drowning_without_suffocating_outcome) |
| dropped | the three payload shapes _construct_destroyed had to know — top-level, nested under construct_damage, and the AoE list under construct_hits. ConstructDamaged.destroyed is one field (::test_construct_destroyed_top_level, ::test_construct_destroyed_nested_in_construct_damage, ::test_construct_destroyed_in_construct_hits_list) |
| dropped | victory_after as a caller's flag, and with it the driver's _actor_won side arithmetic. SessionEnded is in the log (::test_victory_trigger_when_victory_after_true, ::test_actor_won_loser_acting_and_mutual_ko, ::test_driver_step_victory_narrated_by_winner) |
| dropped | OllamaConfig, the bearer token as an argument and the settings.kirby_medialib_api_url read — the client is constructed by the caller, which is why this package is testable without a web application (::test_maybe_narrate_no_bearer_skips_the_seam) |
| dropped | profile_name — the api's character_tactic_profile row in the voice context. The complications stay (::test_driver_step_wall_destruction_emits_soliloquy) |
| reversed | failure is raised, not swallowed. kirby-api wrapped every touch and returned None on any exception, because narration was decoration the combat loop must not care about. A harness that STORES the line cannot tell "he said nothing" from "medialib is down" that way (::test_maybe_narrate_seam_failure_never_raises) |
| kept | the system prompt and the PASS door verbatim, the priority order of the moments, and the one-line-per-man-per-turn throttle with its victory exemption |
| rungs | health is classify_health and the sheet is complications_of — the engine's doors, where the api had its own _classify_health snapshots and a complications_for(db, character_id) query |
Engine gaps this found
- A wound has no "worse than before" in the log.
VitalsChangedcarries the delta andclassify_healthreads where a man stands now, but nothing says he crossed a rung on this blow — the api knew because it snapshotted the class either side of_resolve. A consumer that wants the crossing re-derives it by keeping its own before-picture, which is the thing the event was meant to end. - Drowning is not a state.
EnvironmentalTriggeredfires every segment a man is under water and carries a free-formeffectdict; there is no event for starting to suffocate and no status for being in it, so "he just began to drown" cannot be told from "he is still drowning" without a consumer's own memory. - No first blow. Nothing records that a fight has gone live.
SessionStartedis setup and the first damagingVitalsChangedis only recognisable as the first if you have read the whole log.
Layering
| layer | owns |
|---|---|
| kirby-medialib | providers, credentials, execution, and the seat rows themselves — shared with ProductBinder |
| kirby-ai | the client, parsing, provenance |
| kirby-api | HTTP serving, persistence. Names a seat, never a model. |
| kirby-combat | rules. Knows nothing about any of this. |
combat-pick and campaign-prose are still Kirby's vocabulary — they are
just rows scoped to the kirby tenant in medialib now, not a file this
package loads.
What it owns, and what it does not
Rules and rows belong to their own layers, and nothing here reads either.
Prompts live here, which this file used to deny. The claim was that "a
prompt is domain knowledge — callers hand this package finished strings", and
there is no caller that can. kirby-combat forbids the word: prompt is in the
banned-term list its vocabulary guard fails the build on, alongside llm and
ollama, because the engine models choosing and stays neutral about what
implements a choice — which is what lets a fight run with no dependencies at
all. And the only callers that would hand the strings over are the scripts in
this repository.
So a prompt for the combat-pick seat has nowhere else legal to be, and no
consumer outside this package: the seat is only ever sent by the client here.
The test is the same one that keeps the deliberation harness here rather than
in a package of its own — if nothing outside kirby-ai can ever use it, it
belongs to kirby-ai. A charter the code has broken since before it was
written, in the one place the code has no alternative, is a charter that is
wrong.
What that costs is real and worth stating: someone tuning a combat prompt is
editing a transport package, and a HERO rules change can want a prompt change
in a repo that knows nothing about HERO. The seat names are the seam that
keeps this survivable — combat-pick, combat-review, combat-atmosphere
say who a prompt is for without this package having to understand it.
Dependencies
httpx, and nothing else. tests/test_leaf.py enforces that, and enforces that
nothing here imports kirby.settings — the base URL and bearer token are passed
in, which is what makes the package usable from a script and testable without a
web application.
Release files for kirby-ai 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| kirby_ai-0.3.1.tar.gz | 101.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| kirby_ai-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 176.8 kB
Release files / kirby_ai-0.3.1.tar.gz
| Download URL | kirby_ai-0.3.1.tar.gz |
|---|---|
| Size | 101.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5217c32650dbb8201dde0554eb28b07eaca679b16ab419d3bc0f193810ff6a12
|
|
BLAKE2b-256 checksum How to use checksums |
e31afaa57fabfe93110671f884188fbbe5f18462ce415e86d587ac5351a117ae
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / kirby_ai-0.3.1-py3-none-any.whl
| Download URL | kirby_ai-0.3.1-py3-none-any.whl |
|---|---|
| Size | 75.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
79cc26537802040b149859cc99f28a195f3852b71ebb02770703fdf75ce0c943
|
|
BLAKE2b-256 checksum How to use checksums |
54053f8c126cf346e53101e9148ee471726d662472cdf866d2bf77bb9503de2a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|