Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A self-hosted personal AI agent that runs in a single Docker container. MPA acts
- **Email** — Read, compose, and manage emails via [Himalaya](https://github.com/pimalaya/himalaya) CLI
- **Calendar** — CalDAV integration (Google Calendar, iCloud, etc.)
- **Contacts** — CardDAV providers via the built-in contacts CLI
- **Personae** — Swappable agent identities (own character, skill/tool scope, voice). Bind one per chat — and, on Telegram, per forum topic — so several run concurrently, each with its own isolated context
- **Personae** — Swappable agent identities (own character, skill/tool scope, voice). Bind one per chat — and, on Telegram, per forum topic — so several run concurrently, each with its own isolated context. Give a persona its own bot token and it becomes a separate Telegram contact (bot-per-persona)
- **Memory** — Two-tier system: permanent long-term facts and expiring short-term context, both extracted automatically from conversations
- **Scheduled tasks** — Cron-based jobs for morning briefings, email checks, contact sync, and custom tasks
- **Voice** — Speech-to-text (faster-whisper) and text-to-speech (edge-tts)
Expand Down
33 changes: 22 additions & 11 deletions api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,21 @@ class SkillUpsertIn(BaseModel):
content: str


def _persona_public(persona) -> dict:
"""Persona as JSON for read-only APIs, with the bot token redacted (#29).

Mirrors how the global Telegram token is redacted in config reads — the token
is a secret and must not leave the server in cleartext (exports, list views).
"""
from dataclasses import asdict, replace

from core.config_store import _redact
from core.personae import to_markdown

safe = replace(persona, bot_token=_redact(persona.bot_token))
return {**asdict(safe), "markdown": to_markdown(safe)}


class PersonaUpsertIn(BaseModel):
name: str
agent_name: str = ""
Expand All @@ -493,6 +508,8 @@ class PersonaUpsertIn(BaseModel):
skills: list[str] = []
tools: list[str] = []
secrets: list[str] = []
bot_token: str = "" # per-persona Telegram bot (#29); empty = no own bot
allowed_user_ids: str = "" # comma/newline-separated; empty = inherit global
raw: str = "" # when set, the markdown doc is parsed instead of the fields above


Expand Down Expand Up @@ -2310,34 +2327,26 @@ async def _personae_partial() -> HTMLResponse:

@app.get("/personae", dependencies=[Depends(auth)])
async def list_personae() -> dict:
from dataclasses import asdict

from core.personae import to_markdown

store = await _persona_store_from_config(config_store)
personae = await store.list_personae()
active = (await config_store.get("agent.active_persona") or "").strip()
return {
"count": len(personae),
"active": active,
"personae": [{**asdict(p), "markdown": to_markdown(p)} for p in personae],
"personae": [_persona_public(p) for p in personae],
}

@app.get("/personae/{name}", dependencies=[Depends(auth)])
async def get_persona(name: str) -> dict:
from dataclasses import asdict

from core.personae import to_markdown

store = await _persona_store_from_config(config_store)
persona = await store.get(name)
if not persona:
raise HTTPException(404, f"Persona not found: {name}")
return {**asdict(persona), "markdown": to_markdown(persona)}
return _persona_public(persona)

@app.post("/personae", dependencies=[Depends(auth)])
async def upsert_persona(body: PersonaUpsertIn) -> HTMLResponse:
from core.personae import Persona, parse_markdown
from core.personae import Persona, _as_int_list, parse_markdown

name = body.name.strip()
if not name:
Expand All @@ -2357,6 +2366,8 @@ async def upsert_persona(body: PersonaUpsertIn) -> HTMLResponse:
skills=[s.strip() for s in body.skills if s.strip()],
tools=[t.strip() for t in body.tools if t.strip()],
secrets=[s.strip() for s in body.secrets if s.strip()],
bot_token=body.bot_token.strip(),
allowed_user_ids=_as_int_list(body.allowed_user_ids),
)
store = await _persona_store_from_config(config_store)
await store.upsert(persona)
Expand Down
21 changes: 20 additions & 1 deletion api/templates/persona_editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ <h1 class="text-xl text-accent">{% if is_new %}New Persona{% else %}Edit Persona
</div>
</div>

<div class="card space-y-3">
<h3 class="text-xs text-muted uppercase tracking-wider">Own Telegram bot <span class="text-muted normal-case tracking-normal">— optional</span></h3>
<p class="text-xs text-muted">Give this persona its own bot token and it becomes a separate Telegram contact. Leave blank to keep it reachable only via the default bot. Restart the agent after changing.</p>
<div>
<label class="label">Bot token <span class="text-muted">(from @BotFather)</span></label>
<input class="input-sm" type="password" x-model="bot_token" autocomplete="off" placeholder="123456:ABC-DEF…">
</div>
<div>
<label class="label">Allowed user IDs <span class="text-muted">(comma-separated; blank = inherit global)</span></label>
<input class="input-sm" x-model="allowedUserIds" placeholder="111111111, 222222222">
</div>
</div>

