Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,62 @@ WORKSPACE_SHELL_TIMEOUT_S=5
# ── Misc ──────────────────────────────────────────────────────────────────────
DEBUG=false
LOG_LEVEL=INFO

# ── Archimedes application layer (3.0) ────────────────────────────────────────
# The settings that lift Archimedes from a Discord bot to a personal assistant
# application: editable system prompt (Soul), autonomous self-check loop
# (Heartbeat), durable scheduled tasks, MCP server integration, multi-provider
# fallback chain, and the channels access policy.

# Soul: the persona prompt the assistant lives inside. Blank means "use the
# default soul preset"; set ARCHIMEDES_SOUL_PRESET to one of default, short,
# tutor, creative, expert to start from a named preset. The active soul is
# stored in the database and can be edited at runtime with `.app soul`.
ARCHIMEDES_SOUL=
ARCHIMEDES_SOUL_PRESET=default

# Dynamic UI: when true, the assistant may emit a structured card (title,
# tiles, sections, buttons) alongside its prose reply. Set to false to force
# plaintext-only replies for a stricter chat surface.
ARCHIMEDES_DYNAMIC_UI_ENABLED=true

# Heartbeat: the autonomous self-check loop. Off by default so a fresh
# deployment never spends model tokens unprompted. Interval is in minutes;
# active hours form a window (start inclusive, end exclusive). Set
# ARCHIMEDES_HEARTBEAT_HOUR_START == END to run 24/7.
ARCHIMEDES_HEARTBEAT_ENABLED=false
ARCHIMEDES_HEARTBEAT_INTERVAL=30
ARCHIMEDES_HEARTBEAT_HOUR_START=8
ARCHIMEDES_HEARTBEAT_HOUR_END=22
# Override the default heartbeat prompt. Leave blank to use the built-in one.
ARCHIMEDES_HEARTBEAT_PROMPT=
# Pin a model for heartbeat turns. Blank uses the service chain default.
ARCHIMEDES_HEARTBEAT_MODEL=

# Scheduler: durable scheduled tasks (cron and oneshot). On by default but
# inert until a task is added through `.app schedule add` or chat.
ARCHIMEDES_SCHEDULER_ENABLED=true
ARCHIMEDES_SCHEDULER_POLL_SECONDS=15
ARCHIMEDES_SCHEDULER_MAX_CONCURRENT=2

# MCP servers declared at boot. Comma-separated list of name=transport pairs.
# HTTP transport: name=https://host/mcp. Stdio transport: name=stdio:cmd args.
# Servers added at runtime through chat persist in the database independently
# of this list.
ARCHIMEDES_MCP_SERVERS=

# Model service fallback chain. Ordered list of provider[:model] entries.
# A blank list keeps the legacy single-backend behaviour (CHAT_BACKEND wins).
# Example: openrouter:openai/gpt-4o-mini,ollama:llama3.1
ARCHIMEDES_SERVICES=

# ── Discord channel policies ──────────────────────────────────────────────────
# How the Discord channel decides whether to accept an inbound message before
# the assistant ever sees it. Defaults match the OpenClaw channels vocabulary:
# open, allowlist, disabled. Allowlist is the secure default.
DISCORD_DM_POLICY=allowlist
DISCORD_GUILD_POLICY=allowlist
# Allowlists. Comma-separated numeric ids. The bot owner is always allowed in
# DMs even when missing from DISCORD_DM_ALLOW_USERS.
DISCORD_DM_ALLOW_USERS=
DISCORD_GUILD_ALLOW=
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
# Archimedes

A standalone AI chat bot for Discord. Archimedes is a memory-backed conversational
companion: mention it, reply to it, use `.ask`, or just message it directly,
and it answers with a persona-driven model while learning who it is talking to.

It is fully self-contained: the `.ai` and `.arch` command groups, per-user /
per-channel / per-server context learning, the tool-calling loop and the
memory sidecar all ship with their own framework, config, database schema and
entry point. There are no external service dependencies beyond the model
provider, PostgreSQL and (optionally) Redis.
A personal-assistant application that lives in Discord. Archimedes is a
memory-backed conversational companion with a coherent product surface:
an editable system-prompt **Soul**, an autonomous **Heartbeat** self-check
loop, durable **Scheduled Tasks**, **MCP** server integration, a
**Dynamic UI** card layer, and a multi-provider **Service Chain** with
fallback. Discord is one **channel** into this application; the same
agent can run behind a web, CLI or voice transport without rewriting any
of the assistant logic. See `docs/architecture.md` for the full picture.

Mention it, reply to it, use `.ask`, or just message it directly, and it
answers with a persona-driven model while learning who it is talking to.

It is fully self-contained: the `.ai`, `.arch`, and `.app` command groups,
per-user / per-channel / per-server context learning, the tool-calling
loop, the application layer (`arch/`), the channel layer (`channels/`)
and the memory sidecar all ship with their own framework, config,
database schema and entry point. There are no external service
dependencies beyond the model provider, PostgreSQL and (optionally) Redis.

This bot lives at the root of its own repository. `main.py` is the entry
point, `requirements.txt` / `pyproject.toml` declare the dependencies,
Expand All @@ -17,6 +26,26 @@ runs the test suite.

