Add persona and language command localization
This commit is contained in:
@@ -8,7 +8,15 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.i18n import language_label, resolve_language
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.personas import (
|
||||
DEFAULT_PERSONA,
|
||||
list_personas,
|
||||
persona_workspace,
|
||||
personas_root,
|
||||
resolve_persona_name,
|
||||
)
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.utils.helpers import build_assistant_message, detect_image_mime
|
||||
|
||||
@@ -21,18 +29,36 @@ class ContextBuilder:
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
self.workspace = workspace
|
||||
self.memory = MemoryStore(workspace)
|
||||
self.skills = SkillsLoader(workspace)
|
||||
|
||||
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
||||
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
||||
parts = [self._get_identity()]
|
||||
def list_personas(self) -> list[str]:
|
||||
"""Return the personas available for this workspace."""
|
||||
return list_personas(self.workspace)
|
||||
|
||||
bootstrap = self._load_bootstrap_files()
|
||||
def find_persona(self, persona: str | None) -> str | None:
|
||||
"""Resolve a persona name without applying a default fallback."""
|
||||
return resolve_persona_name(self.workspace, persona)
|
||||
|
||||
def resolve_persona(self, persona: str | None) -> str:
|
||||
"""Return a canonical persona name, defaulting to the built-in persona."""
|
||||
return self.find_persona(persona) or DEFAULT_PERSONA
|
||||
|
||||
def build_system_prompt(
|
||||
self,
|
||||
skill_names: list[str] | None = None,
|
||||
persona: str | None = None,
|
||||
language: str | None = None,
|
||||
) -> str:
|
||||
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
||||
active_persona = self.resolve_persona(persona)
|
||||
active_language = resolve_language(language)
|
||||
parts = [self._get_identity(active_persona, active_language)]
|
||||
|
||||
bootstrap = self._load_bootstrap_files(active_persona)
|
||||
if bootstrap:
|
||||
parts.append(bootstrap)
|
||||
|
||||
memory = self.memory.get_memory_context()
|
||||
memory = self._memory_store(active_persona).get_memory_context()
|
||||
if memory:
|
||||
parts.append(f"# Memory\n\n{memory}")
|
||||
|
||||
@@ -53,9 +79,12 @@ Skills with available="false" need dependencies installed first - you can try in
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
def _get_identity(self) -> str:
|
||||
def _get_identity(self, persona: str, language: str) -> str:
|
||||
"""Get the core identity section."""
|
||||
workspace_path = str(self.workspace.expanduser().resolve())
|
||||
active_workspace = persona_workspace(self.workspace, persona)
|
||||
persona_path = str(active_workspace.expanduser().resolve())
|
||||
language_name = language_label(language, language)
|
||||
system = platform.system()
|
||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||
|
||||
@@ -81,10 +110,18 @@ You are nanobot, a helpful AI assistant.
|
||||
|
||||
## Workspace
|
||||
Your workspace is at: {workspace_path}
|
||||
- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here)
|
||||
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM].
|
||||
- Long-term memory: {persona_path}/memory/MEMORY.md (write important facts here)
|
||||
- History log: {persona_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM].
|
||||
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
||||
|
||||
## Persona
|
||||
Current persona: {persona}
|
||||
- Persona workspace: {persona_path}
|
||||
|
||||
## Language
|
||||
Preferred response language: {language_name}
|
||||
- Use this language for assistant replies and command/status text unless the user explicitly asks for another language.
|
||||
|
||||
{platform_policy}
|
||||
|
||||
## nanobot Guidelines
|
||||
@@ -106,12 +143,21 @@ Reply directly with text for conversations. Only use the 'message' tool to send
|
||||
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
||||
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines)
|
||||
|
||||
def _load_bootstrap_files(self) -> str:
|
||||
def _memory_store(self, persona: str) -> MemoryStore:
|
||||
"""Return the memory store for the active persona."""
|
||||
return MemoryStore(persona_workspace(self.workspace, persona))
|
||||
|
||||
def _load_bootstrap_files(self, persona: str) -> str:
|
||||
"""Load all bootstrap files from workspace."""
|
||||
parts = []
|
||||
persona_dir = None if persona == DEFAULT_PERSONA else personas_root(self.workspace) / persona
|
||||
|
||||
for filename in self.BOOTSTRAP_FILES:
|
||||
file_path = self.workspace / filename
|
||||
if persona_dir:
|
||||
persona_file = persona_dir / filename
|
||||
if persona_file.exists():
|
||||
file_path = persona_file
|
||||
if file_path.exists():
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
parts.append(f"## {filename}\n\n{content}")
|
||||
@@ -126,6 +172,8 @@ Reply directly with text for conversations. Only use the 'message' tool to send
|
||||
media: list[str] | None = None,
|
||||
channel: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
persona: str | None = None,
|
||||
language: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
runtime_ctx = self._build_runtime_context(channel, chat_id)
|
||||
@@ -139,7 +187,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send
|
||||
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
||||
|
||||
return [
|
||||
{"role": "system", "content": self.build_system_prompt(skill_names)},
|
||||
{"role": "system", "content": self.build_system_prompt(skill_names, persona=persona, language=language)},
|
||||
*history,
|
||||
{"role": "user", "content": merged},
|
||||
]
|
||||
|
||||
91
nanobot/agent/i18n.py
Normal file
91
nanobot/agent/i18n.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Minimal session-level localization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from importlib.resources import files as pkg_files
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
SUPPORTED_LANGUAGES = ("en", "zh")
|
||||
|
||||
_LANGUAGE_ALIASES = {
|
||||
"en": "en",
|
||||
"en-us": "en",
|
||||
"en-gb": "en",
|
||||
"english": "en",
|
||||
"zh": "zh",
|
||||
"zh-cn": "zh",
|
||||
"zh-hans": "zh",
|
||||
"zh-sg": "zh",
|
||||
"cn": "zh",
|
||||
"chinese": "zh",
|
||||
"中文": "zh",
|
||||
}
|
||||
|
||||
@lru_cache(maxsize=len(SUPPORTED_LANGUAGES))
|
||||
def _load_locale(language: str) -> dict[str, Any]:
|
||||
"""Load one locale file from packaged JSON resources."""
|
||||
lang = resolve_language(language)
|
||||
locale_file = pkg_files("nanobot") / "locales" / f"{lang}.json"
|
||||
with locale_file.open("r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def normalize_language_code(value: Any) -> str | None:
|
||||
"""Normalize a language identifier into a supported code."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
cleaned = value.strip().lower()
|
||||
if not cleaned:
|
||||
return None
|
||||
return _LANGUAGE_ALIASES.get(cleaned)
|
||||
|
||||
|
||||
def resolve_language(value: Any) -> str:
|
||||
"""Resolve the active language, defaulting to English."""
|
||||
return normalize_language_code(value) or DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def list_languages() -> list[str]:
|
||||
"""Return supported language codes in display order."""
|
||||
return list(SUPPORTED_LANGUAGES)
|
||||
|
||||
|
||||
def language_label(code: str, ui_language: str | None = None) -> str:
|
||||
"""Return a display label for a language code."""
|
||||
active_ui = resolve_language(ui_language)
|
||||
normalized = resolve_language(code)
|
||||
locale = _load_locale(active_ui)
|
||||
return f"{normalized} ({locale['language_labels'][normalized]})"
|
||||
|
||||
|
||||
def text(language: Any, key: str, **kwargs: Any) -> str:
|
||||
"""Return localized UI text."""
|
||||
active = resolve_language(language)
|
||||
template = _load_locale(active)["texts"][key]
|
||||
return template.format(**kwargs)
|
||||
|
||||
|
||||
def help_lines(language: Any) -> list[str]:
|
||||
"""Return localized slash-command help lines."""
|
||||
active = resolve_language(language)
|
||||
return [
|
||||
text(active, "help_header"),
|
||||
text(active, "cmd_new"),
|
||||
text(active, "cmd_lang_current"),
|
||||
text(active, "cmd_lang_list"),
|
||||
text(active, "cmd_lang_set"),
|
||||
text(active, "cmd_persona_current"),
|
||||
text(active, "cmd_persona_list"),
|
||||
text(active, "cmd_persona_set"),
|
||||
text(active, "cmd_stop"),
|
||||
text(active, "cmd_restart"),
|
||||
text(active, "cmd_help"),
|
||||
]
|
||||
|
||||
|
||||
def telegram_command_descriptions(language: Any) -> dict[str, str]:
|
||||
"""Return Telegram command descriptions for a locale."""
|
||||
return _load_locale(resolve_language(language))["telegram_commands"]
|
||||
@@ -14,6 +14,15 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.i18n import (
|
||||
DEFAULT_LANGUAGE,
|
||||
help_lines,
|
||||
language_label,
|
||||
list_languages,
|
||||
normalize_language_code,
|
||||
resolve_language,
|
||||
text,
|
||||
)
|
||||
from nanobot.agent.memory import MemoryConsolidator
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
@@ -119,6 +128,52 @@ class AgentLoop:
|
||||
)
|
||||
self._register_default_tools()
|
||||
|
||||
@staticmethod
|
||||
def _command_name(content: str) -> str:
|
||||
"""Return the normalized slash command name."""
|
||||
parts = content.strip().split(None, 1)
|
||||
return parts[0].lower() if parts else ""
|
||||
|
||||
def _get_session_persona(self, session: Session) -> str:
|
||||
"""Return the active persona name for a session."""
|
||||
return self.context.resolve_persona(session.metadata.get("persona"))
|
||||
|
||||
def _get_session_language(self, session: Session) -> str:
|
||||
"""Return the active language for a session."""
|
||||
metadata = getattr(session, "metadata", {})
|
||||
raw = metadata.get("language") if isinstance(metadata, dict) else DEFAULT_LANGUAGE
|
||||
return resolve_language(raw)
|
||||
|
||||
def _set_session_persona(self, session: Session, persona: str) -> None:
|
||||
"""Persist the selected persona for a session."""
|
||||
if persona == "default":
|
||||
session.metadata.pop("persona", None)
|
||||
else:
|
||||
session.metadata["persona"] = persona
|
||||
|
||||
def _set_session_language(self, session: Session, language: str) -> None:
|
||||
"""Persist the selected language for a session."""
|
||||
if language == DEFAULT_LANGUAGE:
|
||||
session.metadata.pop("language", None)
|
||||
else:
|
||||
session.metadata["language"] = language
|
||||
|
||||
def _persona_usage(self, language: str) -> str:
|
||||
"""Return persona command help text."""
|
||||
return "\n".join([
|
||||
text(language, "cmd_persona_current"),
|
||||
text(language, "cmd_persona_list"),
|
||||
text(language, "cmd_persona_set"),
|
||||
])
|
||||
|
||||
def _language_usage(self, language: str) -> str:
|
||||
"""Return language command help text."""
|
||||
return "\n".join([
|
||||
text(language, "cmd_lang_current"),
|
||||
text(language, "cmd_lang_list"),
|
||||
text(language, "cmd_lang_set"),
|
||||
])
|
||||
|
||||
def _register_default_tools(self) -> None:
|
||||
"""Register the default set of tools."""
|
||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||
@@ -275,7 +330,7 @@ class AgentLoop:
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
cmd = msg.content.strip().lower()
|
||||
cmd = self._command_name(msg.content)
|
||||
if cmd == "/stop":
|
||||
await self._handle_stop(msg)
|
||||
elif cmd == "/restart":
|
||||
@@ -296,15 +351,19 @@ class AgentLoop:
|
||||
pass
|
||||
sub_cancelled = await self.subagents.cancel_by_session(msg.session_key)
|
||||
total = cancelled + sub_cancelled
|
||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||
session = self.sessions.get_or_create(msg.session_key)
|
||||
language = self._get_session_language(session)
|
||||
content = text(language, "stopped_tasks", count=total) if total else text(language, "no_active_task")
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||
))
|
||||
|
||||
async def _handle_restart(self, msg: InboundMessage) -> None:
|
||||
"""Restart the process in-place via os.execv."""
|
||||
session = self.sessions.get_or_create(msg.session_key)
|
||||
language = self._get_session_language(session)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content="Restarting...",
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=text(language, "restarting"),
|
||||
))
|
||||
|
||||
async def _do_restart():
|
||||
@@ -334,9 +393,145 @@ class AgentLoop:
|
||||
logger.exception("Error processing message for session {}", msg.session_key)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="Sorry, I encountered an error.",
|
||||
content=text(self._get_session_language(self.sessions.get_or_create(msg.session_key)), "generic_error"),
|
||||
))
|
||||
|
||||
async def _handle_language_command(self, msg: InboundMessage, session: Session) -> OutboundMessage:
|
||||
"""Handle session-scoped language switching commands."""
|
||||
current = self._get_session_language(session)
|
||||
parts = msg.content.strip().split()
|
||||
if len(parts) == 1 or parts[1].lower() == "current":
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(current, "current_language", language_name=language_label(current, current)),
|
||||
)
|
||||
|
||||
subcommand = parts[1].lower()
|
||||
if subcommand == "list":
|
||||
items = "\n".join(
|
||||
f"- {language_label(code, current)}"
|
||||
+ (f" ({text(current, 'current_marker')})" if code == current else "")
|
||||
for code in list_languages()
|
||||
)
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(current, "available_languages", items=items),
|
||||
)
|
||||
|
||||
if subcommand != "set" or len(parts) < 3:
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=self._language_usage(current),
|
||||
)
|
||||
|
||||
target = normalize_language_code(parts[2])
|
||||
if target is None:
|
||||
languages = ", ".join(language_label(code, current) for code in list_languages())
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(current, "unknown_language", name=parts[2], languages=languages),
|
||||
)
|
||||
|
||||
if target == current:
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(current, "language_already_active", language_name=language_label(target, current)),
|
||||
)
|
||||
|
||||
self._set_session_language(session, target)
|
||||
self.sessions.save(session)
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(target, "switched_language", language_name=language_label(target, target)),
|
||||
)
|
||||
|
||||
async def _handle_persona_command(self, msg: InboundMessage, session: Session) -> OutboundMessage:
|
||||
"""Handle session-scoped persona management commands."""
|
||||
language = self._get_session_language(session)
|
||||
parts = msg.content.strip().split()
|
||||
if len(parts) == 1 or parts[1].lower() == "current":
|
||||
current = self._get_session_persona(session)
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(language, "current_persona", persona=current),
|
||||
)
|
||||
|
||||
subcommand = parts[1].lower()
|
||||
if subcommand == "list":
|
||||
current = self._get_session_persona(session)
|
||||
marker = text(language, "current_marker")
|
||||
personas = [
|
||||
f"{name} ({marker})" if name == current else name
|
||||
for name in self.context.list_personas()
|
||||
]
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(language, "available_personas", items="\n".join(f"- {name}" for name in personas)),
|
||||
)
|
||||
|
||||
if subcommand != "set" or len(parts) < 3:
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=self._persona_usage(language),
|
||||
)
|
||||
|
||||
target = self.context.find_persona(parts[2])
|
||||
if target is None:
|
||||
personas = ", ".join(self.context.list_personas())
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(
|
||||
language,
|
||||
"unknown_persona",
|
||||
name=parts[2],
|
||||
personas=personas,
|
||||
path=self.workspace / "personas" / parts[2],
|
||||
),
|
||||
)
|
||||
|
||||
current = self._get_session_persona(session)
|
||||
if target == current:
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(language, "persona_already_active", persona=target),
|
||||
)
|
||||
|
||||
try:
|
||||
if not await self.memory_consolidator.archive_unconsolidated(session):
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(language, "memory_archival_failed_persona"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("/persona archival failed for {}", session.key)
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(language, "memory_archival_failed_persona"),
|
||||
)
|
||||
|
||||
session.clear()
|
||||
self._set_session_persona(session, target)
|
||||
self.sessions.save(session)
|
||||
self.sessions.invalidate(session.key)
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=text(language, "switched_persona", persona=target),
|
||||
)
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
"""Close MCP connections."""
|
||||
if self._mcp_stack:
|
||||
@@ -365,12 +560,18 @@ class AgentLoop:
|
||||
logger.info("Processing system message from {}", msg.sender_id)
|
||||
key = f"{channel}:{chat_id}"
|
||||
session = self.sessions.get_or_create(key)
|
||||
persona = self._get_session_persona(session)
|
||||
language = self._get_session_language(session)
|
||||
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
|
||||
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
|
||||
history = session.get_history(max_messages=0)
|
||||
messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message=msg.content, channel=channel, chat_id=chat_id,
|
||||
current_message=msg.content,
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
persona=persona,
|
||||
language=language,
|
||||
)
|
||||
final_content, _, all_msgs = await self._run_agent_loop(messages)
|
||||
self._save_turn(session, all_msgs, 1 + len(history))
|
||||
@@ -384,40 +585,39 @@ class AgentLoop:
|
||||
|
||||
key = session_key or msg.session_key
|
||||
session = self.sessions.get_or_create(key)
|
||||
persona = self._get_session_persona(session)
|
||||
language = self._get_session_language(session)
|
||||
|
||||
# Slash commands
|
||||
cmd = msg.content.strip().lower()
|
||||
cmd = self._command_name(msg.content)
|
||||
if cmd == "/new":
|
||||
try:
|
||||
if not await self.memory_consolidator.archive_unconsolidated(session):
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content="Memory archival failed, session not cleared. Please try again.",
|
||||
content=text(language, "memory_archival_failed_session"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("/new archival failed for {}", session.key)
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content="Memory archival failed, session not cleared. Please try again.",
|
||||
content=text(language, "memory_archival_failed_session"),
|
||||
)
|
||||
|
||||
session.clear()
|
||||
self.sessions.save(session)
|
||||
self.sessions.invalidate(session.key)
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="New session started.")
|
||||
content=text(language, "new_session_started"))
|
||||
if cmd in {"/lang", "/language"}:
|
||||
return await self._handle_language_command(msg, session)
|
||||
if cmd == "/persona":
|
||||
return await self._handle_persona_command(msg, session)
|
||||
if cmd == "/help":
|
||||
lines = [
|
||||
"🐈 nanobot commands:",
|
||||
"/new — Start a new conversation",
|
||||
"/stop — Stop the current task",
|
||||
"/restart — Restart the bot",
|
||||
"/help — Show available commands",
|
||||
]
|
||||
return OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content="\n".join(lines),
|
||||
channel=msg.channel, chat_id=msg.chat_id, content="\n".join(help_lines(language)),
|
||||
)
|
||||
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
@@ -431,7 +631,10 @@ class AgentLoop:
|
||||
history=history,
|
||||
current_message=msg.content,
|
||||
media=msg.media if msg.media else None,
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
persona=persona,
|
||||
language=language,
|
||||
)
|
||||
|
||||
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
|
||||
|
||||
@@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.i18n import DEFAULT_LANGUAGE, resolve_language
|
||||
from nanobot.agent.personas import DEFAULT_PERSONA, persona_workspace, resolve_persona_name
|
||||
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -71,6 +73,7 @@ def _is_tool_choice_unsupported(content: str | None) -> bool:
|
||||
return any(m in text for m in _TOOL_CHOICE_ERROR_MARKERS)
|
||||
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
|
||||
|
||||
@@ -195,7 +198,7 @@ class MemoryConsolidator:
|
||||
build_messages: Callable[..., list[dict[str, Any]]],
|
||||
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
||||
):
|
||||
self.store = MemoryStore(workspace)
|
||||
self.workspace = workspace
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.sessions = sessions
|
||||
@@ -204,13 +207,27 @@ class MemoryConsolidator:
|
||||
self._get_tool_definitions = get_tool_definitions
|
||||
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
|
||||
|
||||
def _get_persona(self, session: Session) -> str:
|
||||
"""Resolve the active persona for a session."""
|
||||
return resolve_persona_name(self.workspace, session.metadata.get("persona")) or DEFAULT_PERSONA
|
||||
|
||||
def _get_language(self, session: Session) -> str:
|
||||
"""Resolve the active language for a session."""
|
||||
metadata = getattr(session, "metadata", {})
|
||||
raw = metadata.get("language") if isinstance(metadata, dict) else DEFAULT_LANGUAGE
|
||||
return resolve_language(raw)
|
||||
|
||||
def _get_store(self, session: Session) -> MemoryStore:
|
||||
"""Return the memory store associated with the active persona."""
|
||||
return MemoryStore(persona_workspace(self.workspace, self._get_persona(session)))
|
||||
|
||||
def get_lock(self, session_key: str) -> asyncio.Lock:
|
||||
"""Return the shared consolidation lock for one session."""
|
||||
return self._locks.setdefault(session_key, asyncio.Lock())
|
||||
|
||||
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
|
||||
async def consolidate_messages(self, session: Session, messages: list[dict[str, object]]) -> bool:
|
||||
"""Archive a selected message chunk into persistent memory."""
|
||||
return await self.store.consolidate(messages, self.provider, self.model)
|
||||
return await self._get_store(session).consolidate(messages, self.provider, self.model)
|
||||
|
||||
def pick_consolidation_boundary(
|
||||
self,
|
||||
@@ -243,6 +260,8 @@ class MemoryConsolidator:
|
||||
current_message="[token-probe]",
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
persona=self._get_persona(session),
|
||||
language=self._get_language(session),
|
||||
)
|
||||
return estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
@@ -258,7 +277,7 @@ class MemoryConsolidator:
|
||||
snapshot = session.messages[session.last_consolidated:]
|
||||
if not snapshot:
|
||||
return True
|
||||
return await self.consolidate_messages(snapshot)
|
||||
return await self.consolidate_messages(session, snapshot)
|
||||
|
||||
async def maybe_consolidate_by_tokens(self, session: Session) -> None:
|
||||
"""Loop: archive old messages until prompt fits within half the context window."""
|
||||
@@ -308,7 +327,7 @@ class MemoryConsolidator:
|
||||
source,
|
||||
len(chunk),
|
||||
)
|
||||
if not await self.consolidate_messages(chunk):
|
||||
if not await self.consolidate_messages(session, chunk):
|
||||
return
|
||||
session.last_consolidated = end_idx
|
||||
self.sessions.save(session)
|
||||
|
||||
66
nanobot/agent/personas.py
Normal file
66
nanobot/agent/personas.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Helpers for resolving session personas within a workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_PERSONA = "default"
|
||||
PERSONAS_DIRNAME = "personas"
|
||||
_VALID_PERSONA_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def normalize_persona_name(name: str | None) -> str | None:
|
||||
"""Normalize a user-supplied persona name."""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
|
||||
cleaned = name.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
if cleaned.lower() == DEFAULT_PERSONA:
|
||||
return DEFAULT_PERSONA
|
||||
if not _VALID_PERSONA_RE.fullmatch(cleaned):
|
||||
return None
|
||||
return cleaned
|
||||
|
||||
|
||||
def personas_root(workspace: Path) -> Path:
|
||||
"""Return the workspace-local persona root directory."""
|
||||
return workspace / PERSONAS_DIRNAME
|
||||
|
||||
|
||||
def list_personas(workspace: Path) -> list[str]:
|
||||
"""List available personas, always including the built-in default persona."""
|
||||
personas: dict[str, str] = {DEFAULT_PERSONA.lower(): DEFAULT_PERSONA}
|
||||
root = personas_root(workspace)
|
||||
if root.exists():
|
||||
for child in root.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
normalized = normalize_persona_name(child.name)
|
||||
if normalized is None:
|
||||
continue
|
||||
personas.setdefault(normalized.lower(), child.name)
|
||||
|
||||
return sorted(personas.values(), key=lambda value: (value.lower() != DEFAULT_PERSONA, value.lower()))
|
||||
|
||||
|
||||
def resolve_persona_name(workspace: Path, name: str | None) -> str | None:
|
||||
"""Resolve a persona name to the canonical workspace directory name."""
|
||||
normalized = normalize_persona_name(name)
|
||||
if normalized is None:
|
||||
return None
|
||||
if normalized == DEFAULT_PERSONA:
|
||||
return DEFAULT_PERSONA
|
||||
|
||||
available = {persona.lower(): persona for persona in list_personas(workspace)}
|
||||
return available.get(normalized.lower())
|
||||
|
||||
|
||||
def persona_workspace(workspace: Path, persona: str | None) -> Path:
|
||||
"""Return the effective workspace root for a persona."""
|
||||
resolved = resolve_persona_name(workspace, persona)
|
||||
if resolved in (None, DEFAULT_PERSONA):
|
||||
return workspace
|
||||
return personas_root(workspace) / resolved
|
||||
@@ -14,6 +14,7 @@ from telegram.request import HTTPXRequest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.i18n import help_lines, normalize_language_code, telegram_command_descriptions, text
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import TelegramConfig
|
||||
@@ -158,14 +159,7 @@ class TelegramChannel(BaseChannel):
|
||||
name = "telegram"
|
||||
display_name = "Telegram"
|
||||
|
||||
# Commands registered with Telegram's command menu
|
||||
BOT_COMMANDS = [
|
||||
BotCommand("start", "Start the bot"),
|
||||
BotCommand("new", "Start a new conversation"),
|
||||
BotCommand("stop", "Stop the current task"),
|
||||
BotCommand("help", "Show available commands"),
|
||||
BotCommand("restart", "Restart the bot"),
|
||||
]
|
||||
COMMAND_NAMES = ("start", "new", "lang", "persona", "stop", "help", "restart")
|
||||
|
||||
def __init__(self, config: TelegramConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
@@ -198,6 +192,17 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
return sid in allow_list or username in allow_list
|
||||
|
||||
@classmethod
|
||||
def _build_bot_commands(cls, language: str) -> list[BotCommand]:
|
||||
"""Build localized command menu entries."""
|
||||
labels = telegram_command_descriptions(language)
|
||||
return [BotCommand(name, labels[name]) for name in cls.COMMAND_NAMES]
|
||||
|
||||
@staticmethod
|
||||
def _preferred_language(user) -> str:
|
||||
"""Map Telegram's user language code to a supported locale."""
|
||||
return normalize_language_code(getattr(user, "language_code", None)) or "en"
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Telegram bot with long polling."""
|
||||
if not self.config.token:
|
||||
@@ -221,6 +226,8 @@ class TelegramChannel(BaseChannel):
|
||||
# Add command handlers
|
||||
self._app.add_handler(CommandHandler("start", self._on_start))
|
||||
self._app.add_handler(CommandHandler("new", self._forward_command))
|
||||
self._app.add_handler(CommandHandler("lang", self._forward_command))
|
||||
self._app.add_handler(CommandHandler("persona", self._forward_command))
|
||||
self._app.add_handler(CommandHandler("stop", self._forward_command))
|
||||
self._app.add_handler(CommandHandler("restart", self._forward_command))
|
||||
self._app.add_handler(CommandHandler("help", self._on_help))
|
||||
@@ -247,7 +254,8 @@ class TelegramChannel(BaseChannel):
|
||||
logger.info("Telegram bot @{} connected", bot_info.username)
|
||||
|
||||
try:
|
||||
await self._app.bot.set_my_commands(self.BOT_COMMANDS)
|
||||
await self._app.bot.set_my_commands(self._build_bot_commands("en"))
|
||||
await self._app.bot.set_my_commands(self._build_bot_commands("zh"), language_code="zh-hans")
|
||||
logger.debug("Telegram bot commands registered")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to register bot commands: {}", e)
|
||||
@@ -420,22 +428,15 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
|
||||
user = update.effective_user
|
||||
await update.message.reply_text(
|
||||
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
|
||||
"Send me a message and I'll respond!\n"
|
||||
"Type /help to see available commands."
|
||||
)
|
||||
language = self._preferred_language(user)
|
||||
await update.message.reply_text(text(language, "start_greeting", name=user.first_name))
|
||||
|
||||
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /help command, bypassing ACL so all users can access it."""
|
||||
if not update.message:
|
||||
if not update.message or not update.effective_user:
|
||||
return
|
||||
await update.message.reply_text(
|
||||
"🐈 nanobot commands:\n"
|
||||
"/new — Start a new conversation\n"
|
||||
"/stop — Stop the current task\n"
|
||||
"/help — Show available commands"
|
||||
)
|
||||
language = self._preferred_language(update.effective_user)
|
||||
await update.message.reply_text("\n".join(help_lines(language)))
|
||||
|
||||
@staticmethod
|
||||
def _sender_id(user) -> str:
|
||||
|
||||
47
nanobot/locales/en.json
Normal file
47
nanobot/locales/en.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"texts": {
|
||||
"current_marker": "current",
|
||||
"new_session_started": "New session started.",
|
||||
"memory_archival_failed_session": "Memory archival failed, session not cleared. Please try again.",
|
||||
"memory_archival_failed_persona": "Memory archival failed, persona not switched. Please try again.",
|
||||
"help_header": "🐈 nanobot commands:",
|
||||
"cmd_new": "/new — Start a new conversation",
|
||||
"cmd_lang_current": "/lang current — Show the active language",
|
||||
"cmd_lang_list": "/lang list — List available languages",
|
||||
"cmd_lang_set": "/lang set <en|zh> — Switch command language",
|
||||
"cmd_persona_current": "/persona current — Show the active persona",
|
||||
"cmd_persona_list": "/persona list — List available personas",
|
||||
"cmd_persona_set": "/persona set <name> — Switch persona and start a new session",
|
||||
"cmd_stop": "/stop — Stop the current task",
|
||||
"cmd_restart": "/restart — Restart the bot",
|
||||
"cmd_help": "/help — Show available commands",
|
||||
"current_persona": "Current persona: {persona}",
|
||||
"available_personas": "Available personas:\n{items}",
|
||||
"unknown_persona": "Unknown persona: {name}\nAvailable personas: {personas}\nCreate one under {path} and add SOUL.md or USER.md.",
|
||||
"persona_already_active": "Persona {persona} is already active.",
|
||||
"switched_persona": "Switched persona to {persona}. New session started.",
|
||||
"current_language": "Current language: {language_name}",
|
||||
"available_languages": "Available languages:\n{items}",
|
||||
"unknown_language": "Unknown language: {name}\nAvailable languages: {languages}",
|
||||
"language_already_active": "Language {language_name} is already active.",
|
||||
"switched_language": "Language switched to {language_name}.",
|
||||
"stopped_tasks": "Stopped {count} task(s).",
|
||||
"no_active_task": "No active task to stop.",
|
||||
"restarting": "Restarting...",
|
||||
"generic_error": "Sorry, I encountered an error.",
|
||||
"start_greeting": "Hi {name}. I'm nanobot.\n\nSend me a message and I'll respond.\nType /help to see available commands."
|
||||
},
|
||||
"language_labels": {
|
||||
"en": "English",
|
||||
"zh": "Chinese"
|
||||
},
|
||||
"telegram_commands": {
|
||||
"start": "Start the bot",
|
||||
"new": "Start a new conversation",
|
||||
"lang": "Switch language",
|
||||
"persona": "Show or switch personas",
|
||||
"stop": "Stop the current task",
|
||||
"help": "Show command help",
|
||||
"restart": "Restart the bot"
|
||||
}
|
||||
}
|
||||
47
nanobot/locales/zh.json
Normal file
47
nanobot/locales/zh.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"texts": {
|
||||
"current_marker": "当前",
|
||||
"new_session_started": "已开始新的会话。",
|
||||
"memory_archival_failed_session": "记忆归档失败,会话未清空,请稍后重试。",
|
||||
"memory_archival_failed_persona": "记忆归档失败,人格未切换,请稍后重试。",
|
||||
"help_header": "🐈 nanobot 命令:",
|
||||
"cmd_new": "/new — 开启新的对话",
|
||||
"cmd_lang_current": "/lang current — 查看当前语言",
|
||||
"cmd_lang_list": "/lang list — 查看可用语言",
|
||||
"cmd_lang_set": "/lang set <en|zh> — 切换命令语言",
|
||||
"cmd_persona_current": "/persona current — 查看当前人格",
|
||||
"cmd_persona_list": "/persona list — 查看可用人格",
|
||||
"cmd_persona_set": "/persona set <name> — 切换人格并开始新会话",
|
||||
"cmd_stop": "/stop — 停止当前任务",
|
||||
"cmd_restart": "/restart — 重启机器人",
|
||||
"cmd_help": "/help — 查看命令帮助",
|
||||
"current_persona": "当前人格:{persona}",
|
||||
"available_personas": "可用人格:\n{items}",
|
||||
"unknown_persona": "未知人格:{name}\n可用人格:{personas}\n请在 {path} 下创建人格目录,并添加 SOUL.md 或 USER.md。",
|
||||
"persona_already_active": "人格 {persona} 已经处于启用状态。",
|
||||
"switched_persona": "已切换到人格 {persona},并开始新的会话。",
|
||||
"current_language": "当前语言:{language_name}",
|
||||
"available_languages": "可用语言:\n{items}",
|
||||
"unknown_language": "未知语言:{name}\n可用语言:{languages}",
|
||||
"language_already_active": "语言 {language_name} 已经处于启用状态。",
|
||||
"switched_language": "已切换语言为 {language_name}。",
|
||||
"stopped_tasks": "已停止 {count} 个任务。",
|
||||
"no_active_task": "当前没有可停止的任务。",
|
||||
"restarting": "正在重启……",
|
||||
"generic_error": "抱歉,处理时遇到了错误。",
|
||||
"start_greeting": "你好,{name}!我是 nanobot。\n\n给我发消息我就会回复你。\n输入 /help 查看可用命令。"
|
||||
},
|
||||
"language_labels": {
|
||||
"en": "英语",
|
||||
"zh": "中文"
|
||||
},
|
||||
"telegram_commands": {
|
||||
"start": "启动机器人",
|
||||
"new": "开启新对话",
|
||||
"lang": "切换语言",
|
||||
"persona": "查看或切换人格",
|
||||
"stop": "停止当前任务",
|
||||
"help": "查看命令帮助",
|
||||
"restart": "重启机器人"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user