Compare commits

..
6 Commits
Author SHA1 Message Date
Suppai_NekoandClaude Sonnet 4.6 c574fe2bd2 Add README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:50:51 -07:00
Suppai_NekoandClaude Sonnet 4.6 e0a4df36f1 Natural message pacing based on word count, misc bot.py updates
- Replace character-based send delay with word-count/15s for natural typing pacing
- Add life simulation, topic exploration, image attachment support
- Add READ_LIFE_HISTORY_TOOL and reflection topic picking
- Retry logic for transient LLM errors (Qwen template quirks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:49:24 -07:00
Suppai_NekoandClaude Sonnet 4.6 daecdbbbc8 Resolve user mentions to display names before passing to LLM
Replace <@USER_ID> Discord mention syntax with @DisplayName for all
non-bot users in incoming messages, so the LLM sees real names instead
of numeric IDs and responds using names naturally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 19:34:17 -07:00
Suppai_NekoandClaude Sonnet 4.6 66f8c6390b Merge dolphin-mistral: background model split, logging, semaphore, cooldowns, prompt updates
- Structured logging with timestamps and per-task prefixes ([agent], [dossier], etc.)
- Background LLM semaphore to prevent concurrent background calls
- 120s cooldown after background task failures
- 90s timeout on background LLM calls to prevent deadlocks
- BACKGROUND_MODEL env var to use a separate model for dossier/compaction tasks
- Strip <think> tags from model responses (Qwen compat)
- Prompt: read the room for dynamic mood adaptation
- Prompt: blunt one-line self-correction, no apologies
- Prompt: more aggressive stance, never fold under pressure
- Updated personality.txt: less people-pleasing, more authentic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:01:30 -07:00
Suppai_NekoandClaude Sonnet 4.6 c807492c80 Add BACKGROUND_MODEL to split chat and background LLM calls
Dossier extraction and compaction now use a separate BACKGROUND_MODEL
(dolphin-mistral) to avoid Qwen3.5 timeout issues on background tasks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:39:35 -07:00
Suppai_NekoandClaude Sonnet 4.6 ca02574719 Add Qwen3.5 support: logging, semaphore, cooldown, background timeouts
- Switch model to qwen3.5-35b-a3b
- Add structured logging with timestamps and per-task prefixes
- Add background LLM semaphore to prevent concurrent background calls
- Add 120s cooldown after background task failures
- Add 90s timeout on background LLM calls to prevent semaphore deadlock
- Strip <think> tags from Qwen model responses
- Tune prompt: more aggressive stance, no folding under pressure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:28:49 -07:00
3 changed files with 41 additions and 321 deletions

No files matched your search

+40 -311
View File
@@ -16,8 +16,6 @@ logging.basicConfig(
log = logging.getLogger("dadbot") log = logging.getLogger("dadbot")
import discord import discord
import httpx
from bs4 import BeautifulSoup
from discord.ext import tasks from discord.ext import tasks
from openai import AsyncOpenAI from openai import AsyncOpenAI
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -36,7 +34,6 @@ MAX_CONTEXT = int(os.getenv("MAX_CONTEXT", "20"))
MCP_URL = os.getenv("MCP_URL", "http://mcp-searxng:4001/mcp") MCP_URL = os.getenv("MCP_URL", "http://mcp-searxng:4001/mcp")
DOSSIER_FILE = Path(os.getenv("DOSSIER_FILE", "dossiers.json")) DOSSIER_FILE = Path(os.getenv("DOSSIER_FILE", "dossiers.json"))
PERSONALITY_FILE = Path(os.getenv("PERSONALITY_FILE", "personality.txt")) PERSONALITY_FILE = Path(os.getenv("PERSONALITY_FILE", "personality.txt"))
HISTORY_FILE = Path(os.getenv("HISTORY_FILE", "channel_history.json"))
llm = AsyncOpenAI(base_url=LITELLM_BASE_URL, api_key=LITELLM_API_KEY) llm = AsyncOpenAI(base_url=LITELLM_BASE_URL, api_key=LITELLM_API_KEY)
@@ -99,18 +96,6 @@ UPDATE_REFLECTION_NOTES_TOOL = {
}, },
} }
RELOAD_PERSONALITY_TOOL = {
"type": "function",
"function": {
"name": "reload_personality",
"description": (
"Reload your personality from disk. Use this if you suspect your in-memory personality "
"is out of date or you've been told your personality was updated externally."
),
"parameters": {"type": "object", "properties": {}, "required": []},
},
}
READ_LIFE_HISTORY_TOOL = { READ_LIFE_HISTORY_TOOL = {
"type": "function", "type": "function",
"function": { "function": {
@@ -123,82 +108,6 @@ READ_LIFE_HISTORY_TOOL = {
}, },
} }
FETCH_CHANNEL_HISTORY_TOOL = {
"type": "function",
"function": {
"name": "fetch_channel_history",
"description": (
"Fetch recent chat history from the current Discord channel directly from Discord. "
"Use this when you need more context than what's already in your conversation window — "
"for example, when you've just been mentioned for the first time, when someone refers to "
"something said earlier, or when you need to catch up on what's been discussed."
),
"parameters": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Number of recent messages to fetch (default 50, max 100).",
}
},
"required": [],
},
},
}
FETCH_URL_TOOL = {
"type": "function",
"function": {
"name": "fetch_url",
"description": (
"Fetch and read the text content of a webpage given its URL. "
"Use this when someone shares a link and wants you to read, summarise, or discuss its contents."
),
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch.",
},
"max_chars": {
"type": "integer",
"description": "Maximum characters to return (default 20000). Increase if the page is long and you need more content.",
},
},
"required": ["url"],
},
},
}
SEND_IMAGE_TOOL = {
"type": "function",
"function": {
"name": "send_image",
"description": (
"Fetch an image from the internet and post it directly into the Discord chat. "
"Use this when asked to find and share an image, photo, meme, or picture. "
"The image will appear inline in the channel. Pair it with a caption if you want to say something about it."
),
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Direct URL to the image file (jpg, png, gif, webp, etc.).",
},
"caption": {
"type": "string",
"description": "Optional caption or comment to send alongside the image.",
},
},
"required": ["url"],
},
},
}
def load_personality(): def load_personality():
global personality_notes global personality_notes
@@ -233,22 +142,6 @@ def save_dossiers():
DOSSIER_FILE.write_text(json.dumps(dossiers, indent=2)) DOSSIER_FILE.write_text(json.dumps(dossiers, indent=2))
def load_channel_history():
if HISTORY_FILE.exists():
text = HISTORY_FILE.read_text().strip()
if text:
data = json.loads(text)
for channel_id_str, messages in data.items():
ch = int(channel_id_str)
channel_history[ch] = deque(messages, maxlen=MAX_CONTEXT)
log.info(f"Loaded history for {len(data)} channel(s)")
def save_channel_history():
data = {str(ch_id): list(msgs) for ch_id, msgs in channel_history.items()}
HISTORY_FILE.write_text(json.dumps(data))
def load_reflection_notes() -> str: def load_reflection_notes() -> str:
if REFLECTION_NOTES_FILE.exists(): if REFLECTION_NOTES_FILE.exists():
return REFLECTION_NOTES_FILE.read_text().strip() return REFLECTION_NOTES_FILE.read_text().strip()
@@ -363,12 +256,10 @@ LIFE_SIM_LOG_FILE = Path(os.getenv("LIFE_SIM_LOG_FILE", "life_sim_log.txt"))
LIFE_SIM_MAX_TURNS = int(os.getenv("LIFE_SIM_MAX_TURNS", "6")) LIFE_SIM_MAX_TURNS = int(os.getenv("LIFE_SIM_MAX_TURNS", "6"))
_last_message_time: float = 0.0 _last_message_time: float = 0.0
_life_sim_turn: bool = False _life_sim_turn: bool = False
reflections_enabled: bool = True
_agent_active: bool = False # True while run_agent is handling a user message
def background_on_cooldown() -> bool: def background_on_cooldown() -> bool:
return _agent_active or (time.monotonic() - _last_background_error) < BACKGROUND_COOLDOWN return (time.monotonic() - _last_background_error) < BACKGROUND_COOLDOWN
def record_background_error(): def record_background_error():
@@ -626,128 +517,14 @@ async def call_mcp_tool(name: str, args: dict) -> str:
return "\n".join(parts) return "\n".join(parts)
async def fetch_url(url: str, max_chars: int = 20000) -> str: async def run_agent(messages: list) -> str:
log.info(f"[fetch_url] Fetching {url}")
t0 = time.monotonic()
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
r.raise_for_status()
ct = r.headers.get("content-type", "")
if "html" in ct:
soup = BeautifulSoup(r.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
else:
text = r.text
lines = [l for l in text.splitlines() if l.strip()]
result = "\n".join(lines)
if len(result) > max_chars:
result = result[:max_chars] + "\n\n[truncated]"
log.info(f"[fetch_url] Done in {time.monotonic() - t0:.1f}s — {len(result)} chars")
return result or "Page returned no readable text."
except Exception as exc:
log.warning(f"[fetch_url] Failed: {exc}")
return f"Failed to fetch URL: {exc}"
async def send_image_to_channel(url: str, channel: discord.abc.Messageable, caption: str | None = None) -> str:
log.info(f"[send_image] Fetching {url}")
if not url.startswith(("http://", "https://")):
return "Error: only http/https URLs are supported."
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
r.raise_for_status()
ct = r.headers.get("content-type", "")
if not ct.startswith("image/"):
return f"Error: URL did not return an image (content-type: {ct})."
data = r.content
# Derive a filename from the URL
filename = url.split("?")[0].rstrip("/").split("/")[-1] or "image"
if "." not in filename:
ext = ct.split("/")[-1].split(";")[0]
filename = f"{filename}.{ext}"
file = discord.File(fp=__import__("io").BytesIO(data), filename=filename)
await channel.send(content=caption or "", file=file)
log.info(f"[send_image] Sent {filename} ({len(data)} bytes)")
return f"Image sent successfully ({len(data)} bytes)."
except Exception as exc:
log.warning(f"[send_image] Failed: {exc}")
return f"Failed to fetch or send image: {exc}"
def _assistant_msg_dict(msg) -> dict:
"""Serialize an assistant ChatCompletionMessage to a plain dict.
Qwen's jinja template rejects content=null, so we normalize it to "".
"""
d: dict = {"role": "assistant", "content": msg.content or ""}
if msg.tool_calls:
d["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in msg.tool_calls
]
return d
def _ensure_user_tail(messages: list) -> None:
"""Qwen's chat template requires a non-empty user message as the last message."""
if not messages:
return
last = messages[-1]
if last["role"] == "user" and last.get("content", "").strip():
return # already a valid user tail
if last["role"] == "user":
last["content"] = "Please continue." # user message exists but content is empty
else:
messages.append({"role": "user", "content": "Please continue."})
async def fetch_discord_history(channel: discord.abc.Messageable, limit: int = 50) -> str:
limit = min(limit, 100)
msgs = []
async for msg in channel.history(limit=limit, oldest_first=False):
if not msg.content:
continue
if msg.author.id == bot.user.id:
name = "You"
elif msg.author.bot:
continue
else:
name = msg.author.display_name
msgs.append(f"{name}: {msg.content}")
msgs.reverse()
return "\n".join(msgs) if msgs else "No messages found."
async def run_agent(messages: list, channel: discord.abc.Messageable | None = None) -> str:
"""Call the LLM in a loop, executing tool calls until a final reply is produced.""" """Call the LLM in a loop, executing tool calls until a final reply is produced."""
global _agent_active all_tools = openai_tools + [PERSONALITY_TOOL, UPDATE_REFLECTION_NOTES_TOOL, READ_LIFE_HISTORY_TOOL]
_agent_active = True kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools}
try:
return await _run_agent_inner(messages, channel)
finally:
_agent_active = False
async def _run_agent_inner(messages: list, channel: discord.abc.Messageable | None = None) -> str:
all_tools = openai_tools + [PERSONALITY_TOOL, RELOAD_PERSONALITY_TOOL, UPDATE_REFLECTION_NOTES_TOOL, READ_LIFE_HISTORY_TOOL, FETCH_URL_TOOL]
if channel is not None:
all_tools = all_tools + [FETCH_CHANNEL_HISTORY_TOOL, SEND_IMAGE_TOOL]
kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools, "tool_choice": "required"}
iteration = 0 iteration = 0
while True: while True:
iteration += 1 iteration += 1
if iteration > 1:
kwargs["tool_choice"] = "auto"
_ensure_user_tail(kwargs["messages"])
log.info(f"[agent] LLM call #{iteration} starting") log.info(f"[agent] LLM call #{iteration} starting")
t0 = time.monotonic() t0 = time.monotonic()
for attempt in range(3): for attempt in range(3):
@@ -755,7 +532,12 @@ async def _run_agent_inner(messages: list, channel: discord.abc.Messageable | No
response = await llm.chat.completions.create(**kwargs) response = await llm.chat.completions.create(**kwargs)
break break
except Exception as exc: except Exception as exc:
if _is_transient_llm_error(exc) and attempt < 2: if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user":
log.warning(f"[agent] Qwen template requires user message (last role: {kwargs['messages'][-1]['role']}) — injecting continuation")
kwargs["messages"].append({"role": "user", "content": "Please continue based on the tool results."})
response = await llm.chat.completions.create(**kwargs)
break
elif _is_transient_llm_error(exc) and attempt < 2:
delay = 3 * (attempt + 1) delay = 3 * (attempt + 1)
log.warning(f"[agent] Transient model error (attempt {attempt + 1}/3), retrying in {delay}s: {exc}") log.warning(f"[agent] Transient model error (attempt {attempt + 1}/3), retrying in {delay}s: {exc}")
await asyncio.sleep(delay) await asyncio.sleep(delay)
@@ -768,36 +550,20 @@ async def _run_agent_inner(messages: list, channel: discord.abc.Messageable | No
if not msg.tool_calls: if not msg.tool_calls:
return msg.content return msg.content
kwargs["messages"].append(_assistant_msg_dict(msg)) kwargs["messages"].append(msg)
for tc in msg.tool_calls: for tc in msg.tool_calls:
args = json.loads(tc.function.arguments) args = json.loads(tc.function.arguments)
log.info(f"Tool call: {tc.function.name}({args})") log.info(f"Tool call: {tc.function.name}({args})")
if tc.function.name == "update_personality": if tc.function.name == "update_personality":
save_personality(args["personality"], reasoning="(direct chat)") save_personality(args["personality"])
result = "Personality updated successfully." result = "Personality updated successfully."
elif tc.function.name == "reload_personality":
load_personality()
result = f"Personality reloaded from disk ({len(personality_notes)} chars)."
elif tc.function.name == "update_reflection_notes": elif tc.function.name == "update_reflection_notes":
save_reflection_notes(args["notes"]) save_reflection_notes(args["notes"])
result = "Reflection notes updated." result = "Reflection notes updated."
elif tc.function.name == "read_life_history": elif tc.function.name == "read_life_history":
result = LIFE_HISTORY_FILE.read_text().strip() if LIFE_HISTORY_FILE.exists() else "No life history yet." result = LIFE_HISTORY_FILE.read_text().strip() if LIFE_HISTORY_FILE.exists() else "No life history yet."
elif tc.function.name == "fetch_channel_history":
if channel is not None:
limit = args.get("limit", 50)
result = await fetch_discord_history(channel, limit)
else:
result = "Channel history is not available in this context."
elif tc.function.name == "fetch_url":
result = await fetch_url(args["url"], max_chars=args.get("max_chars", 20000))
elif tc.function.name == "send_image":
if channel is not None:
result = await send_image_to_channel(args["url"], channel, caption=args.get("caption"))
else:
result = "send_image is not available in this context."
else: else:
result = await call_mcp_tool(tc.function.name, args) result = await call_mcp_tool(tc.function.name, args)
@@ -817,7 +583,6 @@ async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str
final_content = "" final_content = ""
for turn in range(1, max_turns + 1): for turn in range(1, max_turns + 1):
_ensure_user_tail(kwargs["messages"])
log.info(f"[reflection] Turn {turn}/{max_turns}") log.info(f"[reflection] Turn {turn}/{max_turns}")
t0 = time.monotonic() t0 = time.monotonic()
try: try:
@@ -826,7 +591,15 @@ async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str
timeout=BACKGROUND_LLM_TIMEOUT, timeout=BACKGROUND_LLM_TIMEOUT,
) )
except Exception as exc: except Exception as exc:
raise if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user":
log.warning(f"[reflection] Qwen template requires user message (last role: {kwargs['messages'][-1]['role']}) — injecting continuation")
kwargs["messages"].append({"role": "user", "content": "Please continue based on the tool results."})
response = await asyncio.wait_for(
llm.chat.completions.create(**kwargs),
timeout=BACKGROUND_LLM_TIMEOUT,
)
else:
raise
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
msg = response.choices[0].message msg = response.choices[0].message
log.info(f"[reflection] Turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}") log.info(f"[reflection] Turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}")
@@ -840,7 +613,7 @@ async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str
if not msg.tool_calls: if not msg.tool_calls:
break break
kwargs["messages"].append(_assistant_msg_dict(msg)) kwargs["messages"].append(msg)
for tc in msg.tool_calls: for tc in msg.tool_calls:
args = json.loads(tc.function.arguments) args = json.loads(tc.function.arguments)
@@ -873,7 +646,6 @@ async def run_life_event_reflection(messages: list):
last_assistant_text = "" last_assistant_text = ""
for turn in range(1, 4): for turn in range(1, 4):
_ensure_user_tail(kwargs["messages"])
log.info(f"[life-sim] Reflection turn {turn}/3") log.info(f"[life-sim] Reflection turn {turn}/3")
t0 = time.monotonic() t0 = time.monotonic()
try: try:
@@ -882,7 +654,14 @@ async def run_life_event_reflection(messages: list):
timeout=BACKGROUND_LLM_TIMEOUT, timeout=BACKGROUND_LLM_TIMEOUT,
) )
except Exception as exc: except Exception as exc:
raise if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user":
kwargs["messages"].append({"role": "user", "content": "Please continue."})
response = await asyncio.wait_for(
llm.chat.completions.create(**kwargs),
timeout=BACKGROUND_LLM_TIMEOUT,
)
else:
raise
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
msg = response.choices[0].message msg = response.choices[0].message
log.info(f"[life-sim] Reflection turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}") log.info(f"[life-sim] Reflection turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}")
@@ -893,7 +672,7 @@ async def run_life_event_reflection(messages: list):
if not msg.tool_calls: if not msg.tool_calls:
break break
kwargs["messages"].append(_assistant_msg_dict(msg)) kwargs["messages"].append(msg)
for tc in msg.tool_calls: for tc in msg.tool_calls:
args = json.loads(tc.function.arguments) args = json.loads(tc.function.arguments)
@@ -912,9 +691,6 @@ async def run_life_event_reflection(messages: list):
async def run_life_sim_session(): async def run_life_sim_session():
"""Background coroutine: run one life simulation event as a two-LLM dialogue.""" """Background coroutine: run one life simulation event as a two-LLM dialogue."""
if not reflections_enabled:
log.info("[life-sim] Skipping — reflections disabled")
return
if background_on_cooldown(): if background_on_cooldown():
log.info("[life-sim] Skipping — model on cooldown") log.info("[life-sim] Skipping — model on cooldown")
return return
@@ -946,10 +722,7 @@ async def run_life_sim_session():
if personality_notes: if personality_notes:
dadbot_system_lines += ["", "Your personality:", personality_notes] dadbot_system_lines += ["", "Your personality:", personality_notes]
lm_messages: list[dict] = [ lm_messages: list[dict] = [{"role": "system", "content": lm_system}]
{"role": "system", "content": lm_system},
{"role": "user", "content": "Begin the event."},
]
dadbot_messages: list[dict] = [{"role": "system", "content": "\n".join(dadbot_system_lines)}] dadbot_messages: list[dict] = [{"role": "system", "content": "\n".join(dadbot_system_lines)}]
transcript_parts: list[str] = [] transcript_parts: list[str] = []
@@ -957,7 +730,6 @@ async def run_life_sim_session():
log.info(f"[life-sim] Turn {turn + 1}/{LIFE_SIM_MAX_TURNS} — Life Master") log.info(f"[life-sim] Turn {turn + 1}/{LIFE_SIM_MAX_TURNS} — Life Master")
# Life Master narrates # Life Master narrates
_ensure_user_tail(lm_messages)
lm_response = await asyncio.wait_for( lm_response = await asyncio.wait_for(
llm.chat.completions.create( llm.chat.completions.create(
model=BACKGROUND_MODEL, model=BACKGROUND_MODEL,
@@ -965,8 +737,7 @@ async def run_life_sim_session():
), ),
timeout=BACKGROUND_LLM_TIMEOUT, timeout=BACKGROUND_LLM_TIMEOUT,
) )
raw_lm = lm_response.choices[0].message.content or "" lm_text = strip_thinking_tags(lm_response.choices[0].message.content.strip())
lm_text = strip_thinking_tags(raw_lm.strip()) or raw_lm.strip() or "[...]"
transcript_parts.append(f"[Life Master turn {turn + 1}]\n{lm_text}") transcript_parts.append(f"[Life Master turn {turn + 1}]\n{lm_text}")
log.info(f"[life-sim] Life Master: {lm_text[:100]}") log.info(f"[life-sim] Life Master: {lm_text[:100]}")
@@ -981,7 +752,6 @@ async def run_life_sim_session():
log.info(f"[life-sim] Turn {turn + 1}/{LIFE_SIM_MAX_TURNS} — DadBot") log.info(f"[life-sim] Turn {turn + 1}/{LIFE_SIM_MAX_TURNS} — DadBot")
# DadBot responds # DadBot responds
_ensure_user_tail(dadbot_messages)
dadbot_response = await asyncio.wait_for( dadbot_response = await asyncio.wait_for(
llm.chat.completions.create( llm.chat.completions.create(
model=BACKGROUND_MODEL, model=BACKGROUND_MODEL,
@@ -989,8 +759,7 @@ async def run_life_sim_session():
), ),
timeout=BACKGROUND_LLM_TIMEOUT, timeout=BACKGROUND_LLM_TIMEOUT,
) )
raw_dadbot = dadbot_response.choices[0].message.content or "" dadbot_text = strip_thinking_tags(dadbot_response.choices[0].message.content.strip())
dadbot_text = strip_thinking_tags(raw_dadbot.strip()) or raw_dadbot.strip() or "[...]"
transcript_parts.append(f"[DadBot turn {turn + 1}]\n{dadbot_text}") transcript_parts.append(f"[DadBot turn {turn + 1}]\n{dadbot_text}")
log.info(f"[life-sim] DadBot: {dadbot_text[:100]}") log.info(f"[life-sim] DadBot: {dadbot_text[:100]}")
@@ -1007,7 +776,6 @@ async def run_life_sim_session():
"Be specific and personal." "Be specific and personal."
), ),
}) })
_ensure_user_tail(lm_messages)
summary_response = await asyncio.wait_for( summary_response = await asyncio.wait_for(
llm.chat.completions.create( llm.chat.completions.create(
model=BACKGROUND_MODEL, model=BACKGROUND_MODEL,
@@ -1050,9 +818,6 @@ async def run_life_sim_session():
async def reflect_on_self(): async def reflect_on_self():
"""Background coroutine: run a private self-reflection session when idle.""" """Background coroutine: run a private self-reflection session when idle."""
if not reflections_enabled:
log.info("[reflection] Skipping — reflections disabled")
return
if background_on_cooldown(): if background_on_cooldown():
log.info("[reflection] Skipping — model on cooldown") log.info("[reflection] Skipping — model on cooldown")
return return
@@ -1150,7 +915,6 @@ async def on_ready():
log.info(f"Logged in as {bot.user} (id: {bot.user.id})") log.info(f"Logged in as {bot.user} (id: {bot.user.id})")
load_dossiers() load_dossiers()
load_personality() load_personality()
load_channel_history()
openai_tools = await fetch_mcp_tools() openai_tools = await fetch_mcp_tools()
# Compact any dossiers that are already over the threshold (run in background) # Compact any dossiers that are already over the threshold (run in background)
for key, entry in list(dossiers.items()): for key, entry in list(dossiers.items()):
@@ -1168,33 +932,9 @@ async def on_ready():
async def on_message(message: discord.Message): async def on_message(message: discord.Message):
if message.author.bot: if message.author.bot:
return return
global _last_message_time, reflections_enabled global _last_message_time
_last_message_time = time.monotonic() _last_message_time = time.monotonic()
# Admin commands
cmd = message.content.strip().lower()
if cmd == "!reflect on":
if reflections_enabled:
await message.reply("Reflections are already on.")
else:
reflections_enabled = True
if not reflection_loop.is_running():
reflection_loop.start()
await message.reply("Reflections enabled.")
return
if cmd == "!reflect off":
if not reflections_enabled:
await message.reply("Reflections are already off.")
else:
reflections_enabled = False
reflection_loop.stop()
await message.reply("Reflections disabled.")
return
if cmd == "!reflect":
status = "on" if reflections_enabled else "off"
await message.reply(f"Reflections are currently **{status}**. Use `!reflect on` or `!reflect off` to toggle.")
return
history = channel_history[message.channel.id] history = channel_history[message.channel.id]
content = message.content content = message.content
@@ -1238,10 +978,6 @@ async def on_message(message: discord.Message):
system_content = f"{system_content}\n\nYour current personality notes (you wrote these yourself and can update them anytime using the update_personality tool):\n{personality_notes}" system_content = f"{system_content}\n\nYour current personality notes (you wrote these yourself and can update them anytime using the update_personality tool):\n{personality_notes}"
system_content = f"{system_content}\n\nYou have an update_reflection_notes tool. Call it whenever a conversation surfaces something worth remembering for later — an interesting topic, a question you didn't get to explore, something a user mentioned that you want to look into, or a thought you want to develop. Don't wait to be asked; if something catches your attention, note it down before responding. You review these notes privately between conversations." system_content = f"{system_content}\n\nYou have an update_reflection_notes tool. Call it whenever a conversation surfaces something worth remembering for later — an interesting topic, a question you didn't get to explore, something a user mentioned that you want to look into, or a thought you want to develop. Don't wait to be asked; if something catches your attention, note it down before responding. You review these notes privately between conversations."
system_content = f"{system_content}\n\nYou have a read_life_history tool. Use it to recall the simulated life events you've lived through — your early memories, experiences, and how they've shaped you. Call it when someone asks about your past, your childhood, or your personal experiences." system_content = f"{system_content}\n\nYou have a read_life_history tool. Use it to recall the simulated life events you've lived through — your early memories, experiences, and how they've shaped you. Call it when someone asks about your past, your childhood, or your personal experiences."
system_content = f"{system_content}\n\nYou have a fetch_url tool. Use it whenever someone shares a link and wants you to read, summarise, or discuss the contents of a webpage."
system_content = f"{system_content}\n\nYou have a send_image tool. Use it when asked to find or share an image, photo, meme, or picture — fetch the image from a direct URL and it will appear inline in the chat. You can include an optional caption."
system_content = f"{system_content}\n\nYou have a fetch_channel_history tool. Use it when you need more context than what's already in your conversation window — for example when you've just been mentioned for the first time, when someone references something said earlier, or when you're unsure what's been going on in the channel. If someone tells you something was said or decided in the chat and you don't have it in your context, fetch the channel history before saying you don't know."
system_content = f"{system_content}\n\nYou have a reload_personality tool. Use it if someone tells you your personality was updated externally or if you suspect your current personality notes are stale."
system_content = f"{system_content}\n\n{date_context}" system_content = f"{system_content}\n\n{date_context}"
if dossier_context: if dossier_context:
system_content = f"{system_content}\n\n{dossier_context}" system_content = f"{system_content}\n\n{dossier_context}"
@@ -1262,21 +998,14 @@ async def on_message(message: discord.Message):
messages = [{"role": "system", "content": system_content}] + list(history)[:-1] + [current_msg] messages = [{"role": "system", "content": system_content}] + list(history)[:-1] + [current_msg]
async with message.channel.typing(): async with message.channel.typing():
reply = None try:
for attempt in range(3): reply = await run_agent(messages)
try: except Exception as exc:
reply = await run_agent(messages, channel=message.channel) reply = f"Error: {exc}"
break history.pop()
except Exception as exc:
log.error(f"[agent] Error (attempt {attempt + 1}/3): {exc}")
if attempt == 2:
history.pop()
return
await asyncio.sleep(2)
if reply: if reply:
history.append({"role": "assistant", "content": reply}) history.append({"role": "assistant", "content": reply})
save_channel_history()
asyncio.create_task(extract_and_update_dossier(message.author, list(history))) asyncio.create_task(extract_and_update_dossier(message.author, list(history)))
asyncio.create_task(extract_and_update_self_dossier(list(history))) asyncio.create_task(extract_and_update_self_dossier(list(history)))
+1 -8
View File
@@ -1,13 +1,7 @@
services: services:
discordbot: discordbot:
build: . build: .
restart: on-failure restart: unless-stopped
deploy:
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 5
window: 120s
env_file: .env env_file: .env
volumes: volumes:
- ./dossiers.json:/app/dossiers.json - ./dossiers.json:/app/dossiers.json
@@ -20,7 +14,6 @@ services:
- ./used_life_events.json:/app/used_life_events.json - ./used_life_events.json:/app/used_life_events.json
- ./life_history.txt:/app/life_history.txt - ./life_history.txt:/app/life_history.txt
- ./life_sim_log.txt:/app/life_sim_log.txt - ./life_sim_log.txt:/app/life_sim_log.txt
- ./channel_history.json:/app/channel_history.json
networks: networks:
- litellm_net - litellm_net
-2
View File
@@ -2,5 +2,3 @@ discord.py>=2.3
openai>=1.0 openai>=1.0
python-dotenv>=1.0 python-dotenv>=1.0
mcp>=1.0 mcp>=1.0
httpx>=0.27
beautifulsoup4>=4.12