Implement runtime agent loop and container hygiene

This commit is contained in:
2026-05-12 07:56:37 -05:00
parent 412d7caec3
commit a09197e85a
13 changed files with 524 additions and 70 deletions

View File

@@ -1,24 +0,0 @@
# Application Settings
APP_NAME=Sanctum Chronicler
APP_ENV=development
DEBUG=false
# Database
DATABASE_URL=postgresql+asyncpg://sanctum:password@localhost:5432/sanctum
DB_PASSWORD=password
# Twitch Configuration
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
TWITCH_BOT_USERNAME=
TWITCH_CHANNEL_NAME=
# LLM Configuration
# Supported providers: openai, ollama, lm_studio (or leave empty for mock)
LLM_PROVIDER=
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=gpt-3.5-turbo
# Export Configuration
EXPORT_PATH=./exports

4
.gitignore vendored
View File

@@ -44,7 +44,7 @@ pgdata/
# ========================================= # =========================================
# Runtime / Exports # Runtime / Exports
# ========================================= # =========================================
exports/ /exports/
data/ data/
logs/ logs/
@@ -78,4 +78,4 @@ Thumbs.db
# ========================================= # =========================================
# Misc # Misc
# ========================================= # =========================================
*.log *.log

View File

@@ -42,7 +42,7 @@ EXPOSE 8000
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health')" CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
# Run application # Run application
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,9 +1,7 @@
"""Agent Orchestrator - Routes messages and manages agent modes.""" """Agent Orchestrator - Routes messages and manages agent modes."""
import logging import logging
import uuid from datetime import datetime, timedelta
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.agent.policies import ( from app.agent.policies import (
ChatActivityPolicy, ChatActivityPolicy,
@@ -16,7 +14,7 @@ from app.agent.modes.warden import WardenMode
from app.agent.modes.librarian import LibrarianMode from app.agent.modes.librarian import LibrarianMode
from app.agent.modes.scribe import ScribeMode from app.agent.modes.scribe import ScribeMode
from app.llm.client import LLMClient from app.llm.client import LLMClient
from app.memory.database import async_session_factory from app.memory.database import get_session
from app.memory.models import AgentActionType from app.memory.models import AgentActionType
from app.memory.repository import Repository from app.memory.repository import Repository
@@ -32,9 +30,10 @@ class AgentOrchestrator:
and how to flag suspicious content. and how to flag suspicious content.
""" """
def __init__(self): def __init__(self, loop_interval_seconds: float = 60.0):
"""Initialize the orchestrator and all modes.""" """Initialize the orchestrator and all modes."""
self.llm_client = LLMClient() self.llm_client = LLMClient()
self.loop_interval_seconds = loop_interval_seconds
# Initialize modes # Initialize modes
self.hearthkeeper = HearthkeeperMode(self.llm_client) self.hearthkeeper = HearthkeeperMode(self.llm_client)
@@ -63,18 +62,22 @@ class AgentOrchestrator:
Returns: Returns:
Session ID Session ID
""" """
session_id = str(uuid.uuid4()) session_id: str | None = None
async for db_session in get_session():
async with async_session_factory() as db_session:
repo = Repository(db_session) repo = Repository(db_session)
await repo.create_session(channel_name) session_id = await repo.create_session(channel_name)
if session_id is None:
raise RuntimeError("Failed to create stream session")
self.active_sessions[session_id] = { self.active_sessions[session_id] = {
"channel_name": channel_name, "channel_name": channel_name,
"started_at": datetime.utcnow(), "started_at": datetime.utcnow(),
"message_count": 0, "message_count": 0,
"theme": None, "theme": None,
"last_hearthkeeper_prompt_at": None,
} }
self.chat_activity.record_activity(session_id)
logger.info(f"Started session {session_id} for {channel_name}") logger.info(f"Started session {session_id} for {channel_name}")
return session_id return session_id
@@ -90,7 +93,7 @@ class AgentOrchestrator:
logger.warning(f"Session {session_id} not found") logger.warning(f"Session {session_id} not found")
return return
async with async_session_factory() as db_session: async for db_session in get_session():
repo = Repository(db_session) repo = Repository(db_session)
await repo.end_session(session_id) await repo.end_session(session_id)
@@ -122,7 +125,7 @@ class AgentOrchestrator:
actions = [] actions = []
agent_response = None agent_response = None
async with async_session_factory() as db_session: async for db_session in get_session():
repo = Repository(db_session) repo = Repository(db_session)
# Store the message # Store the message
@@ -136,12 +139,13 @@ class AgentOrchestrator:
# Record activity # Record activity
self.chat_activity.record_activity(session_id) self.chat_activity.record_activity(session_id)
session_info["message_count"] += 1 session_info["message_count"] += 1
session_info["last_hearthkeeper_prompt_at"] = None
# 1. Warden always analyzes (passive mode) # 1. Warden always analyzes (passive mode)
warden_result = await self.warden.analyze_message(message) warden_result = await self.warden.analyze_message(message)
if warden_result["is_suspicious"]: if warden_result["is_suspicious"]:
actions.append(f"WARDEN_FLAG: {warden_result['severity']}") actions.append(f"WARDEN_FLAG: {warden_result['severity']}")
async with async_session_factory() as db_session: async for db_session in get_session():
repo = Repository(db_session) repo = Repository(db_session)
await repo.record_action( await repo.record_action(
session_id=session_id, session_id=session_id,
@@ -153,9 +157,12 @@ class AgentOrchestrator:
# 2. Check if we should suppress responses due to active chat # 2. Check if we should suppress responses due to active chat
recent_messages = [] recent_messages = []
async with async_session_factory() as db_session: async for db_session in get_session():
repo = Repository(db_session) repo = Repository(db_session)
recent_messages = await repo.get_recent_messages(session_id, limit=10) recent_messages = await repo.get_messages_since(
session_id=session_id,
since=datetime.utcnow() - timedelta(minutes=1),
)
if self.response_suppression.should_suppress_response(len(recent_messages)): if self.response_suppression.should_suppress_response(len(recent_messages)):
logger.debug("Response suppressed due to active chat") logger.debug("Response suppressed due to active chat")
@@ -164,25 +171,7 @@ class AgentOrchestrator:
"actions_taken": actions, "actions_taken": actions,
} }
# 3. Hearthkeeper: Generate prompt if chat inactive # 3. Librarian: Archive important messages (passive)
if self.chat_activity.should_hearthkeeper_prompt(session_id):
try:
agent_response = await self.hearthkeeper.generate_prompt(
theme=session_info.get("theme")
)
actions.append("HEARTHKEEPER_PROMPT")
async with async_session_factory() as db_session:
repo = Repository(db_session)
await repo.record_action(
session_id=session_id,
action_type=AgentActionType.RESPONSE,
mode="hearthkeeper",
description=agent_response,
)
except Exception as e:
logger.error(f"Error in Hearthkeeper: {e}")
# 4. Librarian: Archive important messages (passive)
if len(message) > 50: # Archive longer messages if len(message) > 50: # Archive longer messages
await self.librarian.archive_message(message_id, message, username) await self.librarian.archive_message(message_id, message, username)
@@ -195,6 +184,88 @@ class AgentOrchestrator:
"actions_taken": actions, "actions_taken": actions,
} }
def set_loop_interval(self, interval_seconds: float) -> None:
"""Update how frequently the background agent loop runs."""
if interval_seconds < 1:
raise ValueError("Loop interval must be at least 1 second")
self.loop_interval_seconds = interval_seconds
def get_loop_status(self) -> dict:
"""Get background loop configuration and current session count."""
return {
"interval_seconds": self.loop_interval_seconds,
"active_session_count": len(self.active_sessions),
}
async def tick(self) -> list[dict]:
"""Evaluate active sessions for time-based agent behavior."""
results = []
for session_id in list(self.active_sessions.keys()):
result = await self._tick_session(session_id)
if result:
results.append(result)
return results
async def _tick_session(self, session_id: str) -> dict | None:
"""Evaluate a single active session during the background loop."""
session_info = self.active_sessions.get(session_id)
if not session_info:
return None
recent_messages = []
async for db_session in get_session():
repo = Repository(db_session)
recent_messages = await repo.get_messages_since(
session_id=session_id,
since=datetime.utcnow() - timedelta(minutes=1),
)
if self.response_suppression.should_suppress_response(len(recent_messages)):
return {
"session_id": session_id,
"actions_taken": [],
"agent_response": None,
"reason": "active_chat",
}
if not self.chat_activity.should_hearthkeeper_prompt(session_id):
return None
last_activity_at = self.chat_activity.last_activity_at(session_id)
last_prompt_at = session_info.get("last_hearthkeeper_prompt_at")
if last_prompt_at and last_activity_at and last_prompt_at >= last_activity_at:
return None
try:
agent_response = await self.hearthkeeper.generate_prompt(
theme=session_info.get("theme")
)
session_info["last_hearthkeeper_prompt_at"] = datetime.utcnow()
async for db_session in get_session():
repo = Repository(db_session)
await repo.record_action(
session_id=session_id,
action_type=AgentActionType.RESPONSE,
mode="hearthkeeper",
description=agent_response,
)
return {
"session_id": session_id,
"actions_taken": ["HEARTHKEEPER_PROMPT"],
"agent_response": agent_response,
"reason": "inactive_chat",
}
except Exception as e:
logger.error(f"Error in Hearthkeeper loop: {e}")
return {
"session_id": session_id,
"actions_taken": [],
"agent_response": None,
"reason": "hearthkeeper_error",
}
async def get_session_status(self, session_id: str) -> dict: async def get_session_status(self, session_id: str) -> dict:
"""Get status of a session.""" """Get status of a session."""
if session_id not in self.active_sessions: if session_id not in self.active_sessions:

