mnemo

Hermes-compatible memory / skills / session / prompt layer for AI agents (MoonBit native + JS)

memory
agent
hermes
mcp
session
prompt
llm
moon add mizchi/mnemo@0.2.2
Download zip
Author
Version
0.2.2
License
MIT
Last updated
3 months ago
Downloads
43
README

#mnemo

CI

Hermes Agent 記憶層の MoonBit 移植 — Node.js proto + MCP/pi アダプター付き

411 tests (JS) / 116 tests (native) passing  |  MoonBit 0.1.20260409  |  Node.js 24+

Note: This project is named mnemo (short for mnemonic). The hermes-agent upstream is still called hermes.


#What is this?

Hermes Agent (Nous Research, Python) は AI エージェントのための記憶・スキル・セッション管理層です。mnemo はその中核サブシステム (MemoryStore / SkillsStore / SessionDB / session_search / PromptBuilder) を MoonBit (native + JS 両 backend) に移植したプロジェクトです。

proto/ は Node.js/TypeScript で書かれたリファレンス実装です。goldens (contract fixture) 生成専用で本番には使いません。本番は src/ の MoonBit 実装で、 contract/ に収められた tool schemas・ファイル形式・SQL スキーマ・セキュリティパターン・ golden fixtures が両実装の単一のソース・オブ・トゥルースです。

本番ビルドは moon build --target js --releasedist/src.js が生成されます。 npm 配布時はこれを dist/src.cjs にリネームしてアダプターへ同梱し (adapter package.json"type": "module" の下でも CommonJS として解決させるため)、 アダプターは require() で読み込み globalThis.__mnemo_api 経由で 13 tool を呼び出します。

アダプターは 2 つあります:

  • @mizchi/mnemo-mcp (adapters/mcp-server/) — MCP stdio サーバー。 Claude Code から claude mcp add で登録すると 13 tool が直接使えます。
  • @mizchi/mnemo-pi (adapters/pi-extension/) — pi-mono 拡張。 pi.extensions で読み込むと mnemo:memory_add などが pi から利用できます。

hermes との機能パリティは約 99% です。詳細は docs/PARITY_AUDIT.md


#Quick Start

# Prerequisites: Node.js 24+, pnpm, moon, just pnpm install just test # proto 280 + moonbit 121 + adapters 10 = all green (411) just build-dist # dist/src.js を生成 (adapters が src.cjs として参照)

個別実行:

just test-proto # proto vitest (280 件、うち 135 が goldens-lint) just goldens # proto で golden JSON を再生成 (45 ファイル) just test-mbt # MoonBit inline tests on JS backend (121 件) just test-adapters # MCP server (7) + pi extension (3) adapter tests

just test の実行順は test-proto → goldens → test-mbt → build-dist → test-adapters です。 goldens を更新してから MoonBit 側をテストしないとパリティがずれ、adapters は build-dist した src.js を参照します。

#Native target (cross-platform)

MoonBit native backend 対応済。cross-target コア (memory / skills / session_db / prompt / lock / sqlite_ffi / fts5 / security / frontmatter) を libsqlite3 + C stub 経由で走らせられます。

# Nix 利用者(推奨)— `nix develop` が sqlite + NIX_LDFLAGS を設定 nix develop just test-mbt-native # 116/121 tests green (JS-only Promise concurrency excluded) # 手動設定 # Linux: sudo apt-get install libsqlite3-dev # macOS: xcode-select --install もしくは brew install sqlite just build-native # MoonBit native compile just test-mbt-native # native test runner

Node.js adapter (mnemo_api.mbt) と session_search.mbt の Promise.all 並列化オーケストレーションは targets: [js] で JS 限定。その他の コアライブラリ(memory / skills / session_db / search_core / prompt_builder / security / fts5 / goldens)はすべて native でも走り、 116 / 121 tests が通ります(残りは JS-only な Promise.all concurrency テスト 5 件)。


#Tools (13 tools)

すべての tool schema は contract/tool-schemas/ に JSON Schema として置かれています。

