API reference¶
Private replies¶
saysafe.PrivacyGuard ¶
Checks replies before they're spoken. Rules only by default: no models, no network.
classifier: optional extra detection layer, e.g. an LLM (see detect.CLASSIFIER_PROMPT). smoother: optional rewriter that makes redacted replies sound natural (see rewrite.smoother_prompt). screen: where private replies go, as the user would say it: "phone", "watch", "app". The host delivers send_to_phone there; the spoken lines name it.
check ¶
check(text: str, *, sources: Iterable[str] = (), room: Room | str = Room.UNKNOWN, headphones: bool = False, discreet: bool = False, whisper: bool = False, context: str = '') -> PrivacyDecision
Where text may go.
sources: where the reply's data came from (otp, bank, health, email, calendar).
room: who may hear the speaker; UNKNOWN is cautious.
headphones: only the owner hears it, so everything is said.
discreet / whisper: say nothing personal or above out loud.
context: earlier text of the same reply that was already spoken. It's only used to
find what makes text sensitive ("Your code is ready." then "It's 482913.").
says_as_is ¶
says_as_is(text: str, *, sources: Iterable[str] = (), room: Room | str = Room.UNKNOWN, headphones: bool = False, discreet: bool = False, whisper: bool = False, context: str = '') -> bool
True if check() would speak text unchanged. Never calls the smoother, and
skips detection when this room may hear anything.
speaks_everything ¶
speaks_everything(*, room: Room | str = Room.UNKNOWN, headphones: bool = False, discreet: bool = False, whisper: bool = False) -> bool
True if any reply, however sensitive, may be said here (alone, or headphones).
detect ¶
detect(text: str, *, sources: Iterable[str] = (), context: str = '') -> Detection
How sensitive text is. Context can only add to what text alone shows.
saysafe.PrivacyDecision
dataclass
¶
saysafe.Room ¶
Bases: str, Enum
Who may hear the speaker right now.
Most apps set it from device state: guest mode, passengers in the car, a meeting on the calendar -> OTHERS_PRESENT; a private-mode switch -> ALONE; nothing known -> UNKNOWN, which is cautious. With the voice extra, AudienceTracker.room() works it out from the voices it hears.
Approvals¶
saysafe.ApprovalGuard ¶
Decides whether an action may run, and signs the approval.
secret: at least 16 random bytes (32 recommended), kept server-side.
policy: a dict or YAML path with rules (and optionally the other sections of
saysafe/data/policy.yaml); sections you leave out keep the defaults.
thresholds: voice score bands (saysafe.load_thresholds(path) loads yours).
The packaged ones are placeholders: calibrate on your own mic and users.
require_voice_score: True (default) means a spoken yes or word only approves with a
voice score in the accept band. Set False only if your app knows who's speaking
another way (e.g. a signed-in phone line); then the reply alone approves.
nonces: NonceStore or SQLite path for used tokens (default: in memory).
steps: StepStore (default: in memory).
audit: AuditLog for decisions (no audio, transcripts or embeddings).
on_event(name, data): risk_assessed, readback, voice_scored, reply_matched, decision.
screen: what the user approves on, as said out loud: "phone", "watch", "app".
assess ¶
assess(action: Action) -> RiskAssessment
The tier this action needs, without starting anything.
start ¶
start(action: Action, *, room: Room | str = Room.UNKNOWN, headphones: bool = False) -> Step
Begin approving action. room/headphones decide whether a money read-back may
be said out loud; if not, it goes to the phone instead.
reply ¶
reply(step: Step | str, transcript: str | None, *, voice_score: float | None = None, command_score: float | None = None) -> Step
The owner's reply to a read-back. transcript None means nobody answered in time.
how much the reply sounds like the owner (e.g. cosine to their
voiceprint), or None if you couldn't score it (too short, typed, no voice ID).
command_score: the same for the spoken command, if you have it; fused with the reply.
phone_approve ¶
phone_approve(step: Step | str) -> Step
The owner tapped Approve. Works once, and only before the request expires.
cancel ¶
cancel(step: Step | str) -> Step
Drop a step that's still waiting (the conversation moved on).
get ¶
get(step_id: str) -> Step | None
The step as it stands now (a waiting step past its expiry reads as expired).
verify ¶
verify(token: str, action: Action) -> dict
For the executor, right before it runs action: returns the token's payload, or
raises Refused (bad_signature, action_changed, expired, replayed). Single use.
saysafe.Step
dataclass
¶
Where an approval stands. Every ApprovalGuard call returns the latest Step.
saysafe.StepClosed ¶
Bases: Exception
The step can't take this call: it's resolved, expired, waiting for something else, or unknown. status is its current status, or None if unknown.
saysafe.Action ¶
Bases: BaseModel
Something the agent wants to do. source: user_voice (the owner asked for it), agent_initiated (the assistant suggested it) or from_content (an email, a web page or a message asked for it); the last two need one more tier of proof.
saysafe.Refused ¶
Bases: Exception
The executor must not run the action. reason is one of the REASONS.
saysafe.approvals.guard.StepStore ¶
Bases: Protocol
Where steps live between calls. Memory by default; implement this for a store shared by several processes (e.g. a phone webhook served by another worker).
swap ¶
swap(old: Step, new: Step) -> bool
Atomically replace old with new if the stored step still has old.version.
saysafe.approvals.tokens.NonceStore ¶
Bases: Protocol
burn ¶
burn(nonce: str, at: float) -> bool
Record a nonce as used. True the first time, False if it was already used. Must be atomic: two concurrent burns of one nonce can't both return True.
saysafe.AuditLog ¶
Voice scores¶
saysafe.Thresholds
dataclass
¶
saysafe.calibrate ¶
calibrate(owner: Sequence[float], others: Sequence[float], target: float = 0.01) -> Thresholds
Thresholds from your own scores: the owner's held-out clips vs other voices.
t_accept is the lowest threshold that accepts at most target of the other voices;
t_reject the highest that rejects at most target of the owner's clips. When the two
sets overlap, the band between them is "uncertain" and steps up to the phone. Use a
few hundred scores of each, recorded on the mic you ship, and keep them separate from
the clips you enrolled with.
saysafe.load_thresholds ¶
load_thresholds(path: str | Path | None = None) -> Thresholds
Thresholds from a YAML file with t_accept and t_reject (e.g. your own calibration), or the packaged placeholders. Calibrate on your own mic and users before relying on them.
Voice ID¶
saysafe.voice.VoiceID ¶
model_dir: where the ECAPA model is kept (downloaded there on first use). min_speech_s: speech needed to score a clip; replies like "yes" are short, so the default is policy.yaml's min_reply_speech_s (0.35 s). Shorter clips score None.
embed ¶
embed(audio: ndarray, *, min_speech_s: float | None = None) -> Embedding
The speech-only part of audio as a unit vector. Raises TooShort.
score ¶
score(audio: ndarray, profile: Profile, *, min_speech_s: float | None = None) -> float | None
Cosine between audio and the profile, or None with too little speech.
verify ¶
verify(audio: ndarray, profile: Profile, thresholds: Thresholds | None = None, *, min_speech_s: float | None = None) -> VerifyResult
Score plus band (accept / uncertain / reject) and how much speech there was.
enroll ¶
enroll(name: str, clips: Sequence[ndarray], *, mic_name: str = 'unknown', min_clips: int = 3) -> Profile
A profile from clips of one person speaking (3 s each is plenty; 5-8 clips).
Clips with under 0.8 s of speech are skipped, and so are clips that don't sound
like the rest. Raises EnrollmentError if fewer than min_clips are left. Save the
profile with ProfileStore, which encrypts it.
tracker ¶
tracker(profile: Profile, **kwargs) -> AudienceTracker
An AudienceTracker that labels each speech segment owner / other / unclear.
saysafe.voice.ProfileStore ¶
__init__ ¶
__init__(directory: Path | str, key: bytes)
Profiles are Fernet-encrypted with key (Fernet.generate_key(), or keyring_key()).
Pipecat¶
saysafe.integrations.pipecat.PrivacyFilter ¶
Bases: FrameProcessor
Checks LLM replies and TTSSpeakFrames before TTS.
delivers withheld replies privately (to guard.screen). Required, because
the spoken line says "I sent it to your phone"; if it raises, failed_line() is said.
guard: a PrivacyGuard, e.g. with an LLM classifier; rules only by default. room / headphones: values, or zero-argument callables read for every sentence (e.g. room=tracker.room). Set the attributes any time. tool_sources: function name -> source tags, applied to the reply after its result.
Event "on_decision"(processor, decision): every reply that was checked as a whole.