View File

@@ -19,9 +19,13 @@ class ChatActivityPolicy:
self.inactivity_threshold = timedelta(minutes=inactivity_threshold_minutes) self.inactivity_threshold = timedelta(minutes=inactivity_threshold_minutes)
self.last_message_time: dict[str, datetime] = {} self.last_message_time: dict[str, datetime] = {}
def record_activity(self, session_id: str) -> None: def record_activity(self, session_id: str, occurred_at: datetime | None = None) -> None:
"""Record that chat activity occurred.""" """Record that chat activity occurred."""
self.last_message_time[session_id] = datetime.utcnow() self.last_message_time[session_id] = occurred_at or datetime.utcnow()
def last_activity_at(self, session_id: str) -> datetime | None:
"""Get the most recent chat activity time for a session."""
return self.last_message_time.get(session_id)
def minutes_since_activity(self, session_id: str) -> int: def minutes_since_activity(self, session_id: str) -> int:
"""Get minutes since last chat message.""" """Get minutes since last chat message."""

View File

@@ -1,5 +1,6 @@
"""Configuration management using pydantic-settings.""" """Configuration management using pydantic-settings."""
from pydantic import field_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional from typing import Optional
@@ -12,6 +13,30 @@ class Settings(BaseSettings):
APP_ENV: str = "development" APP_ENV: str = "development"
DEBUG: bool = False DEBUG: bool = False
@field_validator("DEBUG", mode="before")
@classmethod
def parse_debug(cls, value: object) -> bool:
"""Parse permissive runtime DEBUG values from shell environments."""
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "t", "yes", "y", "on", "debug"}:
return True
if normalized in {
"0",
"false",
"f",
"no",
"n",
"off",
"release",
"prod",
"production",
}:
return False
return False
# Database # Database
DATABASE_URL: str = "postgresql+asyncpg://sanctum:password@localhost:5432/sanctum" DATABASE_URL: str = "postgresql+asyncpg://sanctum:password@localhost:5432/sanctum"
@@ -27,6 +52,9 @@ class Settings(BaseSettings):
LLM_API_KEY: Optional[str] = None LLM_API_KEY: Optional[str] = None
LLM_MODEL: str = "gpt-3.5-turbo" LLM_MODEL: str = "gpt-3.5-turbo"
# Agent loop
AGENT_LOOP_INTERVAL_SECONDS: float = 60.0
# Export # Export
EXPORT_PATH: str = "exports" EXPORT_PATH: str = "exports"

