Skip to content

Latest commit

Β 

History

136 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Mame まめ

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.

What it is

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.

What it does

  • πŸ’¬ 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_human tool 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_config tool
  • πŸ‘₯ Multi-persona β€” same engine, different personality and toolset per user

Architecture

      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.

Hybrid memory

Every memory is stored in three places:

  1. memories table β€” canonical rows with content, category, project, importance, created_at, last_accessed, access_count
  2. memories_fts β€” FTS5 virtual table, auto-synced via triggers, indexed by BM25
  3. memories_vec β€” sqlite-vec virtual table with 384-dim embeddings from Xenova/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):

  1. FTS5 keyword search (top 20 candidates, BM25 ranked)
  2. In parallel, the query is embedded and sqlite-vec returns top 20 nearest neighbors by cosine similarity
  3. 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
  4. 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.

MCP orchestration

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:

  1. Registers a task in ask-human-state tagged with the dispatching Discord channel
  2. Spawns claude -p <task> as a subprocess with MAME_MCP_URL in its env
  3. 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:

  1. Looks at the current active task from ask-human-state
  2. Creates a pending question, registers the resolve callback
  3. Sends the question to the user's Discord channel via gateway.notify()
  4. 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.

Setup for Claude Code integration

On the machine running Mame, register the MCP server with Claude Code once:

claude mcp add --transport http mame http://localhost:3848/mcp --scope user

Then 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"
    ]
  }
}

Requirements

  • 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 the claude_code tool β€” optional, but you lose the coding orchestration without it
  • agent-browser for the browser tool β€” optional
  • API key for OpenRouter (OPENROUTER_API_KEY), or MAME_LLM_BASE_URL + MAME_LLM_API_KEY for any OpenAI-compatible endpoint. Anthropic and Google models are reached as openrouter/anthropic/... and openrouter/google/....

Quick start

npx mame init

Runs 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 start

Starts 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.

Configuration

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.

Multi-persona

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 accepted

Bot 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"

Model routing

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) escalation

Format: 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.

Heartbeat scheduler

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 agenda

Mame reloads the schedule whenever you save HEARTBEAT.md β€” no daemon restart needed.

CLI

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 number

Post-cutover to systemd + systemd-creds, interactive CLI commands that need the master key require a small wrapper β€” see deployment.

Deployment

The recommended production setup on Linux is:

  1. systemd service for supervision, auto-restart, auto-start on boot
  2. systemd-creds to encrypt the MAME_MASTER_KEY at rest with a TPM-backed or host-key-derived blob β€” no plaintext credentials on disk

systemd unit (/etc/systemd/system/mame.service)

[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.target

Encrypting the master key

echo -n "$MAME_MASTER_KEY" | sudo systemd-creds encrypt \
  --name=MAME_MASTER_KEY \
  - /etc/credstore.encrypted/MAME_MASTER_KEY

The 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.

Interactive CLI wrapper

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.

Observability

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}

Security model

  • Vault β€” secrets stored AES-256-GCM encrypted in ~/.mame/.vault/; decrypted into process.env at 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 (OpenAI sk-, Anthropic, Brave BSA, GitHub ghp_, Google AIza, Slack xoxb-, AWS AKIA, 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 an X-Webhook-Secret header (timing-safe compare) matching the vault key global/WEBHOOK_SECRET; with no secret configured, webhook ingestion is disabled entirely. The webhook server binds to 127.0.0.1 by default β€” set MAME_BIND_HOST explicitly (ideally behind a trusted proxy) to expose it.
  • Localhost-only MCP server β€” the embedded MCP server binds to 127.0.0.1 and is never exposed to the network
  • Pinned answer routing β€” ask_human questions 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, ReadWritePaths scoped to only ~/.mame and the project directory

Dependencies

  • @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

Documentation

License

MIT


まめ β€” diligent, hardworking, small but mighty.

About

A minimal persistent AI agent that lives on your machine, can use Claude Code, talks to you on Discord or Signal, and gets smarter over time.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages