227 lines
12 KiB
Python
227 lines
12 KiB
Python
"""Orac bot constants: names, radio config, triggers, limits, paths."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
# ── Bot identity ─────────────────────────────────────────────────
|
|
|
|
BOT_NAME: str = "Orac"
|
|
|
|
# ── Payload limits ───────────────────────────────────────────────
|
|
|
|
MAX_GRP_TEXT: int = 163 # max bytes for "SenderName: message" in a GRP_TXT
|
|
MAX_RESPONSE_CHARS: int = MAX_GRP_TEXT - len(BOT_NAME) - 2 # subtract "Orac: "
|
|
# DM plaintext: timestamp(4) + txt_type_attempt(1) + text + padding to 16
|
|
# Max payload 184 - dst(1) - src(1) - mac(2) = 180 bytes ciphertext
|
|
# 180 bytes ciphertext / 16 = 11 blocks -> 176 bytes plaintext max
|
|
# 176 - 5 (header) = 171 bytes text, minus null terminator padding
|
|
MAX_DM_TEXT: int = 170
|
|
|
|
# ── Trigger words ────────────────────────────────────────────────
|
|
|
|
TRIGGERS: tuple[str, ...] = ("!askai", "!ai", "!claude", "!orac")
|
|
AT_MENTIONS: tuple[str, ...] = (f"@{BOT_NAME}".lower(), f"@[{BOT_NAME}]".lower())
|
|
|
|
# ── History / rate-limit sizing ──────────────────────────────────
|
|
|
|
CHANNEL_HISTORY_SIZE: int = 20
|
|
DM_HISTORY_SIZE: int = 20
|
|
|
|
RATE_LIMIT_PER_SENDER: float = 5 # seconds, channel messages
|
|
RATE_LIMIT_GLOBAL: float = 2 # seconds, any two responses
|
|
RATE_LIMIT_DM: float = 2 # seconds, DMs (more interactive)
|
|
RATE_LIMIT_REPLY_COOLDOWN: float = 30 # seconds between rate-limit replies
|
|
|
|
# ── Timers ───────────────────────────────────────────────────────
|
|
|
|
ADVERT_INTERVAL: float = 7200 # 2 hours in seconds
|
|
|
|
# ── Paths ────────────────────────────────────────────────────────
|
|
|
|
DATA_DIR: Path = Path.home() / ".donglora"
|
|
KEY_FILE: Path = DATA_DIR / "orac_key.bin"
|
|
STATE_FILE: Path = DATA_DIR / "orac_state.json"
|
|
|
|
# ── Radio ────────────────────────────────────────────────────────
|
|
|
|
RADIO_CONFIG: dict[str, int] = {
|
|
"freq_hz": 910_525_000,
|
|
"bw": 6, # 62.5 kHz
|
|
"sf": 7,
|
|
"cr": 5, # CR 4/5
|
|
"sync_word": 0x3444,
|
|
"tx_power_dbm": -128, # TX_POWER_MAX
|
|
}
|
|
|
|
# ── AI system prompt ─────────────────────────────────────────────
|
|
|
|
SYSTEM_PROMPT: str = """\
|
|
You are Orac, a terse AI assistant on a MeshCore LoRa radio mesh network.
|
|
|
|
ABSOLUTE HARD LIMIT: Your ENTIRE response must be {{max_chars}} characters or fewer.
|
|
This is a physical constraint of the radio protocol. Every character beyond this limit
|
|
is permanently lost and never transmitted. There is NO exception.
|
|
|
|
Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply.
|
|
|
|
You can see recent messages for context. Use the chat history to understand what
|
|
people mean. "what do you think?" refers to whatever was just discussed. "tell me
|
|
more" means elaborate on the recent topic.
|
|
|
|
Social awareness is YOUR TOP PRIORITY — above cleverness, above information density.
|
|
Before every response, work out:
|
|
- Who is actually talking, and to whom? Are they asking you, or about you?
|
|
- What's the vibe: casual banter, technical help, emergency, venting, inside joke?
|
|
- What's the group's energy? Are you being invited in, or just name-dropped?
|
|
- If someone said "you could always ask @Orac" (or similar), that's a social nudge,
|
|
NOT a request for a factual answer. Acknowledge lightly, or offer to help if the
|
|
invitation was sincere — don't barge in with an essay.
|
|
- If the conversation is jokey, match the tone. If serious, be substantive.
|
|
If someone's frustrated, don't be flippant. Read the room like a person would.
|
|
- When people are talking among themselves and you're tagged in passing, a short
|
|
witty nod is often the right move — or silence would be, if you could stay silent.
|
|
- When you ARE being asked something directly, answer crisply.
|
|
|
|
NEVER assume location. Weather, traffic, time zones, sunset, local services —
|
|
all of these are useless without knowing where the person is. If someone asks
|
|
"what's the weather?" or anything else location-sensitive without telling you
|
|
where, ask them. A short quip is fine: "Where?" or "Drop a city/airport code
|
|
and I'll tell you." Do NOT guess a city. Do NOT default to Denver or anywhere
|
|
else. Only answer a location-dependent question after the user has provided
|
|
the location (either in the current turn or visible in recent history).
|
|
|
|
Rules:
|
|
- One sentence or short phrase. Never more.
|
|
- Emoji OK but don't overdo it. No markdown, no bullet points, no formatting.
|
|
- Use common abbreviations freely (e.g., w/, b/c, approx, etc).
|
|
- Skip all pleasantries, greetings, and filler.
|
|
- You have a dry, sardonic wit. Be helpful but never wordy.
|
|
- Cynicism is fine in small doses; punching-down or mean-spirited is not.
|
|
- If the question requires a long answer, give the most important point only.
|
|
- NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take.
|
|
- Be creative and unpredictable. Surprise the reader.\
|
|
"""
|
|
|
|
# ── MeshCore protocol constants ──────────────────────────────────
|
|
|
|
ADVERT_MAX_AGE: float = 43200 # 12 hours (display only, not key expiry)
|
|
PENDING_DM_TTL: float = 300 # 5 minutes
|
|
|
|
DEDUP_TTL: float = 120 # seconds
|
|
DM_DEDUP_TTL: float = 60 # seconds
|
|
|
|
RESP_SERVER_LOGIN_OK: int = 0x01
|
|
|
|
# ── DM reliability: IO loop ──────────────────────────────────────
|
|
|
|
IO_POLL_TIMEOUT_S: float = 0.2
|
|
RX_DRAIN_PER_TICK: int = 8
|
|
|
|
# Donglora's send() reads the TxDone response using the serial timeout; LoRa
|
|
# airtime at SF7/62.5 kHz for a max-size 180 B DM is ~425 ms, plus up to
|
|
# 100 ms of CAD retries in the firmware. 2 s gives a comfortable margin.
|
|
TX_RESPONSE_TIMEOUT_S: float = 2.0
|
|
|
|
# ── DM reliability: ACK scheduling ───────────────────────────────
|
|
# Small jitter below single-ACK airtime (~89 ms at SF7/62.5 kHz/CR4/5)
|
|
# decouples us from other responders, without delaying the sender's
|
|
# firmware retry clock.
|
|
ACK_JITTER_MIN_S: float = 0.03
|
|
ACK_JITTER_MAX_S: float = 0.09
|
|
PATH_RETURN_DELAY_S: float = 0.15
|
|
|
|
# Multi-ACK protocol (upstream commits b1ca3d1 + 5881b04, `multi_acks` pref).
|
|
# When > 0, N extra ACKs are emitted as PAYLOAD_TYPE_MULTIPART (0x0A) packets
|
|
# with remaining-count nibbles counting down to 1, followed by a final plain
|
|
# PAYLOAD_TYPE_ACK (0x03). Each successive packet is 300 ms + small jitter
|
|
# after the previous.
|
|
#
|
|
# Firmware restricts extra ACKs to direct-routed packets only (see
|
|
# Mesh::routeDirectRecvAcks). We do the same: if no learned direct route to
|
|
# the peer exists, we fall back to a single plain ACK and skip MULTIPART,
|
|
# matching upstream semantics.
|
|
#
|
|
# Upstream default is 0 (multi_acks pref = 0). MeshCore One's iOS app surfaces
|
|
# this as the "2 ACKs" toggle (multi_acks=1 -> 1 MULTIPART + 1 ACK = 2 total).
|
|
MULTI_ACK_COUNT: int = 0
|
|
MULTI_ACK_SPACING_S: float = 0.3
|
|
|
|
# ── DM reliability: reply scheduling ─────────────────────────────
|
|
# Initial reply jitter keeps the reply from racing our own ACK/PATH
|
|
# on the air and below any retry window.
|
|
REPLY_INITIAL_JITTER_MIN_S: float = 0.05
|
|
REPLY_INITIAL_JITTER_MAX_S: float = 0.20
|
|
|
|
# Retry delays between attempts. Index i = delay AFTER firing attempt (i+1)
|
|
# before attempt (i+2). With REPLY_MAX_ATTEMPTS=5 we need 4 entries (for
|
|
# attempts 2-5). Schedule mirrors pymc's 3-direct + 2-flood pattern.
|
|
#
|
|
# Timeline at current radio config (SF7/62.5 kHz/CR4/5, ~3.3 s 2-hop RTT):
|
|
# Attempt 1 (initial) at T=0
|
|
# Attempt 2 (direct) at T=6 (~1.8x 2-hop RTT headroom)
|
|
# Attempt 3 (direct) at T=20 (bigger gap for congested relays)
|
|
# Attempt 4 (flood, path-reset) at T=50 (mesh quiesce window)
|
|
# Attempt 5 (flood) at T=95 (last hail-Mary after a long wait)
|
|
# Give up (sweep) at T=125 (~30s past the last attempt for ACK)
|
|
RETRY_SCHEDULE_S: tuple[float, ...] = (6.0, 14.0, 30.0, 45.0)
|
|
REPLY_MAX_ATTEMPTS: int = 5 # 1 initial + 2 direct retries + 2 flood retries
|
|
REPLY_FLOOD_FIRST_ATTEMPT: int = 4 # 1-indexed: first attempt that forces flood
|
|
|
|
# ── DM reliability: state bounds ─────────────────────────────────
|
|
# TTL must be beyond the final retry's transmit time plus enough for the
|
|
# reply's ACK to arrive: 6+14+30+45 = 95s to final retry, +30s for ACK.
|
|
PENDING_ACK_TTL_S: float = 125.0
|
|
PENDING_ACK_CAP: int = 64
|
|
REPLY_CACHE_CAP: int = 32
|
|
REPLY_CACHE_TTL_S: float = 300.0 # matches PENDING_DM_TTL
|
|
|
|
# Duplicate DMs arriving within this window *with a cached reply* trigger a
|
|
# cached-reply resend (interpreted as loss recovery). Outside this window, a
|
|
# duplicate text is treated as the peer legitimately re-asking after giving up,
|
|
# and falls through to normal text-level dedup (silent drop inside DM_DEDUP_TTL
|
|
# and a fresh Claude call beyond that).
|
|
REPLY_CACHE_RESEND_WINDOW_S: float = 20.0
|
|
TX_QUEUE_CAP: int = 64
|
|
|
|
# ── DM reliability: route table ──────────────────────────────────
|
|
ROUTE_TTL_S: float = 1800.0 # 30 min
|
|
|
|
# Minimum hop-hash size (bytes) we will accept when learning a return path,
|
|
# and the minimum we will echo back in a PATH_RETURN. 1-byte hashes have a
|
|
# 256-way namespace that collides under any real node population; this bot
|
|
# refuses to participate in 1-byte routing.
|
|
MIN_HASH_SIZE: int = 2
|
|
|
|
# ── DM reliability: worker / Claude ──────────────────────────────
|
|
WORKER_QUEUE_CAP: int = 8
|
|
CLAUDE_TIMEOUT_S: float = 20.0
|
|
|
|
# ── Channel follow-up screener ───────────────────────────────────
|
|
# Inexpensive yes/no classifier that decides whether a NON-TRIGGERED channel
|
|
# message is a follow-up to Orac (we should reply) or just chatter between
|
|
# other participants (we should not). Keeps Orac capable of natural multi-turn
|
|
# conversation without flooding the channel with bot noise.
|
|
|
|
# Haiku is the cheapest Claude model — suitable for a yes/no gate.
|
|
SCREENER_MODEL: str = "claude-haiku-4-5"
|
|
|
|
# How long after Orac was last active in a channel to keep screening new
|
|
# non-triggered messages. Outside this window, non-triggered messages are
|
|
# ignored without spending any API call.
|
|
CHANNEL_FOLLOWUP_WINDOW_S: float = 180.0 # 3 min
|
|
|
|
# Per-channel minimum gap between screener API calls, to prevent spam from
|
|
# running up cost during a burst of traffic.
|
|
SCREENER_PER_CHANNEL_RATE_S: float = 3.0
|
|
|
|
# Short timeout for the screener — it returns a single token.
|
|
SCREENER_TIMEOUT_S: float = 6.0
|
|
|
|
# ── Events JSONL path ────────────────────────────────────────────
|
|
EVENTS_FILE: Path = DATA_DIR / "orac-events.jsonl"
|
|
|
|
# ── Channels CSV path ────────────────────────────────────────────
|
|
# Resolved at import time relative to the project root (ai_bot.py's directory).
|
|
CHANNELS_CSV: Path = Path(__file__).resolve().parent.parent / "channels.csv"
|