Implement a "Quiet Mode" feature that allows users to toggle into a one-way journaling state. When Quiet Mode is active, the chatbot (Maya) stops responding to user messages entirely — the user can dump thoughts without conversational interruptions. All messages are still persisted. When the user exits Quiet Mode, everything written during the session is summarized and stored in the memory/remembering system with a "Quiet Mode" tag.
model User {
// ... existing fields ...
quietModeActive Boolean @default(false)
quietModeStartedAt DateTime?
// incremented counter for each quiet-mode session to create unique tags
quietModeSessionCount Int @default(0)
}Migration name: add_quiet_mode
Reasoning: Quiet mode is per-user, not per-conversation. A user who enables it in Telegram should also be in quiet mode on web/mobile. The quietModeSessionCount allows tagging summaries as "Quiet Mode #1", "Quiet Mode #2", etc.
model ChatMessage {
// ... existing fields ...
quietModeSessionId Int? // null for normal messages; set to User.quietModeSessionCount for quiet-mode messages
}This lets us efficiently query all messages belonging to a specific quiet-mode session at exit time without relying on timestamps.
Toggle Quiet Mode on/off.
Auth: JWT required.
Request body:
{
"enable": true // true = enter quiet mode, false = exit quiet mode
}Response (200) — entering quiet mode:
{
"quietModeActive": true,
"startedAt": "2026-05-16T12:30:00.000Z",
"sessionNumber": 3
}Response (200) — exiting quiet mode (summary generated):
{
"quietModeActive": false,
"sessionNumber": 3,
"messageCount": 14,
"summary": "You wrote about feeling stuck at work, a tense conversation with your manager, and excitement about an upcoming trip to Goa...",
"summaryStored": true
}Implementation logic:
Entering quiet mode:
- Verify user is authenticated and onboarded.
- If already active, return 409 Conflict.
- Atomically increment
quietModeSessionCountand setquietModeActive = true,quietModeStartedAt = now(). - Evict all user pi sessions (
evictUserSessions) — the next turn after quiet mode will need a fresh context. - Track analytics event:
quiet_mode_entered. - Return the new session number.
Exiting quiet mode:
- Verify user is authenticated and quiet mode is currently active.
- Query all
ChatMessagerows wherequietModeSessionId === user.quietModeSessionCount(the current session), ordered bycreatedAt ASC. - If message count is 0, just disable quiet mode and return empty summary.
- Build a summarization prompt using the collected messages.
- Call the LLM (use same model as regular chat, or a cheaper one for summarization — gpt-4o-mini is fine) with a prompt like:
You are summarizing a "Quiet Mode" journaling session. The user wrote these thoughts without expecting a response. Summarize what they wrote in a first-person narrative (as if the user wrote it themselves). Include key themes, emotions, people mentioned, decisions, and recurring patterns. Keep it concise (3-6 paragraphs). Do NOT address the user or offer advice — this is a personal summary.
Messages:
[timestamp] message 1
[timestamp] message 2
...
Output the summary as plain text.
- Store the summary:
- Call
addDocumentto SuperMemory withtype: "quiet_mode",metadata: { sessionNumber, tag: "Quiet Mode", messageCount, startedAt, endedAt }. - Optionally create a
JournalBlockwithsource: CHAT_SESSION(or a newQUIET_MODEsource if you want distinct treatment) containing the summary and raw transcript.
- Call
- Reset
quietModeActive = false,quietModeStartedAt = null. - Track analytics:
quiet_mode_exitedwithmessageCountandsummaryLength. - Return the summary and metadata.
Returns current quiet mode status. No auth required beyond JWT.
Response (200):
{
"quietModeActive": true,
"startedAt": "2026-05-16T12:30:00.000Z",
"sessionNumber": 3,
"messageCount": 7
}At the top of processChat (after assertCanSend but before building the session), add a quiet-mode short-circuit:
// Quiet Mode: persist the user message but do NOT generate an assistant reply.
if (user.quietModeActive) {
const userMessageId = await persistMessage(
user.id,
conversationId,
"USER",
text,
user.quietModeSessionCount, // new param — see below
);
// Increment the flush counter so messages still get flushed to journal.
// But do NOT trigger the LLM path — no session, no reply, no usage event.
const updated = await db.user.update({
where: { id: user.id },
data: { unflushedCount: { increment: 1 } },
select: { unflushedCount: true },
});
if (updated.unflushedCount >= FLUSH_THRESHOLD) {
setImmediate(() => {
flushUserBuffer(user.id).catch((err) => {
log.error("background flush threw (quiet mode)", { userId: user.id, err });
});
});
}
// Return an empty reply. The caller (bot handler / http endpoint) must handle
// the "no reply" case — the bot should just stay silent, the http endpoint
// should return `{ reply: "", quietMode: true }`.
return {
reply: "",
bubbles: [],
turnId: randomUUID(),
userMessageId,
quietMode: true,
};
}Add an optional quietModeSessionId parameter:
async function persistMessage(
userId: string,
conversationId: string,
role: "USER" | "ASSISTANT",
content: string,
quietModeSessionId?: number,
): Promise<string> {
// ... existing logic, add quietModeSessionId to the create data ...
}Add quietMode?: boolean to the return type so callers know whether a reply was intentionally empty.
After processChat returns, check for quietMode:
const result = await processChat({ user, conversationId, text, images });
if (result.quietMode) {
// Don't send any reply. Don't even send "typing".
// The user is in quiet mode — silence is the feature.
return { userMessageId: result.userMessageId };
}
// Normal flow: send the reply
await ctx.reply(result.reply);Also skip the ctx.sendChatAction("typing") call when quiet mode is active (check user.quietModeActive before calling it).
Register a /quiet command in registerHandlers:
bot.command("quiet", async (ctx) => {
const user = await identifyOrCreate(ctx);
if (!user.onboardedAt) {
await ctx.reply("let's finish setting up first, then you can use quiet mode.");
return;
}
if (user.quietModeActive) {
// Exit quiet mode — delegate to the toggle logic
// Show a quick "summarizing..." message, then the summary
await ctx.reply("exiting quiet mode, give me a moment to summarize...");
const summary = await exitQuietMode(user.id); // shared function
if (summary) {
await ctx.reply(`here's what you covered:\n\n${summary}`);
} else {
await ctx.reply("quiet mode off. nothing was written this session.");
}
} else {
// Enter quiet mode
await enterQuietMode(user.id); // shared function
await ctx.reply("quiet mode on. i'll stay quiet — write whenever you're ready. use /quiet again when you're done.");
}
});Optionally, detect exit intent from user messages during quiet mode. If the user sends something like "exit quiet mode", "done quiet mode", "stop quiet mode", or "/quiet", exit automatically. This is a UX nicety:
const EXIT_PATTERNS = [
/^exit quiet mode$/i,
/^stop quiet mode$/i,
/^end quiet mode$/i,
/^done quiet mode$/i,
/^\/quiet$/i,
];
function isQuietModeExitIntent(text: string): boolean {
const trimmed = text.trim().toLowerCase();
return EXIT_PATTERNS.some((p) => p.test(trimmed));
}If matched during quiet mode, trigger the exit + summary flow instead of persisting the exit command as a message.
Same short-circuit pattern as the Telegram handler:
const result = await processChat({ user, conversationId, text: effectiveMessage, images: images?.map(r => r.pi) });
if (result.quietMode) {
sendJson(res, 200, {
conversationId,
reply: "",
persona: user.persona,
quietMode: true,
userMessageId: result.userMessageId,
attachments: [],
});
return;
}For streaming, when quiet mode is active:
- Don't send any deltas
- Just send the done frame with
quietMode: trueand empty reply
if (result.quietMode) {
writeFrame({
type: "done",
conversationId,
reply: "",
turnId: result.turnId,
persona: user.persona,
quietMode: true,
userMessageId: result.userMessageId,
attachments: [],
});
safeEnd();
return;
}Create src/lib/quiet-mode.ts with the shared enter/exit functions used by both the HTTP toggle endpoint and the Telegram /quiet command:
// src/lib/quiet-mode.ts
import { db } from "./db.js";
import { log } from "./log.js";
import { track } from "./analytics.js";
import { addDocument } from "./supermemory.js";
import { decryptForUser } from "./crypto.js";
import { evictUserSessions } from "./pi-runtime.js";
import { generateSummary } from "./summarize.js"; // or a dedicated summarizer
export async function enterQuietMode(userId: string): Promise<{
sessionNumber: number;
startedAt: Date;
}> {
const user = await db.user.update({
where: { id: userId },
data: {
quietModeActive: true,
quietModeStartedAt: new Date(),
quietModeSessionCount: { increment: 1 },
},
select: {
quietModeSessionCount: true,
quietModeStartedAt: true,
},
});
evictUserSessions(userId);
track("quiet_mode_entered", { sessionNumber: user.quietModeSessionCount }, userId);
log.info("quiet mode: entered", { userId, sessionNumber: user.quietModeSessionCount });
return {
sessionNumber: user.quietModeSessionCount,
startedAt: user.quietModeStartedAt!,
};
}
export async function exitQuietMode(userId: string): Promise<{
sessionNumber: number;
messageCount: number;
summary: string | null;
}> {
const user = await db.user.findUniqueOrThrow({
where: { id: userId },
select: { quietModeSessionCount: true, quietModeActive: true },
});
if (!user.quietModeActive) {
throw new Error("Quiet mode is not active");
}
const sessionNumber = user.quietModeSessionCount;
// Fetch all messages from this quiet mode session
const messages = await db.chatMessage.findMany({
where: {
userId,
quietModeSessionId: sessionNumber,
role: "USER",
},
orderBy: { createdAt: "asc" },
select: { content: true, createdAt: true },
});
const messageCount = messages.length;
let summary: string | null = null;
if (messageCount > 0) {
// Decrypt and format messages
const decryptedMessages = await Promise.all(
messages.map(async (m) => ({
timestamp: m.createdAt.toISOString(),
content: await decryptForUser(userId, m.content),
}))
);
const transcript = decryptedMessages
.map((m) => `[${m.timestamp}] ${m.content}`)
.join("\n\n");
// Generate summary
summary = await generateQuietModeSummary(userId, transcript, sessionNumber);
// Store in SuperMemory
try {
await addDocument({
userId,
content: summary ?? transcript.slice(0, 2000),
customId: `quiet_mode_${userId}_${sessionNumber}`,
type: "quiet_mode",
metadata: {
sessionNumber,
messageCount,
tag: "Quiet Mode",
date: new Date().toISOString().slice(0, 10),
},
});
log.info("quiet mode: summary stored in supermemory", {
userId,
sessionNumber,
messageCount,
});
} catch (err) {
log.error("quiet mode: supermemory store failed", {
userId,
sessionNumber,
err: err instanceof Error ? err.message : String(err),
});
}
// Optionally create a JournalBlock for the quiet mode session
try {
// ... JournalBlock creation logic ...
} catch (err) {
log.error("quiet mode: journal block creation failed", {
userId,
sessionNumber,
err: err instanceof Error ? err.message : String(err),
});
}
}
// Disable quiet mode
await db.user.update({
where: { id: userId },
data: {
quietModeActive: false,
quietModeStartedAt: null,
},
});
track("quiet_mode_exited", {
sessionNumber,
messageCount,
hasSummary: !!summary,
}, userId);
log.info("quiet mode: exited", { userId, sessionNumber, messageCount });
return { sessionNumber, messageCount, summary };
}Create the summarization function:
async function generateQuietModeSummary(
userId: string,
transcript: string,
sessionNumber: number,
): Promise<string> {
// Use gpt-4o-mini or the cheapest capable model for cost efficiency.
// This is a batch job, not a real-time turn.
const prompt = `You are summarizing a "Quiet Mode" journaling session from a journaling app called Talkamore. During quiet mode, the user writes freely without receiving any responses — it's a one-way brain dump.
Summarize what they wrote in a concise first-person narrative (as if the user was reflecting back on what they wrote). Include:
- Key themes and topics
- Emotional tone and shifts
- People or relationships mentioned
- Decisions, dilemmas, or realizations
- Recurring patterns (if any are visible)
Style: reflective, grounded, no cheerleading. Do NOT address the user ("you"). Do NOT offer advice. This is a personal summary for the user's own memory. Write in the user's voice.
Session #${sessionNumber}:
${transcript.slice(0, 8000)}
Summary:`;
// Call the LLM — reuse the existing OpenAI/Anthropic client patterns from pi-runtime.ts
const response = await callModelForSummary(prompt);
return response.trim();
}| Scenario | Handling |
|---|---|
| User tries to enter quiet mode while already in it | Return 409 Conflict, no-op |
| User tries to exit but isn't in quiet mode | Return 400, no-op |
| Zero messages written during quiet mode | Exit silently, no summary generated, no SuperMemory write |
| LLM call fails during summarization | Fall back to storing the raw transcript in SuperMemory; log error |
| SuperMemory write fails during exit | Log error, still disable quiet mode, return partial success |
User sends /quiet during onboarding |
Reject with message to finish onboarding first |
| User changes persona during quiet mode | Allow it — quiet mode is persona-agnostic |
| User accesses web app while quiet mode is active on Telegram | Quiet mode is user-global — web UI should also show quiet mode state and not generate replies |
- Enter quiet mode via
/quietcommand on Telegram - Send messages — verify no responses come back
- Send messages — verify they're persisted in
chat_messageswith correctquietModeSessionId - Exit quiet mode — verify summary is generated
- Exit quiet mode — verify summary is stored in SuperMemory with "Quiet Mode" tag
- Enter/exit via HTTP API endpoints
- Zero-message quiet mode session (enter then immediately exit)
- Multi-hundred message quiet mode session (test summarization at scale)
- Quiet mode survives server restart (state is in DB)
- Analytics events fire correctly
- Existing chat functionality is unaffected when quiet mode is off
- Concurrent web + Telegram quiet mode consistency
| File | Change |
|---|---|
backend/prisma/schema.prisma |
Add quietModeActive, quietModeStartedAt, quietModeSessionCount to User; add quietModeSessionId to ChatMessage |
backend/src/lib/quiet-mode.ts |
New file — enter/exit logic, summarization |
backend/src/lib/chat.ts |
Add quiet-mode short-circuit in processChat; update persistMessage |
backend/src/bot/handlers.ts |
Add /quiet command; skip reply for quiet-mode turns |
backend/src/api/chat.ts |
Handle empty replies with quietMode: true flag |
backend/src/api/me-preferences.ts (or new route file) |
Add POST toggle + GET status endpoints |
backend/src/api/server.ts |
Register new routes |