## What it does

- **Soul** -- an editable system-prompt persona with named presets
(default, short, tutor, creative, expert). Switch at runtime with
`.app soul preset <name>` or write a custom soul with `.app soul set`.
The active soul lives in the database, so it survives restarts.
- **Heartbeat** -- an optional autonomous self-check loop. Every N
minutes during a configured active-hours window, the assistant reviews
its memories and pending tasks; when something needs attention it
speaks up. Off by default; opt in with `ARCHIMEDES_HEARTBEAT_ENABLED=1`.
- **Scheduled Tasks** -- durable one-shot and cron-style tasks survive
restarts and fire back into the channel that scheduled them.
- **MCP integration** -- declare Model Context Protocol servers at boot
through `ARCHIMEDES_MCP_SERVERS` or add them live with `.app mcp add`.
HTTP and stdio transports are supported; the discovered tools bridge
into the agent's tool registry alongside the built-ins.
- **Dynamic UI** -- replies can be more than a chat bubble: structured
cards with title, big-number tiles, sections, buttons and follow-up
suggestions render natively as Discord embeds plus interactive views.
- **Service Chain** -- an ordered list of model providers with per-
provider circuit breakers. When OpenRouter errors, the chain falls
through to the next entry without the user noticing.
- **Conversational chat** -- replies to `@mentions`, replies to its own
messages, direct messages, the `.ask` command, and optional ambient
chime-ins.
Expand Down
57 changes: 57 additions & 0 deletions arch/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""arch -- Archimedes as a personal-assistant application.

This package is the application layer the Discord bot now stands on top
of. The Discord cog hands an inbound message to ``ArchAgent.handle`` and
gets back an ``ArchResponse``; future channels (web, voice, CLI) consume
the same surface, so the agent is no longer welded to discord.py.

The public exports below are the only surface external code should touch.
Internal helpers live in their own modules and stay there.
"""
from __future__ import annotations

from arch.config import (
ArchConfig, HeartbeatConfig, MCPServerSpec, SchedulerConfig, ServiceSpec,
)
from arch.core import ArchAgent, ChannelContext
from arch.dynamic_ui import (
ArchResponse, Button, Card, Section, StatTile, Suggestion,
render_card_plain,
)
from arch.heartbeat import (
Heartbeat, HeartbeatResult, HeartbeatStore, OK_TOKEN, in_active_window,
)
from arch.mcp import MCPRegistry, MCPTool
from arch.memories import ArchMemory, MemoryEntry, format_for_prompt
from arch.scheduler import (
Scheduler, SchedulerStore, ScheduledTask, cron_due, parse_oneshot_delay,
)
from arch.services import (
ProviderError, ServiceChain, ServiceHealth, build_chain_from_env,
)
from arch.soul import (
DEFAULT_SOUL, SOUL_MAX_CHARS, SOUL_PRESETS, SoulRecord, SoulStore,
list_presets, normalise, preset,
)
from arch.version import ARCH_CODENAME, ARCH_VERSION

__version__ = ARCH_VERSION

__all__ = [
"__version__",
"ARCH_VERSION", "ARCH_CODENAME",
"ArchAgent", "ChannelContext",
"ArchConfig", "HeartbeatConfig", "MCPServerSpec",
"SchedulerConfig", "ServiceSpec",
"ArchResponse", "Button", "Card", "Section", "StatTile", "Suggestion",
"render_card_plain",
"Heartbeat", "HeartbeatResult", "HeartbeatStore", "OK_TOKEN",
"in_active_window",
"MCPRegistry", "MCPTool",
"ArchMemory", "MemoryEntry", "format_for_prompt",
"Scheduler", "SchedulerStore", "ScheduledTask", "cron_due",
"parse_oneshot_delay",
"ProviderError", "ServiceChain", "ServiceHealth", "build_chain_from_env",
"DEFAULT_SOUL", "SOUL_MAX_CHARS", "SOUL_PRESETS", "SoulRecord",
"SoulStore", "list_presets", "normalise", "preset",
]
167 changes: 167 additions & 0 deletions arch/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""arch/config.py -- Archimedes application settings.

A small typed view over the Archimedes-specific environment variables. The bot's
top-level ``config.Config`` still owns the Discord, database, model-provider
and pipeline knobs; this module only covers the Archimedes personality layer:

* Soul (the editable system prompt and its preset name)
* Heartbeat (autonomous self-check cadence and active window)
* Scheduler (durable task runner)
* MCP servers (declared at boot, more added at runtime)
* Service chain (ordered list of model providers, with fallback)
* Dynamic UI (whether Archimedes may emit cards alongside prose)

All values are env-driven and frozen. Database overrides are merged in by
``arch.core.ArchAgent.reload_settings`` so an operator can flip a switch with
``/soul`` or ``.ai arch`` without redeploying.
"""
from __future__ import annotations

import os
from dataclasses import dataclass, field

from config import _env, _env_bool, _env_int, _env_list


@dataclass(frozen=True)
class HeartbeatConfig:
"""Autonomous self-check loop. Off by default so a fresh deployment
never spends model tokens unprompted."""

