feat: add Matrix appservice bot and platform-agnostic conversation core
Introduce a shared ConversationService (steward/bot/core.py) that owns the LLM call, history, knowledge-base search, and thread-memory keying behind a normalized ThreadKey, so both Telegram and Matrix drive the same pipeline. - Add steward/bot/matrix.py: a mautrix-python appservice bot that receives Synapse transactions and replies via the client-server API. - Refactor telegram.py handlers into thin wrappers over ConversationService. - Generalize ThreadMemoryStore/ThreadSummary to platform-scoped keys with legacy chat_id:thread_id migration. - Add a matrix config section (homeserver, tokens, room/user allowlists). - Rewrite main.py as async, starting Telegram and/or Matrix on one event loop. - Add mautrix>=0.21.0 dependency. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""Platform-agnostic conversation core for Steward.
|
||||
|
||||
This module owns the shared message pipeline (LLM call, history management,
|
||||
knowledge-base search, and thread-memory keying) so that both the Telegram
|
||||
and Matrix adapters can drive the same behaviour without duplicating logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from steward.bot.thread_key import ThreadKey
|
||||
from steward.config import Settings
|
||||
from steward.llm.client import LLMClient
|
||||
from steward.memory.thread_store import ThreadMemoryStore, ThreadSummary
|
||||
from steward.tools.client import ToolClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_HISTORY = 20
|
||||
|
||||
_FLUSH_SYSTEM_PROMPT = (
|
||||
"You are Steward. The following is a complete conversation thread. "
|
||||
"Produce a concise but comprehensive summary that captures:\n"
|
||||
"- The main topics discussed\n"
|
||||
"- Key decisions or conclusions reached\n"
|
||||
"- Any outstanding actions or open questions\n"
|
||||
"- Important context that would help recall this conversation later\n\n"
|
||||
"Be precise. Omit pleasantries."
|
||||
)
|
||||
|
||||
_TAGS_SYSTEM_PROMPT = (
|
||||
"You are a keyword tagger for a knowledge base. "
|
||||
"Extract 5-8 short, lowercase keyword tags from the following conversation summary. "
|
||||
"Tags should represent the main topics, entities, and concepts discussed. "
|
||||
"Return ONLY a comma-separated list of tags with no other text or punctuation. "
|
||||
"Example output: api design, authentication, database schema, user roles, caching"
|
||||
)
|
||||
|
||||
_KB_CONTEXT_HEADER = (
|
||||
"The following are relevant past conversation summaries from your knowledge base. "
|
||||
"Use them as background context if they relate to the current question, "
|
||||
"but do not repeat their contents unless directly asked."
|
||||
)
|
||||
|
||||
|
||||
class ConversationService:
|
||||
"""Owns the shared message pipeline used by all platform adapters."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
llm: LLMClient,
|
||||
thread_store: ThreadMemoryStore,
|
||||
tool_client: ToolClient | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._llm = llm
|
||||
self._store = thread_store
|
||||
self._tool_client = tool_client
|
||||
self._histories: dict[ThreadKey, list[dict[str, Any]]] = {}
|
||||
|
||||
def _history_for(self, key: ThreadKey) -> list[dict[str, Any]]:
|
||||
return self._histories.setdefault(key, [])
|
||||
|
||||
@property
|
||||
def llm(self) -> LLMClient:
|
||||
"""The shared LLM client (used by platform adapters for ad-hoc calls)."""
|
||||
return self._llm
|
||||
|
||||
def _with_kb_context(
|
||||
self,
|
||||
history: list[dict[str, Any]],
|
||||
relevant: list[ThreadSummary],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not relevant:
|
||||
return history
|
||||
snippets = [f"[Thread {s.thread_id}] {s.summary[:400]}" for s in relevant[:3]]
|
||||
kb_msg = _KB_CONTEXT_HEADER + "\n\n" + "\n\n---\n\n".join(snippets)
|
||||
return [{"role": "system", "content": kb_msg}, *history]
|
||||
|
||||
async def process_message(
|
||||
self,
|
||||
key: ThreadKey,
|
||||
user_id: str,
|
||||
text: str,
|
||||
system_prompt: str,
|
||||
history_cap: int | None = None,
|
||||
history_formatter: Callable[[str], str] | None = None,
|
||||
) -> str:
|
||||
"""Process a user message and return the raw assistant reply text.
|
||||
|
||||
The reply is the raw LLM output; platform adapters are responsible for
|
||||
parsing and rendering it (e.g. Telegram polls/multi-message markers).
|
||||
``history_formatter`` transforms the raw reply into the text stored in
|
||||
conversation history (defaults to the raw reply).
|
||||
"""
|
||||
history = self._history_for(key)
|
||||
call_history = self._with_kb_context(history, self._store.search(text))
|
||||
if self._tool_client is not None:
|
||||
reply = await self._llm.chat_with_tools(
|
||||
text,
|
||||
self._tool_client,
|
||||
history=call_history,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
else:
|
||||
reply = await self._llm.chat(text, history=call_history, system_prompt=system_prompt)
|
||||
history.append({"role": "user", "content": text})
|
||||
stored_reply = history_formatter(reply) if history_formatter else reply
|
||||
history.append({"role": "assistant", "content": stored_reply})
|
||||
if history_cap is not None and len(history) > history_cap:
|
||||
self._histories[key] = history[-history_cap:]
|
||||
return reply
|
||||
|
||||
def clear_history(self, key: ThreadKey) -> None:
|
||||
"""Wipe the in-memory conversation history for a scope."""
|
||||
self._histories.pop(key, None)
|
||||
|
||||
def has_history(self, key: ThreadKey) -> bool:
|
||||
"""Return True if the scope has any in-memory conversation history."""
|
||||
return bool(self._histories.get(key))
|
||||
|
||||
async def flush_history(self, key: ThreadKey) -> ThreadSummary | None:
|
||||
"""Summarise the scope's history, persist it, and compress in-memory history.
|
||||
|
||||
Returns the persisted :class:`ThreadSummary`, or ``None`` if there was no
|
||||
history to flush.
|
||||
"""
|
||||
history = self._history_for(key)
|
||||
if not history:
|
||||
return None
|
||||
|
||||
transcript_lines = []
|
||||
for msg in history:
|
||||
role_label = "User" if msg["role"] == "user" else "Steward"
|
||||
transcript_lines.append(f"{role_label}: {msg['content']}")
|
||||
transcript = "\n".join(transcript_lines)
|
||||
|
||||
summary_text = await self._llm.chat(
|
||||
f"Thread transcript:\n\n{transcript}",
|
||||
system_prompt=_FLUSH_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
tags_raw = await self._llm.chat(
|
||||
f"Summary to tag:\n\n{summary_text}",
|
||||
system_prompt=_TAGS_SYSTEM_PROMPT,
|
||||
)
|
||||
tags = [t.strip().lower() for t in tags_raw.split(",") if t.strip()][:8]
|
||||
|
||||
message_count = sum(1 for m in history if m["role"] == "user")
|
||||
thread_summary = ThreadSummary(
|
||||
platform=key.platform,
|
||||
scope=key.scope,
|
||||
thread=key.thread,
|
||||
summary=summary_text,
|
||||
message_count=message_count,
|
||||
tags=tags,
|
||||
)
|
||||
self._store.save(thread_summary)
|
||||
|
||||
self._histories[key] = [
|
||||
{"role": "system", "content": f"Summary of earlier conversation:\n{summary_text}"}
|
||||
]
|
||||
return thread_summary
|
||||
|
||||
def get_summary(self, key: ThreadKey) -> ThreadSummary | None:
|
||||
"""Return the stored summary for a scope, or None if not found."""
|
||||
return self._store.get(key)
|
||||
|
||||
def search_summaries(self, query: str) -> list[ThreadSummary]:
|
||||
"""Search the knowledge base for summaries whose tags overlap the query."""
|
||||
return self._store.search(query)
|
||||
|
||||
def all_summaries(self) -> list[ThreadSummary]:
|
||||
"""Return all stored summaries, newest first."""
|
||||
return self._store.all()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Matrix appservice bot interface for Steward.
|
||||
|
||||
Registers as a Synapse application service. Synapse pushes room events to the
|
||||
appservice HTTP server (``/_matrix/app/v1/transactions/{txnId}``); the bot
|
||||
replies through the client-server API using the appservice's ``as_token``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from mautrix.appservice import AppService
|
||||
from mautrix.types import (
|
||||
Event,
|
||||
EventType,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
TextMessageEventContent,
|
||||
)
|
||||
|
||||
from steward.bot.core import ConversationService
|
||||
from steward.bot.thread_key import ThreadKey
|
||||
from steward.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MATRIX_SYSTEM_APPENDIX = """
|
||||
Matrix conversation guidance:
|
||||
- Reply like a thoughtful software engineer in a chat, not like a one-shot FAQ bot.
|
||||
- Ask a brief follow-up question when the requested outcome, constraints, or preferred
|
||||
option is unclear.
|
||||
- Keep replies to a single message unless splitting genuinely helps readability.
|
||||
""".strip()
|
||||
|
||||
|
||||
class StewardMatrixBot:
|
||||
"""Matrix appservice bot that drives the shared conversation pipeline."""
|
||||
|
||||
def __init__(self, settings: Settings, service: ConversationService) -> None:
|
||||
self._settings = settings
|
||||
self._service = service
|
||||
self._az = AppService(
|
||||
server=settings.matrix.homeserver_url,
|
||||
domain=settings.matrix.homeserver_domain,
|
||||
as_token=settings.matrix.as_token,
|
||||
hs_token=settings.matrix.hs_token,
|
||||
bot_localpart=settings.matrix.bot_localpart,
|
||||
id=settings.matrix.appservice_id,
|
||||
)
|
||||
self._az.matrix_event_handler(self._on_event)
|
||||
|
||||
@property
|
||||
def bot_mxid(self) -> str:
|
||||
return self._az.bot_mxid
|
||||
|
||||
def _is_allowed_user(self, sender: str) -> bool:
|
||||
allowed = self._settings.matrix.allowed_user_ids
|
||||
if not allowed:
|
||||
return True
|
||||
return sender in allowed
|
||||
|
||||
def _is_allowed_room(self, room_id: str) -> bool:
|
||||
allowed = self._settings.matrix.allowed_room_ids
|
||||
if not allowed:
|
||||
return True
|
||||
return room_id in allowed
|
||||
|
||||
def _matrix_system_prompt(self) -> str:
|
||||
base_prompt = self._settings.openai_system_prompt.strip()
|
||||
if not base_prompt:
|
||||
return _MATRIX_SYSTEM_APPENDIX
|
||||
return f"{base_prompt}\n\n{_MATRIX_SYSTEM_APPENDIX}"
|
||||
|
||||
async def _on_event(self, evt: Event) -> None:
|
||||
if not isinstance(evt, MessageEvent):
|
||||
return
|
||||
if evt.sender == self.bot_mxid:
|
||||
return
|
||||
if evt.type != EventType.ROOM_MESSAGE:
|
||||
return
|
||||
if not isinstance(evt.content, TextMessageEventContent):
|
||||
return
|
||||
if not self._is_allowed_user(evt.sender):
|
||||
logger.info("Ignoring message from unauthorized user %s", evt.sender)
|
||||
return
|
||||
if not self._is_allowed_room(evt.room_id):
|
||||
logger.info("Ignoring message in unauthorized room %s", evt.room_id)
|
||||
return
|
||||
|
||||
body = (evt.content.body or "").strip()
|
||||
if not body:
|
||||
return
|
||||
|
||||
key = ThreadKey(platform="matrix", scope=evt.room_id)
|
||||
reply = await self._service.process_message(
|
||||
key,
|
||||
evt.sender,
|
||||
body,
|
||||
self._matrix_system_prompt(),
|
||||
history_cap=40,
|
||||
)
|
||||
if not reply.strip():
|
||||
return
|
||||
|
||||
content = TextMessageEventContent(msgtype=MessageType.TEXT, body=reply)
|
||||
await self._az.intent.send_message(evt.room_id, content)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Start the appservice HTTP server and keep the event loop alive."""
|
||||
await self._az.start(
|
||||
host=self._settings.matrix.listen_host,
|
||||
port=self._settings.matrix.listen_port,
|
||||
)
|
||||
logger.info(
|
||||
"Matrix appservice listening on %s:%s (bot %s)",
|
||||
self._settings.matrix.listen_host,
|
||||
self._settings.matrix.listen_port,
|
||||
self.bot_mxid,
|
||||
)
|
||||
await self._az.intent.set_displayname("Steward")
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self._az.stop()
|
||||
+61
-184
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -16,23 +15,13 @@ from telegram.ext import (
|
||||
filters,
|
||||
)
|
||||
|
||||
from steward.bot.core import ConversationService
|
||||
from steward.bot.thread_key import ThreadKey
|
||||
from steward.config import Settings
|
||||
from steward.llm.client import LLMClient
|
||||
from steward.memory.thread_store import ThreadMemoryStore, ThreadSummary
|
||||
from steward.proposals.generator import Proposal, ProposalGenerator
|
||||
from steward.tools.client import ToolClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-user conversation history for non-threaded messages (capped at _MAX_HISTORY turns).
|
||||
_history: dict[int, list[dict[str, Any]]] = defaultdict(list)
|
||||
_MAX_HISTORY = 20
|
||||
|
||||
# Per-thread conversation history: (chat_id, thread_id) → full history (unbounded).
|
||||
# Messages belonging to a Telegram message thread are kept in their entirety here
|
||||
# until explicitly flushed by the /flush command.
|
||||
_thread_history: dict[tuple[int, int], list[dict[str, Any]]] = defaultdict(list)
|
||||
|
||||
_FLUSH_SYSTEM_PROMPT = (
|
||||
"You are Steward. The following is a complete Telegram message thread conversation. "
|
||||
"Produce a concise but comprehensive summary that captures:\n"
|
||||
@@ -45,18 +34,12 @@ _FLUSH_SYSTEM_PROMPT = (
|
||||
|
||||
_TAGS_SYSTEM_PROMPT = (
|
||||
"You are a keyword tagger for a knowledge base. "
|
||||
"Extract 5–8 short, lowercase keyword tags from the following conversation summary. "
|
||||
"Extract 5\u20138 short, lowercase keyword tags from the following conversation summary. "
|
||||
"Tags should represent the main topics, entities, and concepts discussed. "
|
||||
"Return ONLY a comma-separated list of tags with no other text or punctuation. "
|
||||
"Example output: api design, authentication, database schema, user roles, caching"
|
||||
)
|
||||
|
||||
_KB_CONTEXT_HEADER = (
|
||||
"The following are relevant past conversation summaries from your knowledge base. "
|
||||
"Use them as background context if they relate to the current question, "
|
||||
"but do not repeat their contents unless directly asked."
|
||||
)
|
||||
|
||||
_CONVERSATION_SYSTEM_APPENDIX = """
|
||||
Telegram conversation guidance:
|
||||
- Reply like a thoughtful software engineer in a chat, not like a one-shot FAQ bot.
|
||||
@@ -111,8 +94,8 @@ class TelegramResponsePlan:
|
||||
return [action for action in self.actions if isinstance(action, PollRequest)]
|
||||
|
||||
|
||||
def _thread_key(update: Update) -> tuple[int, int] | None:
|
||||
"""Return the (chat_id, thread_id) key if the message is part of a thread, else None."""
|
||||
def _thread_key(update: Update) -> ThreadKey | None:
|
||||
"""Return the ThreadKey if the message is part of a Telegram thread, else None."""
|
||||
msg = update.message
|
||||
chat = update.effective_chat
|
||||
if msg is None or chat is None:
|
||||
@@ -120,7 +103,7 @@ def _thread_key(update: Update) -> tuple[int, int] | None:
|
||||
thread_id = msg.message_thread_id
|
||||
if thread_id is None:
|
||||
return None
|
||||
return (chat.id, thread_id)
|
||||
return ThreadKey(platform="telegram", scope=str(chat.id), thread=str(thread_id))
|
||||
|
||||
|
||||
def _is_allowed(user_id: int, settings: Settings) -> bool:
|
||||
@@ -307,11 +290,11 @@ async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
"Hello, I'm *Steward* \U0001f916\n\n"
|
||||
"I'm your AI-assisted personal operations platform.\n"
|
||||
"Talk to me naturally, or use:\n"
|
||||
"/help – show available commands\n"
|
||||
"/clear – reset conversation history\n"
|
||||
"/flush – summarise and archive this thread's memory\n"
|
||||
"/recall [query] – retrieve archived thread summaries\n"
|
||||
"/analyse – run a manual API analysis right now",
|
||||
"/help \u2013 show available commands\n"
|
||||
"/clear \u2013 reset conversation history\n"
|
||||
"/flush \u2013 summarise and archive this thread's memory\n"
|
||||
"/recall [query] \u2013 retrieve archived thread summaries\n"
|
||||
"/analyse \u2013 run a manual API analysis right now",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
|
||||
@@ -329,24 +312,26 @@ async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
"*Steward commands*\n\n"
|
||||
"/start – greeting\n"
|
||||
"/help – this message\n"
|
||||
"/clear – reset conversation history for this context\n"
|
||||
"/flush – summarise the current thread, store the summary, and compress memory\n"
|
||||
"/start \u2013 greeting\n"
|
||||
"/help \u2013 this message\n"
|
||||
"/clear \u2013 reset conversation history for this context\n"
|
||||
"/flush \u2013 summarise the current thread, store the summary, and compress memory\n"
|
||||
" _(only available inside a message thread)_\n"
|
||||
"/recall [query] – show this thread's summary, list all summaries, or search by keyword\n"
|
||||
"/analyse – trigger an immediate API analysis and proposal",
|
||||
"/recall [query] \u2013 show this thread's summary, list all summaries, or search\n"
|
||||
" by keyword\n"
|
||||
"/analyse \u2013 trigger an immediate API analysis and proposal",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
|
||||
|
||||
async def clear_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /clear – wipe conversation history for this context.
|
||||
"""Handle /clear \u2013 wipe conversation history for this context.
|
||||
|
||||
Inside a message thread: clears the thread's unbounded history.
|
||||
Outside a thread: clears the per-user capped history.
|
||||
"""
|
||||
settings: Settings = context.bot_data["settings"]
|
||||
service: ConversationService = context.bot_data["service"]
|
||||
user = update.effective_user
|
||||
if user is None or not _is_allowed(user.id, settings):
|
||||
return
|
||||
@@ -357,30 +342,21 @@ async def clear_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
|
||||
key = _thread_key(update)
|
||||
if key is not None:
|
||||
_thread_history[key].clear()
|
||||
service.clear_history(key)
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
"Thread conversation history cleared."
|
||||
)
|
||||
else:
|
||||
_history[user.id].clear()
|
||||
service.clear_history(ThreadKey(platform="telegram", scope=str(user.id)))
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
"Conversation history cleared."
|
||||
)
|
||||
|
||||
|
||||
async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /flush – summarise thread memory, persist it, and compress in-memory history.
|
||||
|
||||
Steps:
|
||||
1. Verify the command is issued inside a message thread.
|
||||
2. Summarise the full thread history via the LLM.
|
||||
3. Persist the summary in the ThreadMemoryStore (keyed by chat_id + thread_id).
|
||||
4. Replace the in-memory thread history with a single compressed context message
|
||||
so conversation can continue with the summary as background.
|
||||
"""
|
||||
"""Handle /flush \u2013 summarise thread memory, persist it, and compress in-memory history."""
|
||||
settings: Settings = context.bot_data["settings"]
|
||||
llm: LLMClient = context.bot_data["llm"]
|
||||
store: ThreadMemoryStore = context.bot_data["thread_store"]
|
||||
service: ConversationService = context.bot_data["service"]
|
||||
user = update.effective_user
|
||||
|
||||
if user is None or not _is_allowed(user.id, settings):
|
||||
@@ -397,10 +373,7 @@ async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
)
|
||||
return
|
||||
|
||||
chat_id, thread_id = key
|
||||
history = _thread_history[key]
|
||||
|
||||
if not history:
|
||||
if not service.has_history(key):
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
"This thread has no conversation history to flush."
|
||||
)
|
||||
@@ -410,60 +383,25 @@ async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
"\U0001f4be Summarising thread memory\u2026"
|
||||
)
|
||||
|
||||
# Build a readable transcript for the LLM to summarise
|
||||
transcript_lines = []
|
||||
for msg in history:
|
||||
role_label = "User" if msg["role"] == "user" else "Steward"
|
||||
transcript_lines.append(f"{role_label}: {msg['content']}")
|
||||
transcript = "\n".join(transcript_lines)
|
||||
|
||||
summary_text = await llm.chat(
|
||||
f"Thread transcript:\n\n{transcript}",
|
||||
system_prompt=_FLUSH_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
# Extract keyword tags for knowledge-base indexing (second LLM call, lightweight)
|
||||
tags_raw = await llm.chat(
|
||||
f"Summary to tag:\n\n{summary_text}",
|
||||
system_prompt=_TAGS_SYSTEM_PROMPT,
|
||||
)
|
||||
tags = [t.strip().lower() for t in tags_raw.split(",") if t.strip()][:8]
|
||||
|
||||
message_count = sum(1 for m in history if m["role"] == "user")
|
||||
thread_summary = ThreadSummary(
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
summary=summary_text,
|
||||
message_count=message_count,
|
||||
tags=tags,
|
||||
)
|
||||
store.save(thread_summary)
|
||||
|
||||
# Compress: replace history with a single system-context entry so the thread
|
||||
# can continue with the summary as background knowledge.
|
||||
_thread_history[key] = [
|
||||
{"role": "system", "content": f"Summary of earlier conversation:\n{summary_text}"}
|
||||
]
|
||||
thread_summary = await service.flush_history(key)
|
||||
if thread_summary is None:
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
"This thread has no conversation history to flush."
|
||||
)
|
||||
return
|
||||
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
f"\u2705 Thread memory flushed and stored "
|
||||
f"(thread `{thread_id}`, {message_count} messages summarised).",
|
||||
f"(thread `{thread_summary.thread_id}`, "
|
||||
f"{thread_summary.message_count} messages summarised).",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
|
||||
|
||||
async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /recall [query] – retrieve stored thread summaries.
|
||||
|
||||
With a query argument (e.g. ``/recall api design``): searches all stored
|
||||
summaries whose tags overlap with the query keywords and returns matches.
|
||||
|
||||
Without a query:
|
||||
- Inside a thread: shows the stored summary for this thread (if any).
|
||||
- Outside a thread: lists all stored summaries (newest first).
|
||||
"""
|
||||
"""Handle /recall [query] \u2013 retrieve stored thread summaries."""
|
||||
settings: Settings = context.bot_data["settings"]
|
||||
store: ThreadMemoryStore = context.bot_data["thread_store"]
|
||||
service: ConversationService = context.bot_data["service"]
|
||||
user = update.effective_user
|
||||
|
||||
if user is None or not _is_allowed(user.id, settings):
|
||||
@@ -473,11 +411,10 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
if chat is None or not _is_chat_enabled(chat, settings):
|
||||
return
|
||||
|
||||
# If the user supplied a keyword query, search the knowledge base
|
||||
args: list[str] = context.args or [] # type: ignore[assignment]
|
||||
args: list[str] = context.args or []
|
||||
if args:
|
||||
query = " ".join(args).strip()
|
||||
results = store.search(query)
|
||||
results = service.search_summaries(query)
|
||||
if not results:
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
f"No memories found matching *{query}*. "
|
||||
@@ -497,8 +434,7 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
|
||||
key = _thread_key(update)
|
||||
if key is not None:
|
||||
chat_id, thread_id = key
|
||||
stored = store.get(chat_id, thread_id)
|
||||
stored = service.get_summary(key)
|
||||
if stored is None:
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
"No stored summary for this thread yet. Use /flush to create one."
|
||||
@@ -507,8 +443,7 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
await _send_long(update, stored.format_for_telegram())
|
||||
return
|
||||
|
||||
# Outside a thread: list all stored summaries
|
||||
all_summaries = store.all()
|
||||
all_summaries = service.all_summaries()
|
||||
if not all_summaries:
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
"No thread summaries stored yet. Use /flush inside a message thread."
|
||||
@@ -526,9 +461,8 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
|
||||
|
||||
async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /analyse – run the proposal generator on demand."""
|
||||
"""Handle /analyse \u2013 run the proposal generator on demand."""
|
||||
settings: Settings = context.bot_data["settings"]
|
||||
llm: LLMClient = context.bot_data["llm"]
|
||||
user = update.effective_user
|
||||
if user is None or not _is_allowed(user.id, settings):
|
||||
return
|
||||
@@ -538,7 +472,7 @@ async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
return
|
||||
|
||||
await update.message.reply_text("Running analysis, please wait...") # type: ignore[union-attr]
|
||||
generator = ProposalGenerator(settings, llm)
|
||||
generator = ProposalGenerator(settings, context.bot_data["llm"])
|
||||
proposal = await generator.run()
|
||||
if proposal is None:
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
@@ -549,42 +483,10 @@ async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
await _send_long(update, proposal.format_for_telegram())
|
||||
|
||||
|
||||
def _with_kb_context(
|
||||
history: list[dict[str, Any]],
|
||||
relevant: list[ThreadSummary],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Prepend relevant knowledge-base summaries as a transient system context message.
|
||||
|
||||
The returned list is a *new* list — the original *history* is not mutated.
|
||||
The injected message is never appended to the stored history, so it does not
|
||||
permanently consume the context window.
|
||||
"""
|
||||
if not relevant:
|
||||
return history
|
||||
snippets = [f"[Thread {s.thread_id}] {s.summary[:400]}" for s in relevant[:3]]
|
||||
kb_msg = _KB_CONTEXT_HEADER + "\n\n" + "\n\n---\n\n".join(snippets)
|
||||
return [{"role": "system", "content": kb_msg}, *history]
|
||||
|
||||
|
||||
async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle plain text messages – forward to LLM and reply.
|
||||
|
||||
Thread messages: history is stored unbounded under the (chat_id, thread_id) key.
|
||||
Non-thread messages: history is capped at _MAX_HISTORY turns per user.
|
||||
|
||||
Before each LLM call the knowledge base is searched for summaries whose tags
|
||||
overlap with keywords in the current message. Any matches are injected as
|
||||
transient context — they are NOT stored in the rolling history, so they do
|
||||
not permanently consume the context window.
|
||||
|
||||
If a :class:`~steward.tools.client.ToolClient` is registered in
|
||||
``context.bot_data``, the LLM is invoked with tool calling support so it
|
||||
can take actions on the configured MCP/OpenAPI tool server.
|
||||
"""
|
||||
"""Handle plain text messages \u2013 forward to the shared pipeline and reply."""
|
||||
settings: Settings = context.bot_data["settings"]
|
||||
llm: LLMClient = context.bot_data["llm"]
|
||||
store: ThreadMemoryStore = context.bot_data["thread_store"]
|
||||
tool_client: ToolClient | None = context.bot_data.get("tool_client")
|
||||
service: ConversationService = context.bot_data["service"]
|
||||
user = update.effective_user
|
||||
chat = update.effective_chat
|
||||
if user is None or not _is_allowed(user.id, settings):
|
||||
@@ -598,60 +500,35 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
|
||||
system_prompt = _telegram_system_prompt(settings)
|
||||
key = _thread_key(update)
|
||||
if key is not None:
|
||||
# Thread message: unbounded history
|
||||
history: list[dict[str, Any]] = _thread_history[key]
|
||||
call_history = _with_kb_context(history, store.search(text))
|
||||
if tool_client is not None:
|
||||
reply = await llm.chat_with_tools(
|
||||
text,
|
||||
tool_client,
|
||||
history=call_history,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
else:
|
||||
reply = await llm.chat(text, history=call_history, system_prompt=system_prompt)
|
||||
response_plan = _parse_telegram_response(reply)
|
||||
history.append({"role": "user", "content": text})
|
||||
history.append(
|
||||
{"role": "assistant", "content": _format_response_for_history(response_plan)}
|
||||
)
|
||||
if key is None:
|
||||
key = ThreadKey(platform="telegram", scope=str(user.id))
|
||||
history_cap = 40
|
||||
else:
|
||||
# Non-thread message: capped history per user
|
||||
user_history: list[dict[str, Any]] = _history[user.id]
|
||||
call_history = _with_kb_context(user_history, store.search(text))
|
||||
if tool_client is not None:
|
||||
reply = await llm.chat_with_tools(
|
||||
text,
|
||||
tool_client,
|
||||
history=call_history,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
else:
|
||||
reply = await llm.chat(text, history=call_history, system_prompt=system_prompt)
|
||||
response_plan = _parse_telegram_response(reply)
|
||||
user_history.append({"role": "user", "content": text})
|
||||
user_history.append(
|
||||
{"role": "assistant", "content": _format_response_for_history(response_plan)}
|
||||
)
|
||||
if len(user_history) > _MAX_HISTORY * 2:
|
||||
_history[user.id] = user_history[-(_MAX_HISTORY * 2) :]
|
||||
history_cap = None
|
||||
|
||||
reply = await service.process_message(
|
||||
key,
|
||||
str(user.id),
|
||||
text,
|
||||
system_prompt,
|
||||
history_cap=history_cap,
|
||||
history_formatter=lambda raw: _format_response_for_history(_parse_telegram_response(raw)),
|
||||
)
|
||||
response_plan = _parse_telegram_response(reply)
|
||||
await _send_telegram_response(update, response_plan)
|
||||
|
||||
|
||||
def build_application(
|
||||
settings: Settings,
|
||||
llm: LLMClient,
|
||||
thread_store: ThreadMemoryStore | None = None,
|
||||
tool_client: ToolClient | None = None,
|
||||
service: ConversationService,
|
||||
generator: ProposalGenerator | None = None,
|
||||
) -> Application: # type: ignore[type-arg]
|
||||
"""Build and return the Telegram Application."""
|
||||
app = Application.builder().token(settings.telegram_bot_token).build()
|
||||
app.bot_data["settings"] = settings
|
||||
app.bot_data["llm"] = llm
|
||||
app.bot_data["thread_store"] = thread_store or ThreadMemoryStore(settings.thread_memory_path)
|
||||
app.bot_data["tool_client"] = tool_client # None when tools are not configured
|
||||
app.bot_data["service"] = service
|
||||
app.bot_data["llm"] = service.llm
|
||||
app.bot_data["generator"] = generator
|
||||
logger.info("Configured allowed users: %s", settings.telegram_allowed_user_ids)
|
||||
logger.info("Configured group IDs: %s", settings.telegram_group_ids)
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Normalised conversation identity shared across platforms and storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThreadKey:
|
||||
"""Normalised identity of a conversation scope across platforms.
|
||||
|
||||
``platform`` is ``"telegram"`` or ``"matrix"``. ``scope`` is the chat/room/user
|
||||
identifier as a string. ``thread`` is an optional sub-thread identifier.
|
||||
"""
|
||||
|
||||
platform: str
|
||||
scope: str
|
||||
thread: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
parts = [self.platform, self.scope]
|
||||
if self.thread:
|
||||
parts.append(self.thread)
|
||||
return ":".join(parts)
|
||||
Reference in New Issue
Block a user