whiskers.git / crates / whiskers-core

For agents, on top of README.md, which they read first.

Notes for agents

  • Speakable has a private constructor on purpose. It is the only type a shell may hand to speech, and only Conversation::respond can make one. Never add a public constructor or a From<String>: that reopens "speak unchecked text", the one thing this type removes.
  • Outcome::Fallback(Fallback), not one flat enum. Answered is not a fallback and a fallback is not an answer; the nesting keeps fallback() total with no unreachable!.
  • Who the words are for is an Audience, from the SharedProfile. Prompts and the guard's age come from it, read once per turn; Guard::check takes an Age and never a name. With no profile the audience is the youngest age (Age::YOUNGEST) with neutral wording. Do not add a prompt, greeting or fixture with a name or an age written in it.
  • The guard failing is a refusal. Err from Guard::check ends the turn. Never map it to Allow to keep a conversation going.
  • The log is written before each step is acted on. A broken log, or pictures that cannot be kept, stop the turn before the model or guard is called (LogUnavailable). The single unlogged utterance is that fallback line and, best effort, the closing Said. Do not reorder.
  • Pictures are judged through a description. Jev judges words, not pictures, so a separate model call with its own prompt (describe_pictures_prompt, no persona) describes them first, the description is logged (PictureSeen) and the guard judges it like speech; only then does the cat see them. Any failure keeps the picture from the cat. That call is itself shown the picture and could be talked at by text inside it, which is why its output is judged rather than trusted.
  • Only turns allowed both ways enter the history, as words. A refused question or answer never reaches the model again, and a picture is shown once.
  • A remembered fact is fed back into the prompt, so it is guarded like speech and logged (Remembered / NotRemembered). A fact that cannot be logged is forgotten again. Memory ids are never reused (next_id is saved with the facts).
  • A damaged memory file is an error, not an empty memory. Starting empty would silently drop everything she told it and then overwrite the evidence.
  • The voice allowance spends whole lines. VoiceBudget never splits a line across voices; a damaged record starts a fresh day (worst case: one day's allowance twice).
  • Ports are synchronous. The shell calls respond from a background thread. Do not add an async runtime to this crate; it would follow the core into the Android binding.
  • Reflection and compression run off the conversation lock. Reflector takes its own shared handles; never make respond wait on it, or the next turn stalls behind memory work (the "listens but does not reply" bug).
  • Jev is rate limited machine-wide (30 questions a minute). Guard facts in one batched check per reflection, and recall's rerank is one Choice for the whole shortlist; do not add a call per fact.
  • Recall degrades, never fails a turn. Embedder or ranker errors fall back (recency, then embedding order) and are logged as MemoryFailed.
  • Keep it buildable for wasm. Code that touches std::fs, SystemTime, Instant or stderr belongs behind #[cfg(not(target_family = "wasm"))], with the pure rule beside it (a merge, a comparison) callable without storage. Check with cargo check --target wasm32-unknown-unknown -p whiskers-core inside nix develop ~/whiskers. The hubs on the service run the pure merges in whiskers-ports' adapters; do not re-implement a merge rule in an adapter.
  • TimeKeeper::merge_durably is for hubs. A device's merge tolerates a failed save (a little lost time); the service's master copy of the grown-ups' choices does not, because a PIN or a limit that vanished on restart is worse than a refused sync.
  • Hide is not forget, and a prompt never sees a hidden fact. Visibility is a typed last-writer-wins register on the Fact; forgotten still beats everything. Anything built into a prompt or a search reads SharedMemory::usable(); facts() is every fact, for the parents' view and the memory's own work (a hidden fact still counts as known, so Whiskers does not file it again). A new reader of facts picks one on purpose. A hide or restore is stamped later than the write it follows (hidden_after, restored_after), so a slow clock cannot lose a parent's restore. An untouched fact has no visibility in its stored or wire form: keep skip_serializing_if, the wire is a contract.
  • A fact's icon is an IconId, never a string. It can only name an entry of crates/whiskers-icons/allowlist.txt. The memory file reads an unknown name as no icon (lenient_icon) instead of failing: a picture lost must never read as a damaged file. Two copies with different icons meet at the smaller name; the first icon given locally is kept (give_icon). The merge never takes a picture away.
  • A fact's picture is chosen off the conversation, and failing to choose is never an error she sees. Reflector::give_icons asks the IconChooser (the service: shortlist by meaning, then a Jev Choice) for a few iconless facts per pass and per sync; an Err stops the pass and is retried at the next, a "none fits" is remembered for the process. A fact with no picture shows a generic star. Do not add a call per fact on the conversation path (Jev answers thirty questions a minute).