enabled: bool
interval_minutes: int
active_hour_start: int # 0-23, local server clock
active_hour_end: int # 0-23, exclusive
prompt: str
model: str # blank means "use the Archimedes default"


@dataclass(frozen=True)
class SchedulerConfig:
enabled: bool
poll_seconds: int
max_concurrent: int


@dataclass(frozen=True)
class MCPServerSpec:
"""One MCP server declared in the environment. Servers added at runtime
live in the ``archimedes_mcp_servers`` table and are not represented here."""

name: str
transport: str # "stdio" or "http"
url: str # used for http
command: str # used for stdio
args: tuple[str, ...]


@dataclass(frozen=True)
class ServiceSpec:
"""One entry in the model service fallback chain."""

name: str # "openrouter", "ollama", "anthropic"
model: str # may be blank to use the provider default


@dataclass(frozen=True)
class ArchConfig:
"""Frozen Archimedes application configuration. Built once at boot."""

soul: str
soul_preset: str
dynamic_ui_enabled: bool
heartbeat: HeartbeatConfig
scheduler: SchedulerConfig
mcp_servers: tuple[MCPServerSpec, ...]
services: tuple[ServiceSpec, ...]
dm_policy: str
guild_policy: str

@classmethod
def from_env(cls) -> "ArchConfig":
return cls(
soul=_env("ARCHIMEDES_SOUL", ""),
soul_preset=_env("ARCHIMEDES_SOUL_PRESET", "default"),
dynamic_ui_enabled=_env_bool("ARCHIMEDES_DYNAMIC_UI_ENABLED", True),
heartbeat=HeartbeatConfig(
enabled=_env_bool("ARCHIMEDES_HEARTBEAT_ENABLED", False),
interval_minutes=_env_int("ARCHIMEDES_HEARTBEAT_INTERVAL", 30),
active_hour_start=_env_int("ARCHIMEDES_HEARTBEAT_HOUR_START", 8),
active_hour_end=_env_int("ARCHIMEDES_HEARTBEAT_HOUR_END", 22),
prompt=_env(
"ARCHIMEDES_HEARTBEAT_PROMPT",
"[HEARTBEAT] This is an automatic self-check. Review your "
"memories and pending tasks. If everything looks good and "
"nothing needs attention, respond with exactly: "
"HEARTBEAT_OK. If something needs attention (stale "
"memories, due tasks, user follow-ups), address it.",
),
model=_env("ARCHIMEDES_HEARTBEAT_MODEL", ""),
),
scheduler=SchedulerConfig(
enabled=_env_bool("ARCHIMEDES_SCHEDULER_ENABLED", True),
poll_seconds=_env_int("ARCHIMEDES_SCHEDULER_POLL_SECONDS", 15),
max_concurrent=_env_int("ARCHIMEDES_SCHEDULER_MAX_CONCURRENT", 2),
),
mcp_servers=_parse_mcp(_env("ARCHIMEDES_MCP_SERVERS", "")),
services=_parse_services(_env_list("ARCHIMEDES_SERVICES")),
dm_policy=_env("DISCORD_DM_POLICY", "allowlist").lower(),
guild_policy=_env("DISCORD_GUILD_POLICY", "allowlist").lower(),
)


def _parse_mcp(raw: str) -> tuple[MCPServerSpec, ...]:
"""Parse a compact MCP server spec list.

Format: ``name1=http://host/mcp,name2=stdio:my-tool --flag``. A blank
string returns no servers. The right-hand side starts with ``stdio:``
for a local executable or with ``http(s)://`` for a streamable HTTP
endpoint; anything else is rejected silently and logged at boot.
"""
out: list[MCPServerSpec] = []
if not raw:
return tuple(out)
for entry in raw.split(","):
entry = entry.strip()
if not entry or "=" not in entry:
continue
name, sep, value = entry.partition("=")
name = name.strip()
value = value.strip()
if not name or not value:
continue
if value.startswith("stdio:"):
tail = value[len("stdio:"):].strip()
parts = tail.split()
if not parts:
continue
out.append(MCPServerSpec(
name=name, transport="stdio", url="",
command=parts[0], args=tuple(parts[1:]),
))
elif value.startswith("http://") or value.startswith("https://"):
out.append(MCPServerSpec(
name=name, transport="http", url=value,
command="", args=(),
))
return tuple(out)


def _parse_services(raw: list[str]) -> tuple[ServiceSpec, ...]:
"""Parse ``ARCHIMEDES_SERVICES`` as an ordered list of ``provider[:model]``.

A blank list falls back to the bot's existing single backend, so an
upgrade from a pre-Archimedes deployment behaves exactly as before.
"""
out: list[ServiceSpec] = []
for item in raw:
name, sep, model = item.partition(":")
name = name.strip().lower()
model = model.strip()
if name:
out.append(ServiceSpec(name=name, model=model))
if not out:
# Single-backend fallback: pick whichever the bot was already using.
backend = (os.environ.get("CHAT_BACKEND") or "openrouter").strip().lower()
out.append(ServiceSpec(name=backend, model=""))
return tuple(out)
Loading
Loading