1
app/exports/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Exports module."""

126
app/exports/markdown.py Normal file
View File

@@ -0,0 +1,126 @@
"""Markdown export functionality for stream ledgers."""
import logging
from datetime import datetime
from pathlib import Path
from app.config import settings
from app.memory.database import get_session
from app.memory.repository import Repository
logger = logging.getLogger(__name__)
class MarkdownExporter:
"""Exports stream session data as markdown ledgers."""
def __init__(self, export_path: str | None = None):
"""
Initialize exporter.
Args:
export_path: Directory to export ledgers to (defaults to settings.EXPORT_PATH)
"""
self.export_path = Path(export_path or settings.EXPORT_PATH)
self.export_path.mkdir(parents=True, exist_ok=True)
async def export_session(self, session_id: str) -> str:
"""
Export a session as a markdown ledger.
Args:
session_id: Session ID to export
Returns:
Markdown content
"""
async for db_session in get_session():
repo = Repository(db_session)
session = await repo.get_session(session_id)
if not session:
logger.warning(f"Session {session_id} not found")
return ""
# Gather data
messages = await repo.get_recent_messages(session_id, limit=1000)
actions = await repo.get_session_actions(session_id)
clips = await repo.get_clip_candidates(session_id)
seeds = await repo.get_blog_seeds(session_id)
# Build markdown
date = session.started_at.strftime("%Y-%m-%d")
ledger = f"# Sanctum Ledger — {date}\n\n"
ledger += f"**Channel:** {session.channel_name}\n"
ledger += f"**Started:** {session.started_at.isoformat()}\n"
if session.ended_at:
ledger += f"**Ended:** {session.ended_at.isoformat()}\n"
ledger += "\n"
# Stream Theme
ledger += "## Stream Theme\n"
if session.theme:
ledger += f"{session.theme}\n"
else:
ledger += "*No theme recorded*\n"
ledger += "\n"
# Notable Discussion
ledger += "## Notable Discussion\n"
if messages:
for msg in messages[:20]: # Latest 20 messages
ledger += f"- **{msg.username}:** {msg.content[:100]}\n"
else:
ledger += "*No messages recorded*\n"
ledger += "\n"
# Agent Actions
ledger += "## Agent Actions\n"
if actions:
for action in actions:
ledger += f"- **{action.mode}** ({action.action_type}): {action.description}\n"
else:
ledger += "*No agent actions recorded*\n"
ledger += "\n"
# Clip Candidates
ledger += "## Clip Candidates\n"
if clips:
for clip in clips:
ledger += f"- {clip.reason}\n"
else:
ledger += "*No clip candidates identified*\n"
ledger += "\n"
# Blog Seeds
ledger += "## Blog Seeds\n"
if seeds:
for seed in seeds:
ledger += f"- **{seed.topic}:** {seed.description}\n"
else:
ledger += "*No blog seeds proposed*\n"
ledger += "\n"
logger.info(f"Generated ledger for session {session_id}")
return ledger
async def save_session_ledger(self, session_id: str) -> Path:
"""
Export session and save to file.
Args:
session_id: Session ID
Returns:
Path to saved file
"""
ledger = await self.export_session(session_id)
date = datetime.utcnow().strftime("%Y-%m-%d")
filename = f"ledger_{date}_{session_id[:8]}.md"
filepath = self.export_path / filename
filepath.write_text(ledger, encoding="utf-8")
logger.info(f"Saved ledger to {filepath}")
return filepath

View File

@@ -1,7 +1,8 @@
"""FastAPI main application.""" """FastAPI main application."""
from fastapi import FastAPI, HTTPException import asyncio
from fastapi.responses import JSONResponse from contextlib import suppress
from fastapi import FastAPI, HTTPException, Form
from datetime import datetime from datetime import datetime
import logging import logging
@@ -20,15 +21,37 @@ app = FastAPI(
# Global orchestrator instance # Global orchestrator instance
orchestrator: AgentOrchestrator | None = None orchestrator: AgentOrchestrator | None = None
agent_loop_task: asyncio.Task | None = None
async def agent_loop() -> None:
"""Run periodic time-based agent behavior for active sessions."""
if not orchestrator:
return
while True:
try:
results = await orchestrator.tick()
if results:
logger.info(f"Agent loop actions: {results}")
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"Agent loop tick failed: {e}")
await asyncio.sleep(orchestrator.loop_interval_seconds)
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Initialize database and services on startup.""" """Initialize database and services on startup."""
global orchestrator global orchestrator, agent_loop_task
try: try:
await init_db() await init_db()
orchestrator = AgentOrchestrator() orchestrator = AgentOrchestrator(
loop_interval_seconds=settings.AGENT_LOOP_INTERVAL_SECONDS
)
agent_loop_task = asyncio.create_task(agent_loop())
logger.info("Application started successfully") logger.info("Application started successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to start application: {e}") logger.error(f"Failed to start application: {e}")
@@ -38,6 +61,10 @@ async def startup_event():
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():
"""Clean up resources on shutdown.""" """Clean up resources on shutdown."""
if agent_loop_task:
agent_loop_task.cancel()
with suppress(asyncio.CancelledError):
await agent_loop_task
logger.info("Application shutting down") logger.info("Application shutting down")
@@ -53,7 +80,7 @@ async def health_check() -> dict:
@app.post("/admin/session/start") @app.post("/admin/session/start")
async def start_session(channel_name: str) -> dict: async def start_session(channel_name: str = Form(...)) -> dict:
"""Start a new stream session.""" """Start a new stream session."""
if not orchestrator: if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized") raise HTTPException(status_code=503, detail="Orchestrator not initialized")
@@ -68,7 +95,7 @@ async def start_session(channel_name: str) -> dict:
@app.post("/admin/session/end") @app.post("/admin/session/end")
async def end_session(session_id: str) -> dict: async def end_session(session_id: str = Form(...)) -> dict:
"""End the current stream session.""" """End the current stream session."""
if not orchestrator: if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized") raise HTTPException(status_code=503, detail="Orchestrator not initialized")
@@ -82,7 +109,7 @@ async def end_session(session_id: str) -> dict:
@app.post("/admin/test-message") @app.post("/admin/test-message")
async def test_message(session_id: str, message: str, username: str = "test_user") -> dict: async def test_message(session_id: str = Form(...), message: str = Form(...), username: str = Form("test_user")) -> dict:
"""Send a test message to the orchestrator.""" """Send a test message to the orchestrator."""
if not orchestrator: if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized") raise HTTPException(status_code=503, detail="Orchestrator not initialized")
@@ -116,6 +143,37 @@ async def get_ledger(session_id: str) -> dict:
} }
@app.get("/admin/loop/status")
async def get_loop_status() -> dict:
"""Get the background agent loop runtime configuration."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
return {
"status": "running" if agent_loop_task and not agent_loop_task.done() else "stopped",
**orchestrator.get_loop_status(),
"timestamp": datetime.utcnow().isoformat(),
}
@app.post("/admin/loop/frequency")
async def set_loop_frequency(interval_seconds: float = Form(...)) -> dict:
"""Set how frequently the background agent loop runs."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
try:
orchestrator.set_loop_interval(interval_seconds)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {
"status": "loop_frequency_updated",
"interval_seconds": orchestrator.loop_interval_seconds,
"timestamp": datetime.utcnow().isoformat(),
}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run( uvicorn.run(

View File

@@ -95,6 +95,21 @@ class Repository:
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
async def get_messages_since(
self, session_id: str, since: datetime
) -> list[ChatMessage]:
"""Get messages recorded since a specific timestamp."""
stmt = (
select(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.timestamp >= since,
)
.order_by(ChatMessage.timestamp.desc())
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
# Agent Action operations # Agent Action operations
async def record_action( async def record_action(

View File

@@ -41,6 +41,7 @@ services:
LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-} LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-gpt-3.5-turbo} LLM_MODEL: ${LLM_MODEL:-gpt-3.5-turbo}
AGENT_LOOP_INTERVAL_SECONDS: ${AGENT_LOOP_INTERVAL_SECONDS:-60}
EXPORT_PATH: /app/exports EXPORT_PATH: /app/exports
volumes: volumes:
- ./exports:/app/exports - ./exports:/app/exports
@@ -49,7 +50,7 @@ services:
networks: networks:
- sanctum-net - sanctum-net
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"]
interval: 30s interval: 30s
timeout: 3s timeout: 3s
retries: 3 retries: 3

173
prompt.md Normal file
View File

@@ -0,0 +1,173 @@
Build a Dockerized Python MVP called sanctum-agent.
Goal:
Create scaffolding for an AI stream assistant called “The Sanctum Chronicler.” It should eventually connect to Twitch chat, monitor stream discussion, lightly guide conversation, store stream events, and export a post-stream markdown ledger.
Tech stack:
- Python 3.12
- FastAPI
- PostgreSQL
- Docker Compose
- Async architecture
- Markdown export
- Environment variables via .env
- Placeholder LLM client that can later support OpenAI, Ollama, or LM Studio
Project structure:
sanctum-agent/
app/
main.py
config.py
twitch/
__init__.py
eventsub.py
chat.py
agent/
__init__.py
orchestrator.py
policies.py
modes/
__init__.py
hearthkeeper.py
steward.py
warden.py
librarian.py
scribe.py
memory/
__init__.py
database.py
models.py
repository.py
llm/
__init__.py
client.py
prompts.py
exports/
__init__.py
markdown.py
exports/
data/
Dockerfile
docker-compose.yml
requirements.txt
.env.example
README.md
Requirements:
1. FastAPI app
Create endpoints:
- GET /health
- POST /admin/session/start
- POST /admin/session/end
- GET /admin/ledger
- POST /admin/test-message
2. Configuration
Create app/config.py using pydantic-settings.
Support these environment variables:
- APP_NAME
- APP_ENV
- DATABASE_URL
- TWITCH_CLIENT_ID
- TWITCH_CLIENT_SECRET
- TWITCH_BOT_USERNAME
- TWITCH_CHANNEL_NAME
- LLM_PROVIDER
- LLM_BASE_URL
- LLM_API_KEY
- EXPORT_PATH
3. Database
Use SQLAlchemy async if reasonable.
Create models for:
- StreamSession
- ChatMessage
- AgentAction
- ClipCandidate
- BlogSeed
The database layer can be functional scaffolding. It does not need full production migrations yet.
4. Agent Orchestrator
Create an AgentOrchestrator class that:
- receives chat messages
- stores them
- decides whether the agent should respond
- suppresses responses when human chat is active
- routes behavior to internal modes
Add a simple policy:
- If no human chat for 15 minutes, Hearthkeeper may generate a gentle prompt.
- If chat is active, agent stays silent.
- If message contains suspicious Discord-growth language, Warden flags it.
5. Modes
Create placeholder classes:
- HearthkeeperMode
- StewardMode
- WardenMode
- LibrarianMode
- ScribeMode
Each should have clear docstrings explaining its purpose.
6. LLM Client
Create an LLMClient abstraction with a generate() method.
For now, return deterministic placeholder text if no provider is configured.
7. Markdown Export
Create a markdown exporter that generates:
# Sanctum Ledger — YYYY-MM-DD
## Stream Theme
## Notable Discussion
## Agent Actions
## Clip Candidates
## Blog Seeds
8. Twitch Layer
Create placeholder Twitch modules:
- eventsub.py should define a TwitchEventSubClient class with connect(), disconnect(), and listen() stubs.
- chat.py should define send_chat_message() as a placeholder.
Do not implement real OAuth yet. Add TODO comments with where EventSub and Send Chat Message API integration will go.
9. Docker
Create:
- Dockerfile for FastAPI app
- docker-compose.yml with:
- sanctum-agent
- sanctum-db using postgres:16
10. README
Write a README with:
- project purpose
- architecture overview
- setup steps
- docker compose commands
- current limitations
- next implementation steps
Style:
- Keep code clean and readable.
- Use type hints.
- Add comments where future Twitch, Discord, and LLM integrations will be inserted.
- Do not overbuild.
- This is scaffolding, not a finished production bot.
After generating the files, also provide:
1. a file tree
2. commands to run the app
3. a short explanation of the next practical implementation step

View File

@@ -7,3 +7,4 @@ asyncpg==0.29.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
httpx==0.25.2 httpx==0.25.2
python-dotenv==1.0.0 python-dotenv==1.0.0
python-multipart==0.0.6