Skip to content

Commit 231d633

Browse files
authored
Merge pull request #4 from vasumbaa24143-crypto/claude/new-session-ui80t8
Pluto: fix voice notes and on-time reminders, add borrow plan and Sarvam script
2 parents 4026fb0 + 4561da2 commit 231d633

14 files changed

Lines changed: 752 additions & 75 deletions

File tree

‎pluto/.env.example‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ GROQ_API_KEY=
1414
ZAI_API_KEY=
1515
# NVIDIA Nemotron (build.nvidia.com -> Get API Key; starts with nvapi-)
1616
NVIDIA_API_KEY=
17-
# Sarvam API key for voice notes (dashboard.sarvam.ai). Optional: text works without it.
17+
# Voice notes are transcribed by Groq Whisper, then Gemini (both use the keys above),
18+
# then Sarvam. Sarvam is optional (dashboard.sarvam.ai).
1819
SARVAM_API_KEY=
1920

2021
# Optional
@@ -26,5 +27,6 @@ SARVAM_API_KEY=
2627
# ZAI_MODEL=glm-4.6
2728
# NVIDIA_MODEL=nvidia/nemotron-3.5-super-vl-120b-a12b
2829
# NVIDIA_THINKING=0 # 1 = let Nemotron reason first (smarter, much slower)
29-
# SARVAM_STT_URL=https://api.sarvam.ai/speech-to-text
30-
# SARVAM_STT_MODEL=saaras:v3
30+
# STT_PROVIDERS=groq,gemini,sarvam
31+
# GROQ_STT_MODEL=whisper-large-v3
32+
# SARVAM_STT_MODEL=saarika:v2.5

‎pluto/CLAUDE.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ Also built in: only `ALLOWED_USER_ID` may use the bot (fail closed if unset); th
4141
assistant can mark only the user's own open items done; `/delete` removes an item,
4242
its nudge history and the chat messages it came from, for good; `/forget` clears the
4343
chat history; `/model` picks which AI provider goes first (saved per user, others
44-
stay as backup); `/export` returns items and conversation; no nudges 22:00–08:00 local;
45-
at most 5 nudges per user per 24 hours.
44+
stay as backup); `/export` returns items and conversation. A reminder's first alert
45+
always goes out at the time the user set (checked every minute), even at night and
46+
past the cap; repeat nudges and idea nudges wait out quiet hours (22:00–08:00) and are
47+
capped at 5 per user per 24 hours.
4648

4749
## Layout
4850

@@ -56,7 +58,7 @@ at most 5 nudges per user per 24 hours.
5658
core saves the raw note (rule 5).
5759
`offline.py` — rule-based stand-in for `simulate.py --offline`. Any object with
5860
`respond(message, history, items, now_local)` works as the assistant.
59-
- `stt.py` — Sarvam speech-to-text.
61+
- `stt.py` — voice-to-text: Groq Whisper → Gemini → Sarvam, whichever keys exist.
6062
- `tests/` — pytest with a fake assistant and fake Telegram objects; no network needed.
6163

6264
## Working on it

‎pluto/README.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ You need Python 3.11 or newer.
1717

1818
1. In Telegram, message **@BotFather** → `/newbot` → copy the token.
1919
2. Message **@userinfobot** → copy your numeric user id.
20-
3. Get at least one AI key: Gemini (aistudio.google.com/apikey), Groq (console.groq.com/keys), Z AI (z.ai) or NVIDIA Nemotron (build.nvidia.com → Get API Key). With more than one, Pluto falls back automatically when one is rate-limited or down. For voice, also get a Sarvam key (dashboard.sarvam.ai).
20+
3. Get at least one AI key: Gemini (aistudio.google.com/apikey), Groq (console.groq.com/keys), Z AI (z.ai) or NVIDIA Nemotron (build.nvidia.com → Get API Key). With more than one, Pluto falls back automatically when one is rate-limited or down. Voice notes work with your Groq or Gemini key (Sarvam is an optional extra).
2121
4. Run:
2222