Tool説明
memory_addMEMORY.md / USER.md に新しいエントリを追加する
memory_replace既存エントリをキーで置換する
memory_removeエントリをキーで削除する (複数ヒットは ambiguous_match で拒否)
memory_readMEMORY.md または USER.md の全エントリを読む
skills_listplatform / available_tools / disabled[] でフィルタしたスキル一覧を返す
skill_view単一スキルのフルコンテンツ (frontmatter + 本文) を返す
session_create新しいセッションを作成する
session_appendセッションにメッセージを追記する
session_getセッション ID でセッションとメッセージを取得する
session_listセッション一覧を返す (最新順)
session_searchFTS5 全文検索 + 並列 LLM 要約 (proto / MoonBit 双方で concurrency 3)
session_resolveセッション ID prefix (UUID 先頭 8 字など) からフル ID を解決する
prompt_buildidentity → memory → user → skills → context → SOUL の順で system prompt を構築する (disabled_skills[] 対応)


#Adapter 統合

#Claude Code (MCP)

# ローカル開発: claude mcp add mnemo -- node /path/to/adapters/mcp-server/bin/mnemo-mcp # 公開後 (予定): claude mcp add mnemo -- npx -y @mizchi/mnemo-mcp

Claude Code セッション中に memory_add などのツールが直接呼べるようになります。 詳細: adapters/mcp-server/README.md

#pi-mono (pi extension)

pi.extensions に以下を追加してください:

{ "pi": { "extensions": ["/path/to/adapters/pi-extension/src/index.ts"] } }


#環境変数

変数デフォルト説明
MNEMO_HOME~/.mnemomemories・skills・sessions.db のルートディレクトリ
MNEMO_CONTRACT_DIRadapter から contract/ を参照するときのパス (通常は自動解決)
OPENROUTER_API_KEYLLM 要約を有効化する場合の API キー
MNEMO_SUMMARIZER_API_KEYOPENROUTER_API_KEY の代替
MNEMO_SUMMARIZER_MODEL要約に使う LLM モデル名 (OpenRouter 形式、例 openai/gpt-4o-mini)
MNEMO_SEARCH_CONCURRENCY3session_search の並列 LLM 要約数 (1-5、proto + MoonBit)
MNEMO_LOCK_STALE_TTL_MS30000memories/.writing sentinel が stale と判定されるまでの ms

MNEMO_SUMMARIZER_MODEL + OPENROUTER_API_KEY が設定されている場合、 session_search は OpenRouter 経由で LLM 要約を生成し結果に付与します。 未設定時は raw preview にフォールバックします。


#ドキュメント

ファイル内容
docs/ARCHITECTURE.md3 層構造・サブシステム詳細・MoonBit JS 橋渡し
docs/PARITY_AUDIT.mdhermes 再現率の詳細 (機能ごとの対応状況)
docs/MOONBIT_GOTCHAS.mdMoonBit API 差分・既知の制限
docs/superpowers/specs/設計仕様
docs/superpowers/plans/Phase 別実装計画 (Phase 0–10)。Phase 11-12 は個別 commit としてのみ


#Phase / Tag 一覧

実装は以下のフェーズに分割して進行し、各フェーズ完了時に git tag を打っています。 プロジェクトは途中で ermesmnemo に改名しましたが、 過去のタグ名は歴史の整合性を保つため ermes-phase* のまま残しています。

Tag内容
ermes-phase1-memoryMemoryStore (MEMORY.md / USER.md)
ermes-phase2-skillsSkillsStore (SKILL.md + frontmatter)
ermes-phase3-sessiondbSessionDB (node:sqlite, FTS5)
ermes-phase5-prompt-builderPromptBuilder (identity → SOUL)
ermes-phase6-adaptersMCP server + pi extension adapters
ermes-phase8-asyncasync/await 移行 (moonbitlang/async)
ermes-phase9-safetyセキュリティパターン + FTS5 sanitize
ermes-phase9-completeparity 完成 (safety + LLM + WAL + schema)
ermes-phase10-completemodel guidance + Int64 統一 + session_resolve + LICENSE/docs/CI

