feat: multi-instance support — agent_name and user_name configurable

All hardcoded "Inara"/"Scott" strings replaced with settings.agent_name
and settings.user_name, read from .env at startup:

- config.py: AGENT_NAME and USER_NAME settings (defaults: Inara / Scott)
- llm_client.py: conversation labels in prompt builder
- session_logger.py: **Name:** labels in session log markdown
- memory_distiller.py: distillation system prompts (mid + long)
- routers/nextcloud_talk.py: @mention prefix strip
- routers/google_chat.py: greeting message

Second instance scaffolding:
- holly/: identity directory with placeholder files (USER_NAME=Holly,
  AGENT_NAME to be chosen by Holly)
- cortex/.env.holly: config for Holly's instance on port 8001
- cortex-holly.service: systemd unit for the second instance

No behavioural change to the Inara/Scott instance — defaults unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-18 20:13:11 -04:00
parent 0b10558f80
commit 97438f1a0f
16 changed files with 116 additions and 12 deletions

36
cortex/.env.holly Normal file
View File

@@ -0,0 +1,36 @@
# Holly instance .env
# Copy secrets from cortex/.env (API keys, NC Talk secret etc.)
# then customise the identity settings below.
# TODO: Set AGENT_NAME to whatever name Holly chooses for her agent
AGENT_NAME=TBD
USER_NAME=Holly
PORT=8001
HOST=0.0.0.0
INARA_DIR=/home/scott/agents_sync/projects/Cortex_and_Inara_dev/holly
SESSIONS_DIR=/home/scott/agents_sync/projects/Cortex_and_Inara_dev/holly/sessions
DEFAULT_MODEL=claude-sonnet-4-6
DEFAULT_TIER=2
# ── Copy these from cortex/.env ──────────────────────────────────────────────
GEMINI_API_KEY=
AE_API_URL=https://dev-api.oneskyit.com
AE_API_KEY=
AE_ACCOUNT_ID=
NEXTCLOUD_URL=https://cloud.dgrzone.com
NEXTCLOUD_TALK_BOT_SECRET=
# Per-backend timeouts
TIMEOUT_CLAUDE=60
TIMEOUT_GEMINI=120
TIMEOUT_LOCAL=300
SCHEDULER_TIMEZONE=America/New_York
AUTO_DISTILL=true
AUTO_DISTILL_SHORT=true
AUTO_DISTILL_MID=true
AUTO_DISTILL_LONG=false

View File

@@ -22,6 +22,11 @@ class Settings(BaseSettings):
ae_account_id: str = "" # x-account-id header
ae_api_timeout: int = 15 # per-request timeout in seconds
# Agent identity — used in prompts, session logs, and memory distillation
# Override in .env for each instance (e.g. AGENT_NAME=Holly, USER_NAME=Holly)
agent_name: str = "Inara"
user_name: str = "Scott"
inara_dir: Path = Path("../inara")
sessions_dir: Path = Path("./data/sessions")
default_model: str = "claude-sonnet-4-6"

View File

@@ -193,7 +193,7 @@ def _build_conversation(messages: list[dict]) -> str:
if prior:
history_lines = []
for msg in prior:
label = "Scott" if msg["role"] == "user" else "Inara"
label = settings.user_name if msg["role"] == "user" else settings.agent_name
history_lines.append(f"{label}: {msg['content']}")
parts.append("<conversation>\n" + "\n\n".join(history_lines) + "\n</conversation>")
parts.append(messages[-1]["content"] if messages else "")

View File

@@ -87,12 +87,12 @@ async def distill_mid() -> dict:
budget_tokens = settings.memory_budget_mid
system_prompt = (
"You are Inara's memory distillation system. "
f"You are {settings.agent_name}'s memory distillation system. "
"Summarize the following recent session logs into a concise mid-term memory digest. "
f"Target length: under {budget_tokens} tokens. "
"Focus on: recurring themes, important decisions made, ongoing projects, "
"Scott's current state and priorities, and anything that should persist into future sessions. "
"Write in first person as Inara (e.g. 'Scott and I worked on...'). "
f"{settings.user_name}'s current state and priorities, and anything that should persist into future sessions. "
f"Write in first person as {settings.agent_name} (e.g. '{settings.user_name} and I worked on...'). "
"Use markdown headings. Be specific and concrete — no filler."
)
@@ -132,7 +132,7 @@ async def distill_long() -> dict:
budget_tokens = settings.memory_budget_long
system_prompt = (
"You are Inara's long-term memory curator. "
f"You are {settings.agent_name}'s long-term memory curator. "
"You will receive the current long-term memory and a recent mid-term digest. "
f"Integrate the new information into the long-term memory. Target: under {budget_tokens} tokens. "
"Rules: preserve important historical facts; update or replace stale information; "
@@ -154,7 +154,7 @@ async def distill_long() -> dict:
now = datetime.now().strftime("%Y-%m-%d %H:%M")
if not response_text.lstrip().startswith("# MEMORY_LONG"):
response_text = (
f"# MEMORY_LONG.md — Inara Long-Term Memory\n\n"
f"# MEMORY_LONG.md — {settings.agent_name} Long-Term Memory\n\n"
f"*Last distilled: {now} via {backend}.*\n\n---\n\n"
+ response_text
)

View File

@@ -18,9 +18,9 @@ async def receive(request: Request):
if event_type == "ADDED_TO_SPACE":
space_type = body.get("space", {}).get("type", "")
greeting = "✨ Hello! I'm Inara. Send me a message and I'll do my best to help."
greeting = f"✨ Hello! I'm {settings.agent_name}. Send me a message and I'll do my best to help."
if space_type == "DM":
greeting = "✨ Hello! I'm Inara. What can I help you with?"
greeting = f"✨ Hello! I'm {settings.agent_name}. What can I help you with?"
return {"text": greeting}
if event_type == "REMOVED_FROM_SPACE":

View File

@@ -158,8 +158,9 @@ async def nextcloud_talk_webhook(request: Request, background_tasks: BackgroundT
except (json.JSONDecodeError, AttributeError):
user_text = (obj.get("name") or obj.get("content", "")).strip()
if user_text.lower().startswith("@inara"):
user_text = user_text[6:].strip()
mention_prefix = f"@{settings.agent_name.lower()}"
if user_text.lower().startswith(mention_prefix):
user_text = user_text[len(mention_prefix):].strip()
if not user_text:
return Response(status_code=200)

View File

@@ -17,6 +17,6 @@ def log_turn(session_id: str, user_msg: str, assistant_msg: str) -> None:
f.write(f"# Session Log — {today}\n")
f.write(
f"\n### [{timestamp}] `{session_id}`\n"
f"**Scott:** {user_msg}\n\n"
f"**Inara:** {assistant_msg}\n"
f"**{settings.user_name}:** {user_msg}\n\n"
f"**{settings.agent_name}:** {assistant_msg}\n"
)