<div class="card">
<label class="label">Personalia <span class="text-muted">— who the agent is in this persona</span></label>
<textarea class="textarea" x-model="personalia" style="min-height:160px; font-size:0.8rem; line-height:1.5"
Expand Down Expand Up @@ -109,7 +122,7 @@ <h3 class="text-xs text-muted uppercase tracking-wider mb-2">Secret scope</h3>
<div x-show="mode === 'raw'" class="card">
<label class="label">Markdown (YAML frontmatter)</label>
<textarea class="textarea" x-model="raw" style="min-height:460px; font-size:0.8rem; line-height:1.5; tab-size:2"></textarea>
<p class="hint">Saving in raw mode parses this document directly — frontmatter keys (role, emoji, voice, skills, tools, secrets, personalia, character).</p>
<p class="hint">Saving in raw mode parses this document directly — frontmatter keys (role, emoji, voice, bot_token, allowed_user_ids, skills, tools, secrets, personalia, character).</p>
</div>

<div class="flex items-center gap-2 mt-3">
Expand All @@ -127,6 +140,8 @@ <h3 class="text-xs text-muted uppercase tracking-wider mb-2">Secret scope</h3>
role: {{ persona.role|tojson }},
emoji: {{ persona.emoji|tojson }},
voice: {{ persona.voice|tojson }},
bot_token: {{ persona.bot_token|tojson }},
allowedUserIds: {{ (persona.allowed_user_ids|join(', '))|tojson }},
personalia: {{ persona.personalia|tojson }},
character: {{ persona.character|tojson }},
skills: {{ persona.skills|tojson }},
Expand All @@ -141,12 +156,15 @@ <h3 class="text-xs text-muted uppercase tracking-wider mb-2">Secret scope</h3>
const esc = (s) => (s || '').replace(/\n/g, '\n ');
const list = (a) => a && a.length ? '[' + a.join(', ') + ']' : '[]';
const secrets = this.secretsText.split('\n').map(s => s.trim()).filter(Boolean);
const userIds = this.allowedUserIds.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
this.raw =
'---\n' +
'agent_name: ' + (this.agent_name || '') + '\n' +
'role: ' + (this.role || '') + '\n' +
'emoji: ' + (this.emoji || '') + '\n' +
'voice: ' + (this.voice || '') + '\n' +
'bot_token: ' + JSON.stringify(this.bot_token || '') + '\n' +
'allowed_user_ids: ' + list(userIds) + '\n' +
'skills: ' + list(this.skills) + '\n' +
'tools: ' + list(this.tools) + '\n' +
'secrets: ' + list(secrets) + '\n' +
Expand All @@ -166,6 +184,7 @@ <h3 class="text-xs text-muted uppercase tracking-wider mb-2">Secret scope</h3>
personalia: this.personalia, character: this.character,
skills: this.skills, tools: this.tools,
secrets: this.secretsText.split('\n').map(s => s.trim()).filter(Boolean),
bot_token: this.bot_token, allowed_user_ids: this.allowedUserIds,
};
fetch('/personae', {
method: 'POST',
Expand Down
32 changes: 26 additions & 6 deletions channels/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,30 @@

class TelegramChannel:
def __init__(
self, config: TelegramConfig, agent: AgentCore, voice: VoicePipeline | None = None
self,
config: TelegramConfig,
agent: AgentCore,
voice: VoicePipeline | None = None,
channel_name: str = "telegram",
):
self.config = config
self.agent = agent
self.voice = voice
# The channel string this bot reports to the agent. The default bot is
# bare "telegram"; a per-persona bot is "telegram:<persona>" (#29), which
# silos history and resolves straight to that persona.
self.channel_name = channel_name
# Last chat a user wrote from, used to route approval prompts. Holds a
# folded "<chat>:<thread>" string when the message came from a topic.
self._last_chat_for_user: dict[int, int | str] = {}
self.app = Application.builder().token(config.bot_token).concurrent_updates(8).build()
self.app.add_handler(MessageHandler(filters.TEXT, self._on_text))
self.app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, self._on_voice))
self.app.add_handler(MessageHandler(filters.PHOTO | filters.Document.IMAGE, self._on_photo))
if config.topics_enabled:
# Topic→persona auto-bind only makes sense on the default bot: a persona
# bot resolves straight to its own persona (rung 0), so a per-topic binding
# would be ignored. Topic *folding* (history isolation) still applies below.
if config.topics_enabled and channel_name == "telegram":
self.app.add_handler(
MessageHandler(
filters.StatusUpdate.FORUM_TOPIC_CREATED
Expand Down Expand Up @@ -273,7 +284,9 @@ async def _on_forum_topic(self, update: Update, context) -> None:
if user_id is None or not self._is_allowed(user_id):
return
chat_id = f"{chat.id}:{thread}"
bound = await self.agent.bind_chat_persona_by_label("telegram", str(user_id), chat_id, name)
bound = await self.agent.bind_chat_persona_by_label(
self.channel_name, str(user_id), chat_id, name
)
if bound:
await self.send(chat_id, f"Bound this topic to {bound}.")

Expand Down Expand Up @@ -368,6 +381,13 @@ async def _progress(self, chat_id: int | str):
poll it and edit a single Telegram message in place (the chat equivalent
of the REPL's self-updating spinner line). No-op when nothing is running.
"""
# ponytail: the explore status file is a single global singleton, so only
# the default bot mirrors it — otherwise a run triggered via one persona-bot
# would bubble into every other bot's chat (#29). Per-run scoping (a status
# path keyed by channel/profile) belongs in the browser tool — follow-up.
if self.channel_name != "telegram":
yield
return
status = Path("/app/data" if Path("/app/data").exists() else "data")
status = status / "browser" / "last" / "explore.status"
cid, kw = self._route(chat_id) # split a folded "<chat>:<thread>" topic id
Expand Down Expand Up @@ -423,7 +443,7 @@ async def _handle_text(self, text: str, user_id: int, chat_id: int | str) -> Non
async with self._typing(chat_id), self._progress(chat_id):
response = await self.agent.process(
message=text,
channel="telegram",
channel=self.channel_name,
user_id=str(user_id),
chat_id=str(chat_id),
)
Expand Down Expand Up @@ -459,7 +479,7 @@ async def _handle_voice(
content = f"{reply_context}{content}"
response = await self.agent.process(
message=content,
channel="telegram",
channel=self.channel_name,
user_id=str(user_id),
chat_id=str(chat_id),
)
Expand Down Expand Up @@ -504,7 +524,7 @@ async def _handle_photo(
content = f"{reply_context}{content}" if content else reply_context
response = await self.agent.process(
message=content,
channel="telegram",
channel=self.channel_name,
user_id=str(user_id),
attachments=attachments,
chat_id=str(chat_id),
Expand Down
28 changes: 22 additions & 6 deletions core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,13 +448,19 @@ async def process(
user_id: str,
attachments: list[Attachment] | None = None,
chat_id: str = "",
persona_name: str | None = None,
) -> AgentResponse:
"""Process an incoming message through the LLM with tool-use loop.

``chat_id`` distinguishes different chats for the same user (e.g.
a private Telegram chat vs. a group chat). Each unique
(channel, user_id, chat_id) triple gets its own conversation history,
preventing context leakage across chats.

``persona_name`` forces the identity instead of resolving it from the
channel/binding ladder — used by the scheduler so a ``telegram:<persona>``
job is generated *as* that persona while keeping the ``system`` execution
mode (auto-approved writes, no memory/reflection) (#29).
"""

# Handle /new (alias /clear) command — clear conversational context.
Expand Down Expand Up @@ -482,8 +488,12 @@ async def process(
preamble = self._turn_preamble(decomposed_goal)

# Resolve the active persona (its identity, skills + tool scope) — a
# per-chat binding wins over the globally selected persona (#14).
persona = await self._resolve_persona(channel, user_id, chat_id)
# per-chat binding wins over the globally selected persona (#14). An
# explicit override (scheduler) skips the ladder (#29).
if persona_name:
persona = await self._load_persona(persona_name)
else:
persona = await self._resolve_persona(channel, user_id, chat_id)
tools = apply_feature_gates(
scoped_tools(persona),
secrets_available=self.secret_store is not None,
Expand Down Expand Up @@ -520,14 +530,20 @@ async def process(
async def _resolve_persona(self, channel: str, user_id: str, chat_id: str) -> Persona | None:
"""Resolve the active persona for this request, in precedence order:

0. a per-persona bot — a ``"telegram:<name>"`` channel binds straight to
persona ``<name>``: the bot that received the message *is* the persona (#29),
1. the per-chat binding for ``(channel, user_id, chat_id)`` (#14),
2. the globally-selected persona (``config.agent.active_persona``, #13),
3. the default identity (``None``).

A future per-persona bot (#29) will add a rung above (1): a
``"telegram:<name>"`` channel would resolve straight to that persona.
Not wired here — no such channel exists yet.
"""
# 0. Bot-per-persona: the channel name carries the persona (e.g. "telegram:coach").
_, sep, persona_name = channel.partition(":")
if sep and persona_name:
persona = await self._load_persona(persona_name)
if persona:
return persona
# Unknown/deleted persona — fall through to the ordinary ladder.

# 1. Per-chat binding.
bound = await self.history.get_chat_persona(channel, user_id, chat_id)
if bound:
Expand Down
Loading
Loading