Phase 10 以降の強化は個別 commit として main に積まれています:
  • Phase 10 以降の tier-2: skills disabled filter / MoonBit session_search concurrency / stale lock recovery / atomic write cleanup / goldens lint / MCP stdio bugfixes
  • Phase 11 (cross-platform): SessionDB → mizchi/sqlite、fs/env → moonbitlang/x/fs + core/envnative_stub.c で O_EXCL / fsync+rename の C primitives、lock/memory_store/async_utils/hello の backend split
  • Phase 12 (goldens + search_core cross-target): goldens_*.mbt の node:fs FFI 除去、search_core.mbtsession_search.mbt から抽出してピュア helper を cross-target に

git tag | grep -E "^(ermes|mnemo)-"


#Architecture (概略)

contract/ ← single source of truth tool-schemas/ ← 13 tool の JSON Schema file-formats/ ← MEMORY.md / USER.md / SKILL.md エンコード仕様 sql/schema.sql ← SessionDB DDL + migrations/ security/ ← インジェクション検出パターン (13 項目) goldens/ ← proto が生成する fixture JSON (45 ファイル) proto/ ← Node.js/TS throwaway (golden 生成専用) src/ ← MoonBit 本番実装 (native + JS) *.mbt ← cross-target コア (memory / skills / session_db / search_core / prompt / security / fts5 / goldens) *_js.mbt ← JS backend 固有 (lock / memory_store / async_utils / hello) *_native.mbt ← native backend 固有 (同上、extern "C" via native_stub.c) mnemo_api.mbt ← Node.js adapter glue (JS-only) session_search.mbt ← Promise.all LLM orchestration (JS-only) native_stub.c ← POSIX primitives (O_EXCL / stat mtime / fsync+rename) dist/src.js ← moon build --target js --release 出力 npm 配布時は adapters/*/dist/src.cjs にリネームコピー adapters/ mcp-server/ ← @mizchi/mnemo-mcp (Claude Code, JS 専用) pi-extension/ ← @mizchi/mnemo-pi (pi-mono, JS 専用)

詳細は docs/ARCHITECTURE.md を参照してください。


#License

MIT — Copyright (c) 2026 mizchi

#
AddResult

pub enum AddResult {
AddOk(Int, Int)
AddErr(String, String)
}

#
BuildPromptOptions

pub struct BuildPromptOptions {
hermes_home : String
cwd : String
platform : String
model : String
available_tools : Array[String]
disabled_skills : Array[String]
}

#
FtsResult

pub struct FtsResult {
session_id : String
message_id : Int
content : String
}

#
JsRow

pub struct JsRow {
column_index : Map[String, Int]
cells : Array[
SqlValue
]
}

#
JsRows

pub struct JsRows {
column_index : Map[String, Int]
rows : Array[Array[
SqlValue
]]
}

A fully-materialized result set. Each row is an Array[SqlValue] indexed by the order columns appear in the SELECT. The name→index map lets callers continue to write stmt_get_text(rows, row_idx, "col").

#
MemoryStore

pub struct MemoryStore {
root_dir : String
entries_memory : Array[String]
entries_user : Array[String]
snapshot_memory : String
snapshot_user : String
max_memory : Int
max_user : Int
}

#
MemoryStore::add

async fn MemoryStore::add(self : MemoryStore, target : Target, content : String) -> AddResult
Add a new entry. Acquires a per-directory write lock before touching disk.

#
MemoryStore::list

fn MemoryStore::list(self : MemoryStore, target : Target) -> Array[String]
Return a copy of entries for the given target.

#
MemoryStore::load

fn MemoryStore::load(root_dir : String, max_memory? : Int, max_user? : Int) -> MemoryStore
Load (or create) a MemoryStore from the given root directory.

#
MemoryStore::remove

async fn MemoryStore::remove(self : MemoryStore, target : Target, old_substring : String) -> OpResult
If multiple entries match → ambiguous_match (no modification).

#
MemoryStore::replace

async fn MemoryStore::replace(self : MemoryStore, target : Target, old_substring : String, new_content : String) -> OpResult
Replace the first entry containing old_substring with new_content.

#
MemoryStore::snapshot

fn MemoryStore::snapshot(self : MemoryStore, target : Target) -> String
Return the frozen snapshot for the given target (taken at load time).

#
MsgForFormat

pub struct MsgForFormat {
role : String
content : String
tool_name : String
tool_calls : String
}

#
MsgRow

pub struct MsgRow {
id : Int
role : String
content : String
tool_name : String
reasoning_details : String?
codex_reasoning_items : String?
}

#
OpResult

pub enum OpResult {
OpOk(Int, Int)
OpErr(String, String)
}

#
PatternEntry

type PatternEntry

#
ScanResult

pub enum ScanResult {
ScanOk
ScanBlocked(String)
}

#
SearchResult

pub struct SearchResult {
session_id : String
when : String
source : String
model : String
summary : String
}

#
SessionDb

pub struct SessionDb {
db : SqliteDb
db_path : String
write_count : Int
checkpoint_interval : Int
}

#
SessionRow

pub struct SessionRow {
id : String
source : String
user_id : String?
model : String?
system_prompt : String
parent_session_id : String?
started_at : Int64
ended_at : Int64?
end_reason : String?
message_count : Int
input_tokens : Int
output_tokens : Int
cache_read_tokens : Int
cache_write_tokens : Int
title : String?
}

#
SessionSearchResponse

pub struct SessionSearchResponse {
ok : Bool
query : String
results : Array[SearchResult]
count : Int
sessions_searched : Int
}

#
SkillEntry

pub struct SkillEntry {
name : String
skill_name : String
description : String
category : String
content : String
frontmatter : SkillFrontmatter
}

#
SkillFrontmatter

pub struct SkillFrontmatter {
name : String?
description : String?
platforms : Array[String]
fallback_for_tools : Array[String]
fallback_for_toolsets : Array[String]
requires_tools : Array[String]
requires_toolsets : Array[String]
tags : Array[String]
}

Parsed SKILL.md frontmatter (flat fields only for MVP).

#
SkillsStore

pub struct SkillsStore {
skills : Array[SkillEntry]
}

#
SqliteDb

pub struct SqliteDb {
inner :
Database

}

#
SqliteStmt

pub struct SqliteStmt {
inner :
Statement

pending : Array[
SqlValue
]
}

#
Target

pub enum Target {
Memory
User
}

#
ViewResult

pub enum ViewResult {
ViewOk(String, String)
ViewErr(String)
}

#
_row_at

fn _row_at(rows : JsRows, idx : Int) -> JsRow

#
_row_get_int

fn _row_get_int(row : JsRow, col : String) -> Int

#
_row_get_int64

fn _row_get_int64(row : JsRow, col : String) -> Int64

#
_row_get_text

fn _row_get_text(row : JsRow, col : String) -> String

#
_row_is_null

fn _row_is_null(row : JsRow, col : String) -> Bool

#
_rows_length

fn _rows_length(rows : JsRows) -> Int

#
_stmt_all

fn _stmt_all(stmt : SqliteStmt) -> JsRows

#
_stmt_bind_int

fn _stmt_bind_int(stmt : SqliteStmt, i : Int, val : Int) -> Unit

#
_stmt_bind_int64

fn _stmt_bind_int64(stmt : SqliteStmt, i : Int, val : Int64) -> Unit

#
_stmt_bind_null

fn _stmt_bind_null(stmt : SqliteStmt, i : Int) -> Unit

#
_stmt_bind_text

fn _stmt_bind_text(stmt : SqliteStmt, i : Int, val : String) -> Unit

#
_stmt_buffer_init

fn _stmt_buffer_init() -> Unit

No-op kept for source compatibility with the previous FFI. The old JS implementation stashed pending bindings in globalThis.__mnemo_stmt_buf and needed this one-time init; the new wrapper carries pending values inside the SqliteStmt value itself, so initialization is implicit.

#
_stmt_run

fn _stmt_run(stmt : SqliteStmt) -> Unit

#
append_message

async fn append_message(sdb : SessionDb, session_id : String, role : String, timestamp : Int64, content? : String?, tool_name? : String?, token_count? : Int?, reasoning_details? : String?, codex_reasoning_items? : String?) -> Int

#
build_system_prompt

fn build_system_prompt(opts : BuildPromptOptions) -> String

#
close_session_db

fn close_session_db(sdb : SessionDb) -> Unit

#
create_session

async fn create_session(sdb : SessionDb, source : String, system_prompt : String, model? : String?, user_id? : String?, parent_session_id? : String?) -> String

#
db_exec

fn db_exec(db : SqliteDb, sql : String) -> Unit

#
default_lock_stale_ttl_ms

let default_lock_stale_ttl_ms : Int
almost certainly crashed — memory writes are sub-millisecond atomic rename.

#
delim

let delim : String

#
end_session

async fn end_session(sdb : SessionDb, session_id : String, end_reason : String) -> Unit

#
format_conversation

fn format_conversation(messages : Array[MsgForFormat]) -> String

#
get_compression_tip

fn get_compression_tip(sdb : SessionDb, session_id : String) -> String

#
get_messages

fn get_messages(sdb : SessionDb, session_id : String) -> Array[MsgRow]

#
get_session

fn get_session(sdb : SessionDb, session_id : String) -> SessionRow?
Get a single session by ID. Returns None if not found.

#
last_insert_rowid

fn last_insert_rowid(db : SqliteDb) -> Int

#
list_sessions

fn list_sessions(sdb : SessionDb, limit? : Int, offset? : Int, source? : String) -> (Array[SessionRow], Int)
List sessions with optional source filter, limit, and offset. Returns (rows, total).

#
max_session_chars

let max_session_chars : Int

#
open_db

fn open_db(path : String) -> SqliteDb

#
open_session_db

fn open_session_db(db_path : String) -> SessionDb

#
open_session_db_opts

fn open_session_db_opts(db_path : String, checkpoint_interval? : Int) -> SessionDb

#
parse_frontmatter

fn parse_frontmatter(content : String) -> SkillFrontmatter
Parse SKILL.md frontmatter from the full file content.

#
pb_find_context_file

fn pb_find_context_file(cwd : String) -> String

#
pb_load_soul_md

fn pb_load_soul_md(hermes_home : String) -> String

#
pb_truncate_content

fn pb_truncate_content(content : String) -> String

#
prepare

fn prepare(db : SqliteDb, sql : String) -> SqliteStmt

#
regex_spike

fn regex_spike() -> Bool

#
regex_spike_regex

fn regex_spike_regex() -> Bool

#
register_api

fn register_api() -> Unit

#
render_block

fn render_block(target : String, entries : Array[String], max_chars : Int) -> String
Render a memory block as a formatted string. Returns empty string if entries is empty.

#
resolve_session_id

fn resolve_session_id(sdb : SessionDb, prefix : String) -> Result[String, String]
surface the ambiguous message.

#
rows_length

fn rows_length(rows : JsRows) -> Int

#
sanitize_fts5_query

fn sanitize_fts5_query(q : String) -> String
Sanitize a user-supplied string for safe use in FTS5 MATCH expressions. Returns an empty string when input is blank — caller must treat that as "no search" and return an empty result set.

Pure MoonBit implementation — cross-target. Tokenize on any ASCII whitespace (space, tab, newline, carriage return, form feed, vertical tab), wrap each non-empty token in double quotes, and double any inner " to escape it.

#
scan_content

fn scan_content(content : String) -> ScanResult
Scan content for security issues. Returns ScanOk if safe, ScanBlocked(reason) if a pattern matches.

#
search_messages

fn search_messages(sdb : SessionDb, query : String, limit? : Int, role_filter? : String?) -> Array[FtsResult]

async fn session_search(sdb : SessionDb, query : String, limit? : Int, role_filter? : String?) -> SessionSearchResponse

#
skills_store_build_prompt

fn skills_store_build_prompt(store : SkillsStore, available_tools : Array[String]?, platform_hint : String?, disabled? : Array[String]) -> String

#
skills_store_list

fn skills_store_list(store : SkillsStore, available_tools : Array[String]?, platform_hint : String?, available_toolsets? : Array[String]?, disabled? : Array[String]) -> Array[SkillEntry]

#
skills_store_load

fn skills_store_load(skills_dir : String, external_dirs : Array[String]) -> SkillsStore

#
skills_store_view

fn skills_store_view(store : SkillsStore, name : String) -> ViewResult

#
sleep_ms

async fn sleep_ms(ms : Int) -> Unit

Cooperative sleep: yields to the event loop for approximately ms ms.

#
spike_sqlite

fn spike_sqlite() -> Unit

#
stmt_all

fn stmt_all(stmt : SqliteStmt) -> JsRows

#
stmt_bind_int

fn stmt_bind_int(stmt : SqliteStmt, i : Int, val : Int) -> Unit

#
stmt_bind_int64

fn stmt_bind_int64(stmt : SqliteStmt, i : Int, val : Int64) -> Unit

#
stmt_bind_null

fn stmt_bind_null(stmt : SqliteStmt, i : Int) -> Unit

#
stmt_bind_text

fn stmt_bind_text(stmt : SqliteStmt, i : Int, val : String) -> Unit

#
stmt_finalize

fn stmt_finalize(stmt : SqliteStmt) -> Unit

#
stmt_get_int

fn stmt_get_int(rows : JsRows, idx : Int, col : String) -> Int

#
stmt_get_int64

fn stmt_get_int64(rows : JsRows, idx : Int, col : String) -> Int64

#
stmt_get_int64_opt

fn stmt_get_int64_opt(rows : JsRows, idx : Int, col : String) -> Int64?

#
stmt_get_int_opt

fn stmt_get_int_opt(rows : JsRows, idx : Int, col : String) -> Int?

#
stmt_get_text

fn stmt_get_text(rows : JsRows, idx : Int, col : String) -> String

#
stmt_get_text_opt

fn stmt_get_text_opt(rows : JsRows, idx : Int, col : String) -> String?

#
stmt_run

fn stmt_run(stmt : SqliteStmt) -> Unit

#
tool_memory_add

async fn tool_memory_add(json_args : String) -> String

#
tool_memory_read

fn tool_memory_read(json_args : String) -> String

#
tool_memory_remove

async fn tool_memory_remove(json_args : String) -> String

#
tool_memory_replace

async fn tool_memory_replace(json_args : String) -> String

#
tool_prompt_build

fn tool_prompt_build(json_args : String) -> String

#
tool_session_append

async fn tool_session_append(json_args : String) -> String

#
tool_session_create

async fn tool_session_create(json_args : String) -> String

#
tool_session_get

fn tool_session_get(json_args : String) -> String

#
tool_session_list

fn tool_session_list(json_args : String) -> String

#
tool_session_resolve

fn tool_session_resolve(json_args : String) -> String

async fn tool_session_search(json_args : String) -> String

#
tool_skill_view

fn tool_skill_view(json_args : String) -> String

#
tool_skills_list

fn tool_skills_list(json_args : String) -> String

#
truncate_around_matches

fn truncate_around_matches(full_text : String, query : String, max_chars? : Int) -> String

#
tx_begin_immediate

fn tx_begin_immediate(db : SqliteDb) -> Unit

#
tx_commit

fn tx_commit(db : SqliteDb) -> Unit

#
tx_rollback

fn tx_rollback(db : SqliteDb) -> Unit

#
wal_checkpoint_every_n_writes

let wal_checkpoint_every_n_writes : Int

#
with_lock

async fn[T] with_lock(path : String, f : async () -> T) -> T
MoonBit test scheduler sometimes mishandles on repeated runs.

#
with_write_tx

async fn with_write_tx(sdb : SessionDb, f : () -> Unit) -> Unit