v0.4.0 β A minimal persistent AI agent that lives on your machine, talks to you on Discord, remembers what matters, and orchestrates other agents on your behalf.
~6,000 lines of TypeScript. 16 dependencies. One SQLite file for memory. Runs on any Linux host with systemd β a home server, VPS, or cloud instance all work the same way.
γΎγ β diligent, hardworking, small but mighty.
Mame is a personal daemon that runs on your own hardware, listens on the messaging channels you pick, and does real work: searches the web, remembers facts over time, dispatches coding tasks to Claude Code, delivers scheduled daily briefs, and β when a dispatched subtask needs your input mid-flight β pauses that subtask and asks you in Discord, then resumes when you reply.
It's not a chatbot wrapper. It's a small, opinionated agent framework built on a few strong primitives:
- OpenRouter-first LLM client (in-repo) β any model OpenRouter serves, no client-side catalog; plus any OpenAI-compatible endpoint via
MAME_LLM_BASE_URL/MAME_LLM_API_KEY - Owned agent loop β tool execution, retries, and structured output without a third-party agent SDK
- SQLite + FTS5 + sqlite-vec for hybrid keyword + semantic memory
- @modelcontextprotocol/sdk for the embedded MCP server that lets Mame orchestrate other agents
- systemd + systemd-creds for supervision and at-rest credential encryption (on Linux)
The whole thing is small enough to fit in your head and hackable enough to change.
- π¬ Multi-channel messaging β Discord, Telegram, and Signal, with image attachment support for multimodal vision
- π§ Hybrid memory β FTS5 keyword search + vector similarity via sqlite-vec, fused with Reciprocal Rank Fusion; memories surface with timezone-explicit timestamps and natural-language queries find them by meaning, not just exact words
- π Temporal awareness β knows the current wall-clock time in your timezone on every turn; handles cross-timezone scheduling (JST β EST, DST-aware) without guessing
- π Daily briefs β morning weather + rotating theme (nature, wellness, good news, wisdom, food, culture, reflection), plus evening forecast
- π Web research β web search (Brave), web fetch, headless browser with persistent login sessions via agent-browser
- βοΈ Coding dispatch β hands file-editing, test-running, git, and PR work to Claude Code as a subprocess
- β MCP orchestration β exposes an
ask_humantool via an embedded MCP server; dispatched Claude Code tasks can pause mid-flight, route clarifying questions back to your Discord channel, and resume when you reply - π Secret safety β refuses to store API-key-shaped content in memory; code-level pattern guard plus a SOUL-level rule
- π Structured observability β pino JSON logs queryable via
jq; zod-validated config fails loudly at startup instead of crashing deep in the daemon - π« Self-modification β reads and edits its own SOUL, HEARTBEAT, and config files via the
self_configtool - π₯ Multi-persona β same engine, different personality and toolset per user
Discord β Telegram β Signal β CLI β Webhooks
ββββββ¬ββββββ
β
βββββββββΌβββββββββ
β Gateway β
β β
β routes to β β
ββββββββββββββββββ
β
ββββββββββΌβββββββββ
β in-repo loop β βββ Agent loop
β β Tool execution
β β RRF-fused recall
ββββββββββ¬βββββββββ
β tools
βββββββββ¬ββββββββΌβββββββββ¬βββββββββββ¬βββββββββββ
β β β β β β
Memory Web Browser Claude Self-config Report
(SQLite (Brave) (agent- Code (reads own ...
+ vec) browser) dispatch files)
β
ββββββββββββββΌβββββββββββββ
β Claude Code (subprocess)β
β β
β connects back via MCP β
ββββββββββββββ¬βββββββββββββ
β
ββββββββββββββΌβββββββββββββ
β MCP HTTP Server β
β (in-process, β
β port 3848) β
β β
β tool: ask_human β
ββββββββββββββ¬βββββββββββββ
β routes back through
βΌ
ββββββββββββββββββββββ
β ask-human-state β
β β
β β Gateway.notify() β
β β Discord delivery β
β β User replies β
β β Promise resolves β
ββββββββββββββββββββββ
One process per persona. One SQLite database for memory. Config in ~/.mame/. The MCP server is embedded in the same process as the gateway and agent loop, so all three can share state.
Every memory is stored in three places:
memoriestable β canonical rows with content, category, project, importance,created_at,last_accessed,access_countmemories_ftsβ FTS5 virtual table, auto-synced via triggers, indexed by BM25memories_vecβ sqlite-vec virtual table with 384-dim embeddings fromXenova/all-MiniLM-L6-v2(local, ~23MB model file, no API calls)
On remember(): the row is inserted, FTS5 triggers fire, and the content is embedded and stored in memories_vec with a matching rowid.
On recall(query):
- FTS5 keyword search (top 20 candidates, BM25 ranked)
- In parallel, the query is embedded and sqlite-vec returns top 20 nearest neighbors by cosine similarity
- Both result sets are fused via Reciprocal Rank Fusion (k=60) β memories matching both layers get strictly higher scores without needing to normalize BM25 vs cosine scales
- Recency and access count are applied as secondary tiebreakers
The result: "lumbar discomfort" finds the "back pain" memory even though they share zero tokens. Natural-language queries like "what do you remember about Tokyo weather or Bitcoin I asked about yesterday" surface both relevant memories without needing exact keyword matches.
Timestamps are surfaced to the model in full timezone-explicit form:
[2026-04-08T12:12:44+09:00 JST (3 hours ago)] User mentioned their back pain started last Tuesday
The model reasons about recency, timezone offsets for scheduling, and cross-timezone math (JST β EST) from that anchor instead of guessing.
Mame runs an embedded MCP HTTP server on localhost:3848 that exposes a single tool:
ask_human(question: string) β string
When Mame dispatches a coding task via the claude_code tool, the handler:
- Registers a task in
ask-human-statetagged with the dispatching Discord channel - Spawns
claude -p <task>as a subprocess withMAME_MCP_URLin its env - Waits for the subprocess to exit
Claude Code's MCP client (configured via ~/.claude.json) sees Mame's server, initializes a session, and discovers the ask_human tool. When Claude Code hits an ambiguous decision mid-task, it calls ask_human("should I ..."). Mame's MCP handler:
- Looks at the current active task from
ask-human-state - Creates a pending question, registers the resolve callback
- Sends the question to the user's Discord channel via
gateway.notify() - Returns a Promise that hangs
When the user replies in Discord, the gateway's messageCreate handler checks hasPendingQuestion(), routes the message to provideAnswer() instead of think(), and sends a one-line ack (π¨ Forwarded your answer to the running task). The Promise resolves, Claude Code's tool call returns, and the subprocess resumes from where it paused.
Result: Mame becomes an orchestrator. A Discord dispatch like "use claude_code to update all the product descriptions for the spring collection" can turn into Claude Code pausing 5 minutes in to ask "12 of 47 are last year's leftover stock β include them or skip?", Mame delivers the question to you, you reply, Claude Code resumes, and the final result lands back in Discord.
On the machine running Mame, register the MCP server with Claude Code once:
claude mcp add --transport http mame http://localhost:3848/mcp --scope userThen pre-authorize the tools you want spawned Claude Code processes to use without interactive prompts, in ~/.claude/settings.json:
{
"mcpServers": {
"mame": {
"type": "http",
"url": "http://localhost:3848/mcp"
}
},
"permissions": {
"allow": [
"mcp__mame__ask_human",
"Write",
"Edit",
"Bash"
]
}
}- Node.js 22+
- Linux with systemd for the recommended deployment; macOS works for dev
- Claude Code (installed via
npm install -g @anthropic-ai/claude-code) for theclaude_codetool β optional, but you lose the coding orchestration without it - agent-browser for the
browsertool β optional - API key for OpenRouter (
OPENROUTER_API_KEY), orMAME_LLM_BASE_URL+MAME_LLM_API_KEYfor any OpenAI-compatible endpoint. Anthropic and Google models are reached asopenrouter/anthropic/...andopenrouter/google/....
npx mame initRuns a conversational onboarding interview (English or Japanese) and generates config files from your answers β persona, SOUL, HEARTBEAT, and optionally Discord / Signal / webhook settings. Closed-choice questions (language, personality, platform, tools, wellness) are numbered so you can reply with just 1 or 1,3,5; tools also accept recommended (memory, web_search, web_fetch, self_config) or all.
npx mame startStarts the daemon. Message it from your configured channel.
For a proper production deploy on Linux, use the systemd unit file and systemd-creds to protect your master key β see deployment below.
All state lives in ~/.mame/:
~/.mame/
βββ SOUL-[name].md # Agent personality (loaded fresh every turn)
βββ HEARTBEAT.md # Scheduled tasks in natural language
βββ config.yml # Runtime configuration (zod-validated)
βββ personas/
β βββ [name].yml # Per-persona tools, models, channels
βββ .vault/
β βββ [project].enc # Encrypted secrets (AES-256-GCM)
β # Master key via MAME_MASTER_KEY env,
β # or systemd-creds in production
βββ browsers/
β βββ [site]/ # Persistent browser profiles per site
βββ memory.db # SQLite with FTS5 + sqlite-vec
βββ reports/ # Generated markdown reports
Config validation is enforced at startup via zod schemas β a malformed config.yml or persona.yml fails loudly with path-scoped errors instead of crashing deep in the daemon:
Invalid config.yml at ~/.mame/config.yml:
- webhook.port: Expected number, received string
Fix the fields above and restart.
Same engine, different configs. One machine, multiple agents with distinct personalities, tools, languages, and messaging channels.
# ~/.mame/personas/default.yml
name: "Mame"
soul: "SOUL-Mame.md"
language: "en"
models:
default: openrouter/qwen/qwen3.5-plus-02-15
heartbeat: openrouter/google/gemini-2.5-flash
tools:
- browser
- web_search
- web_fetch
- memory
- write_report
- self_config
- claude_code
discord:
channelMap:
"1234567890": null
telegram:
userIds: [123456789] # numeric Telegram user IDs; quoted strings also acceptedBot token lives in the vault as global/TELEGRAM_BOT_TOKEN (create a bot with @BotFather). Config:
# ~/.mame/config.yml
telegram:
enabled: true
defaultChatId: "123456789"
# chatMap keys must be private chat IDs (positive; same as the user id).
# Group chats cannot answer ask_human / approval prompts β inbound
# messages where chat.type != "private" are dropped.
# chatMap:
# "123456789": my-project# ~/.mame/personas/ayaka.yml β Japanese personal assistant via Signal
name: "Siri-chan"
soul: "SOUL-Siri.md"
language: "ja"
models:
default: openrouter/qwen/qwen3.5-plus-02-15
tools:
- memory
- web_search
- web_fetch
signal:
userNumbers:
- "+819012345678"OpenRouter-first. There is no client-side model catalog β Mame sends whatever id you name and surfaces the provider's error verbatim if it's rejected. New models work the day OpenRouter ships them.
models:
default: openrouter/qwen/qwen3.5-plus-02-15 # Primary conversation model
heartbeat: openrouter/google/gemini-2.5-flash # Scheduled tasks
complex: openrouter/anthropic/claude-opus-4-5 # (Optional) escalationFormat: openrouter/<provider>/<model> is sent to https://openrouter.ai/api/v1 with OPENROUTER_API_KEY; the model id on the wire is <provider>/<model> (e.g. openrouter/x-ai/grok-4.5 β x-ai/grok-4.5).
Direct google/* and anthropic/* ids were removed in 0.4.0. Use openrouter/google/... and openrouter/anthropic/..., or point any OpenAI-compatible endpoint at Mame with MAME_LLM_BASE_URL + MAME_LLM_API_KEY (the full model string is sent as-is).
thinkingLevel (off / low / medium / high) maps to OpenRouter's reasoning.effort. "off" omits the field. There is no catalog flag to auto-enable reasoning β models that require it (MiniMax M2.7, DeepSeek R1, etc.) need thinkingLevel set explicitly on the persona.
API keys come from the vault and are loaded into process.env at daemon startup.
Natural-language scheduling defined in ~/.mame/HEARTBEAT.md. Parsed once by the LLM using structured output (a forced submit_schedule tool with a TypeBox schema) so the model can't hallucinate entries. Uses croner for the actual cron firing.
## Every morning at 7:30 β DAILY REPORT (always send)
- Weather for Kamakura
- Day of the week and date
- Daily theme (nature/wellness/good news/wisdom/food/culture/reflection)
## Every evening at 18:30 β DAILY REPORT (always send)
- Tomorrow's weather outlook
- Tomorrow's agendaMame reloads the schedule whenever you save HEARTBEAT.md β no daemon restart needed.
mame init # First-time setup
mame init --persona # Add a new persona
mame chat [--persona NAME] # Interactive CLI
mame heartbeat run [--persona] # Force-run the scheduled entries
mame memory search [query] # Search memories (FTS5 + vec hybrid)
mame memory list [--project] # List memories
mame memory stats # Category/project counts
mame secrets list [project] # List secret keys (not values)
mame secrets set [proj] [key] # Store a secret (interactive)
mame secrets delete [proj] [key]
mame onboard-signal +NUMBER # Start a Signal onboarding for a phone numberPost-cutover to systemd + systemd-creds, interactive CLI commands that need the master key require a small wrapper β see deployment.
The recommended production setup on Linux is:
- systemd service for supervision, auto-restart, auto-start on boot
- systemd-creds to encrypt the
MAME_MASTER_KEYat rest with a TPM-backed or host-key-derived blob β no plaintext credentials on disk
[Unit]
Description=Mame personal agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mame
Group=mame
# Optional: only needed when more than one persona exists. The persona name
# is the filename in ~/.mame/personas/ (onboarding names it after your agent,
# e.g. Mame.yml β MAME_PERSONA=Mame). With exactly one persona file, the
# daemon auto-discovers it and this line can be omitted.
Environment=MAME_PERSONA=default
WorkingDirectory=/opt/mame
ExecStart=/usr/bin/node /opt/mame/dist/index.js
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Master key for the vault β encrypted at rest via systemd-creds
LoadCredentialEncrypted=MAME_MASTER_KEY:/etc/credstore.encrypted/MAME_MASTER_KEY
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=/home/mame/.mame /opt/mame
[Install]
WantedBy=multi-user.targetecho -n "$MAME_MASTER_KEY" | sudo systemd-creds encrypt \
--name=MAME_MASTER_KEY \
- /etc/credstore.encrypted/MAME_MASTER_KEYThe encrypted blob only decrypts on the same host. On boot, systemd materializes the credential into a tmpfs at $CREDENTIALS_DIRECTORY/MAME_MASTER_KEY, which src/init-credentials.ts reads into process.env before the vault constructs.
After encryption, remove the plaintext MAME_MASTER_KEY export from ~/.bashrc.
For running CLI commands from your shell after the systemd cutover, add to ~/.bashrc:
mame-cli() {
MAME_MASTER_KEY="$(sudo systemd-creds decrypt /etc/credstore.encrypted/MAME_MASTER_KEY -)" \
node ~/Projects/mame/dist/cli.js "$@"
}The key is decrypted on demand for each invocation and lives only for the duration of the command.
Logs go to stdout as structured JSON via pino. Under systemd, they land in journalctl -u mame with ISO 8601 timestamps and component tags:
# Human-readable stream
sudo journalctl -u mame -o cat -f
# jq queries
sudo journalctl -u mame -o cat | jq 'select(.component == "heartbeat")'
sudo journalctl -u mame -o cat | jq 'select(.level >= 40)' # warnings and up
sudo journalctl -u mame -o cat | jq -r '[.time, .component, .msg] | @tsv'Health endpoint for the MCP server and active-task state:
curl http://localhost:3848/health
# {"ok":true,"activeSessions":0,"activeTask":null}- Vault β secrets stored AES-256-GCM encrypted in
~/.mame/.vault/; decrypted intoprocess.envat daemon startup using a master key that lives only in the systemd credential tmpfs during service runtime (never on disk in plaintext post-cutover) - Secret detection β
memory.remember()refuses to store content matching known API key patterns (OpenAIsk-, Anthropic, BraveBSA, GitHubghp_, GoogleAIza, Slackxoxb-, AWSAKIA, JWT, 64-char hex, long base64). Plus a SOUL-level rule instructing the agent to refuse even without the code guard. - Tool approval (enforced in code) β tools flagged
requiresApproval(bash,self_modify) never execute until you approve them: the tool call pauses, an "Approval needed" prompt with the tool input arrives in your Discord/Telegram/Signal channel, and only a yes-shaped reply within 5 minutes lets it run. Denials, timeouts, and channels with no human (heartbeat, webhook, CLI) all fail closed. The SOUL-level confirmation rule still applies on top. - Webhook auth β
/webhook/*requires anX-Webhook-Secretheader (timing-safe compare) matching the vault keyglobal/WEBHOOK_SECRET; with no secret configured, webhook ingestion is disabled entirely. The webhook server binds to127.0.0.1by default β setMAME_BIND_HOSTexplicitly (ideally behind a trusted proxy) to expose it. - Localhost-only MCP server β the embedded MCP server binds to
127.0.0.1and is never exposed to the network - Pinned answer routing β
ask_humanquestions and approval prompts are pinned to the exact Discord channel / Telegram chat / Signal sender that dispatched the task, so a message in another channel can't answer them - Systemd sandboxing β
NoNewPrivileges,ProtectSystem=full,ProtectHome=read-only,ReadWritePathsscoped to only~/.mameand the project directory
- @sinclair/typebox β JSON Schema for structured output (heartbeat schedule parse, onboarding tools)
- @modelcontextprotocol/sdk β MCP HTTP server + client
- sqlite-vec β vector similarity search as a SQLite extension
- @xenova/transformers β local embedding model (~23MB, no API calls)
- better-sqlite3 β synchronous SQLite with FTS5
- croner β TypeScript-native cron
- pino β structured JSON logging
- znv + zod β runtime config and env var validation
- p-retry β exponential backoff for transient tool errors
- discord.js β Discord gateway
- @octokit/rest β GitHub API
- express β webhook server + MCP HTTP server
- yaml β config parsing
- signal-cli (system package) β Signal channel via daemon mode
MIT
γΎγ β diligent, hardworking, small but mighty.