2323
```bash
@@ -56,12 +56,14 @@ The tests use a fake assistant and fake Telegram objects, so they need no keys o
5656

5757
`/list` open items · `/done 3` · `/snooze 3` (+1 day) · `/delete 3` (forever) · `/forget` (clear chat history) · `/model` (choose which AI thinks first) · `/export` (JSON download of items and chat)
5858

59-
## How nudges work
59+
## How reminders and nudges work
6060

61-
- Checked every 30 minutes, never between 22:00 and 08:00 (`QUIET_START/END` in `core.py`).
62-
- Overdue items: every 6 hours, up to 5 times. Ideas with no deadline: after 3 days, then every 3 days, up to 3 times.
63-
- Max 5 nudges per 24 hours. Overdue items go first, and items nudged least go first, so nothing is starved.
61+
- Checked every minute while the bot is running.
62+
- **A reminder you set always arrives at the time you asked for**, even at night, and doesn't count against any limit.
63+
- If it's still open afterwards, Pluto nudges again every 6 hours (up to 5 times). Ideas you haven't touched get a nudge after 3 days, then every 3 days (up to 3 times).
64+
- Those follow-up nudges wait until morning (no follow-ups 22:00–08:00, `QUIET_START/END` in `core.py`) and are capped at 5 per 24 hours.
6465
- Done / Snooze / Drop buttons on every nudge.
66+
- Your computer must be awake with `python bot.py` running for reminders to arrive. To get them when your Mac is asleep, deploy it (see below).
6567

6668
## Rules built in
6769

@@ -78,5 +80,5 @@ The bot must be running to nudge.
7880
## Known limits
7981

8082
- Each message sends the last 100 chat messages and your open items (plus 50 recent closed ones) to the model. "What have we discussed" only reaches that far back in the chat; saved items are always included. Add search/embeddings when this gets slow or costly.
81-
- Check `stt.py` against Sarvam's current docs; the model name and field names are env-configurable.
83+
- Voice notes: Groq `whisper-large-v3` → Gemini → Sarvam `saarika:v2.5` (`STT_PROVIDERS` changes the order).
8284
- AI providers are tried in order Gemini → Groq → Z AI → NVIDIA Nemotron (`PLUTO_PROVIDERS` changes it, and `/model` in chat picks which one goes first; the rest stay as backup). Default models: `gemini-2.5-flash`, `llama-3.3-70b-versatile` (Groq), `glm-4.6` (Z AI), `nvidia/nemotron-3.5-super-vl-120b-a12b` (NVIDIA, with thinking off for quick replies; `NVIDIA_THINKING=1` turns it on); change `PLUTO_MODEL`, `GROQ_MODEL`, `ZAI_MODEL`, `NVIDIA_MODEL` if a provider retires one. Fallback models may phrase replies differently, but the code-side checks (what gets saved, never citing unknown items) apply to all of them.

‎pluto/bot.py‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
log = logging.getLogger("pluto")
3434

3535
ALLOWED = os.getenv("ALLOWED_USER_ID", "").strip() # only this Telegram user may use the bot
36-
NUDGE_EVERY_SECONDS = 1800
36+
NUDGE_EVERY_SECONDS = 60 # reminders arrive within a minute of their time
3737

3838

3939
def allowed(update: Update) -> bool:
@@ -96,11 +96,18 @@ async def on_voice(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
9696
filename=getattr(v, "file_name", None) or "voice.ogg",
9797
mime=getattr(v, "mime_type", None) or "audio/ogg",
9898
)
99+
except stt.TranscriptionFailed as e:
100+
log.warning("transcription failed: %s", e)
101+
if not stt.providers():
102+
msg = (f"I can't listen to voice notes yet: add one of {stt.KEY_NAMES} to .env "
103+
"and restart me. Meanwhile, please type it.")
104+
else:
105+
msg = "I couldn't make out that voice note just now. Please try again or type it."
106+
await update.message.reply_text(msg)
107+
return
99108
except Exception:
100-
log.exception("transcription failed")
101-
await update.message.reply_text(
102-
"I could not understand that voice note. Please type it or try again."
103-
)
109+
log.exception("could not download the voice note")
110+
await update.message.reply_text("I couldn't download that voice note. Please try again.")
104111
return
105112
if not text:
106113
await update.message.reply_text("I heard silence. Try again?")
@@ -200,8 +207,8 @@ def check_config() -> None:
200207
raise SystemExit("ALLOWED_USER_ID must be your numeric Telegram id (ask @userinfobot).")
201208
if len(llm.providers()) == 1:
202209
log.warning("Only one AI provider is set; add another key so Pluto has a fallback.")
203-
if not os.getenv("SARVAM_API_KEY"):
204-
log.warning("SARVAM_API_KEY is not set: voice notes will not work, text will.")
210+
if not stt.providers():
211+
log.warning("No speech-to-text key (%s): voice notes won't work, text will.", stt.KEY_NAMES)
205212

206213

207214
def build_app() -> Application:
@@ -218,7 +225,7 @@ def build_app() -> Application:
218225
app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, on_voice))
219226
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text))
220227
app.add_error_handler(on_error)
221-
app.job_queue.run_repeating(nudge_job, interval=NUDGE_EVERY_SECONDS, first=60)
228+
app.job_queue.run_repeating(nudge_job, interval=NUDGE_EVERY_SECONDS, first=5)
222229
return app
223230

224231

@@ -229,7 +236,7 @@ def build_app() -> Application:
229236
("GROQ_API_KEY", "Groq", "AI"),
230237
("ZAI_API_KEY", "Z AI", "AI"),
231238
("NVIDIA_API_KEY", "NVIDIA Nemotron", "AI"),
232-
("SARVAM_API_KEY", "Sarvam (voice notes)", "optional"),
239+
("SARVAM_API_KEY", "Sarvam (voice backup)", "optional"),
233240
]
234241

235242

@@ -240,8 +247,9 @@ def key_report() -> str:
240247
val = os.getenv(name) or (os.getenv("Z_AI_API_KEY") if name == "ZAI_API_KEY" else None)
241248
mark = "✓ set " if val else ("✗ MISSING" if kind == "required" else "– not set")
242249
lines.append(f" {mark} {name:<20} {what}" + (" (required)" if kind == "required" else ""))
243-
order = llm.describe()
244-
lines.append(f"AI order: {order}")
250+
lines.append(f"AI order: {llm.describe()}")
251+
voice = " → ".join(stt.providers()) or f"✗ none (add {stt.KEY_NAMES})"
252+
lines.append(f"Voice notes: {voice}")
245253
if not llm.providers():
246254
lines.append("✗ Set at least one AI key.")
247255
return "\n".join(lines)

‎pluto/core.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -384,15 +384,18 @@ def in_quiet_hours(now) -> bool:
384384
def due_nudges(now=None) -> list:
385385
"""Nudges to send now, as (user_id, item_id, Reply).
386386
387-
The caller sends each one and then calls db.mark_nudged(item_id, user_id, now),
388-
so a failed send is retried on the next run."""
387+
A reminder's first alert goes out at the time the user asked for, even in
388+
quiet hours. Repeats and stale-idea nudges wait for daytime and respect the
389+
daily cap. The caller sends each one and then calls
390+
db.mark_nudged(item_id, user_id, now), so a failed send is retried."""
389391
now = now or db.now_utc()
390-
if in_quiet_hours(now):
391-
return []
392392
out = []
393-
for r, reason in db.pick_nudges(now):
394-
if reason == "due":
393+
for r, reason in db.pick_nudges(now, quiet=in_quiet_hours(now)):
394+
if reason == "reminder":
395395
reply = Reply(f"⏰ Reminder: {r['title']} (#{r['id']})", item_buttons(r["id"]))
396+
elif reason == "due":
397+
reply = Reply(f"⏰ Still pending: {r['title']} (#{r['id']}) — due {fmt_due(r['due_at'])}",
398+
item_buttons(r["id"]))
396399
else:
397400
days = (now - db.from_str(r["created_at"])).days
398401
reply = Reply(

‎pluto/db.py‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,17 @@ def nudges_last_24h(user_id, now=None) -> int:
265265
).fetchone()[0]
266266

267267

268-
def pick_nudges(now=None):
268+
def pick_nudges(now=None, quiet=False):
269269
"""Return (item_row, reason) pairs that should be nudged now.
270270
271-
reason is 'due' or 'stale'. Overdue items go before stale ideas; within
272-
each, items nudged the fewest times go first (so the daily cap never starves
273-
an item), then the oldest. The daily cap is applied per user.
274-
Quiet hours are the caller's job (see core.due_nudges)."""
271+
reason is:
272+
'reminder' - the first alert for something due: the user asked for this time,
273+
so it always goes out, ignoring the daily cap and quiet hours;
274+
'due' - a repeat nudge for an item still open after its due time;
275+
'stale' - an idea left untouched for a while.
276+
Repeats and stale ideas respect the daily cap (per user) and are held back
277+
while `quiet` (quiet hours) is true. Overdue items go before stale ideas;
278+
within each, items nudged the fewest times go first, then the oldest."""
275279
now = now or now_utc()
276280
now_s = to_str(now)
277281
with conn() as c:
@@ -281,13 +285,16 @@ def pick_nudges(now=None):
281285
(now_s,),
282286
).fetchall()
283287

284-
candidates = []
288+
reminders, candidates = [], []
285289
for r in rows:
286290
last = from_str(r["last_nudged_at"]) if r["last_nudged_at"] else None
287291
if r["due_at"]:
288-
if (
289-
r["due_at"] <= now_s
290-
and r["nudge_count"] < DUE_MAX_NUDGES
292+
if r["due_at"] > now_s:
293+
continue
294+
if r["nudge_count"] == 0:
295+
reminders.append((r, "reminder"))
296+
elif (
297+
r["nudge_count"] < DUE_MAX_NUDGES
291298
and (last is None or now - last >= timedelta(hours=DUE_GAP_HOURS))
292299
):
293300
candidates.append((0, r["nudge_count"], r["due_at"], r["id"], r, "due"))
@@ -299,9 +306,14 @@ def pick_nudges(now=None):
299306
and (last is None or now - last >= timedelta(days=IDEA_GAP_DAYS))
300307
):
301308
candidates.append((1, r["nudge_count"], r["created_at"], r["id"], r, "stale"))
309+
reminders.sort(key=lambda t: (t[0]["due_at"], t[0]["id"]))
310+
if quiet:
311+
return reminders
302312
candidates.sort(key=lambda t: t[:4])
303313

304-
picked, per_user = [], {}
314+
picked, per_user = list(reminders), {}
315+
for r, _ in reminders:
316+
per_user[r["user_id"]] = per_user.get(r["user_id"], nudges_last_24h(r["user_id"], now)) + 1
305317
for *_, r, reason in candidates:
306318
uid = r["user_id"]
307319
if uid not in per_user:

0 commit comments

Comments
 (0)