This document describes the advanced memory management features.
Memories decay over time according to the FSRS v6 power-law forgetting curve, validated against real human recall data via Anki benchmarks:
R(t) = (1 + 19 · t / S)^(-0.5)
| Variable | Description |
|---|---|
R |
Retention (0.0–1.0) |
t |
Days since last access (in hours internally) |
S |
Stability in days (grows on each recall) |
decay_exponent |
0.5 (canonical FSRS v6) |
Effective search score: strength = importance × R(t)
Why power-law? FSRS starts with faster initial decay than Ebbinghaus but maintains a much heavier tail — memories survive far longer in the "barely remembered" zone. This better models human long-term memory.
-
When a memory is created, its initial stability is set based on emotional charge:
emotion_intensity > 0.7→ S = 10 (emotionally vivid, hard to forget)emotion_intensity > 0.5→ S = 5- otherwise → S = 1
-
Every 6 hours, a background worker recomputes
strengthfor all memories. -
When a memory is recalled (returned by search), its stability is multiplied by 1.5 (capped at 365 days), effectively resetting the decay clock. This models the "spacing effect."
-
Search ranking uses
strengthinstead of rawimportancewhenimportance_weight > 0.
| Table | Column | Description |
|---|---|---|
memories |
importance |
Immutable score set at creation |
memory_strength |
strength |
Current effective score (decayed) |
memory_strength |
stability |
Current stability factor |
memory_strength |
last_decay_at |
Last time the decay worker ran |
User information fields (name, nickname, preferred_address) are stored with full temporal history. Instead of overwriting values, each change is recorded with valid_from / valid_until timestamps.
CREATE TABLE user_state_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
persona TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
valid_from TEXT NOT NULL,
valid_until TEXT DEFAULT NULL, -- NULL means "currently valid"
created_at TEXT NOT NULL
);# Get current state
state = get_current_user_state("herta")
# → {"name": "らうらう", "nickname": "らう", ...}
# Full history for a key
history = get_user_state_history("herta", "name")
# → [{"value": "らうらう", "valid_from": "...", "valid_until": None, "is_current": True}, ...]When update_context(user_info={"name": "..."}) is called, the current record is invalidated and a new one is inserted atomically.
Inspired by Letta (MemGPT), memory blocks are structured segments
stored in the memory_blocks table (block_name 主キー / content / block_type /
max_tokens / priority / metadata)。
session_begin() /
get_context() の出力にも自動注入されない。旧 memory(operation="block_write" ...)
構文は存在しない(memory(operation=...) ディスパッチャ自体が廃止済み)。
| 操作 | 経路 |
|---|---|
| 一覧 | GET /api/blocks/{persona} → {"persona": ..., "blocks": [...]} |
| 書き込み / 更新 | POST /api/blocks/{persona}(block_name + content 必須。block_type / max_tokens / priority 任意)→ {"ok": true, "block_name": ...} |
| 削除 | DELETE /api/blocks/{persona}/{block_name} → {"ok": true} |
| 閲覧(UI) | ペルソナダッシュボード(nous/api/http/routers/persona/persona_dashboard.py が blocks を同梱) |
| 内部 API | memory_service.list_blocks() / write_block() / delete_block()(memory_blocks テーブル、priority 降順) |
LLM が使うペルソナ状態は update_context()(bi-temporal な履歴付き)と
session_begin() の出力であり、ブロックとは別系統。エージェント向けのツール一覧は
LLM Usage Guide を参照。
新しく記憶が作成されたとき、意味的に類似する既存の記憶を検出して自動的に更新する仕組みです。A-MEM の「各事実が既存の理解を継続的に洗練させる」パターンを実装しています。
MemoryService.create_memory()が完了した後、asyncio.create_taskで非同期・非ブロッキング実行- 意味検索(semantic search,
similarity ≥ 0.8)で類似する既存記憶を最大3件検出 - 検出された各記憶に対して:
- アクセス更新:
access_count増加、last_accessed更新 - Hebbian リンク強化: 新記憶と既存記憶の間に
SEMANTICタイプのリンクを作成・強化 summary_ref設定: 既存記憶に関連する新しい情報があることをマーク
- アクセス更新:
- 非同期・ノンブロッキング:
asyncio.create_task()で実行され、ユーザー応答を遅延させない - ベストエフォート: 例外はすべて
try/exceptで吸収され、メインの記憶作成フローには影響しない - 条件付き実行: コンテンツ長が30文字未満の場合はスキップ(ノイズ記憶を進化させない)
保存時の重複排除(similarity > 0.85)は進化より前に実行されます。つまり、重複と判定されるほど近い記憶は保存自体がスキップされ、進化の対象にはなりません。進化が扱うのは 0.8 ≤ similarity ≤ 0.85 の「関連はしているが重複ではない」記憶です。
CompressStep の第4段階として、機械的圧縮(行数トリム・古いツール結果クリア・メッセージ切り詰め)だけではコンテキストが予算超過している場合に、LLM を使って古い会話を要約します。
- トリガー条件: 以下のすべてを満たす場合のみ実行
context_use_llm_summary = True(デフォルト:True)- APIキーが設定されている
- 会話ターン数が6以上(十分な履歴がある場合のみ価値がある)
- Stage 1-3 の機械圧縮後もコンテキストが予算超過
- 最も古いターン(直近の
keep_recentターンを除く全メッセージ)を抽出 - LLM に要約プロンプト(日本語)を送信し、300文字以内の要約を生成
- 古いメッセージを要約メッセージ
[過去の会話要約] ...に置き換え
| 設定キー | デフォルト | 説明 |
|---|---|---|
context_use_llm_summary |
True |
Stage 4 の有効/無効 |
チャット設定パネルからも切り替え可能。
LLM 呼び出しが失敗した場合(ネットワークエラー、レート制限など)は、機械的圧縮の結果をそのまま使用します。要約失敗がユーザー体験に影響することはありません。
記憶に有効期間(validity window)を導入し、時間的に矛盾する情報を「上書き」せず「共存」させます。Zep/Graphiti の bi-temporal model に着想。
@dataclass
class Memory:
valid_from: datetime | None = None # 記憶が有効になった日時
valid_until: datetime | None = None # None = 現在も有効| 値 | 意味 |
|---|---|
valid_from = None, valid_until = None |
時間制限なしで常に有効(後方互換、デフォルト) |
valid_from = 2024-03, valid_until = 2025-06 |
この期間のみ有効 |
valid_until = now() に設定 |
無効化(論理削除、矛盾する新しい記憶で上書き時) |
新しい記憶が既存の記憶と矛盾する場合(semantic similarity ≥ threshold かつ 3-op 分類で CONTRADICTORY)、古い記憶の valid_until が自動的に新しい記憶の created_at に設定されます。非同期(asyncio.create_task)かつ best-effort。
SearchQuery.valid_at を指定することで、特定の時点で有効な記憶のみを取得可能:
SearchQuery(text="...", valid_at=datetime(2024, 6, 15))
# → valid_from <= 2024-06-15 AND (valid_until IS NULL OR valid_until > 2024-06-15)valid_at=None(デフォルト)では全件返却。後方互換を完全維持。
v037: ALTER TABLE memories ADD COLUMN valid_from TEXT; ADD COLUMN valid_until TEXT;
矛盾を「検知するだけ」から「分類して適切に処理する」に拡張。HiMem の reconsolidation パターンに準拠。
| 分類 | 動作 |
|---|---|
| INDEPENDENT | 新記憶と既存記憶は無関係 → 両方ともそのまま保存 |
| EXTENDABLE | 新記憶が既存記憶を拡張 → 既存記憶の metadata(tags, importance 等)を更新 |
| CONTRADICTORY | 新記憶が既存記憶と明確に矛盾 → 既存記憶を tombstone(論理削除)+ 新記憶を保存 |
MemoryEnricher.classify_contradiction(): LLM 1回呼出で分類MemoryService._evolve_related_memories(): semantic similarity check → 分類 → 処理- 判定プロンプトは日本語。全処理 best-effort(LLM 障害時はスキップ)
既存の ContradictionDetector(ベクトル類似度ベースの検出)は維持。LLM 分類は _evolve_related_memories() 内の進化パイプラインに統合され、より粒度の細かい処理を可能にします。
HiMem の核心: Episode Memory(生の会話ログ)と Note Memory(抽象化された知識)の2階層分離。HiMem の LoCoMo 80.71(Mem0 比 +12pt)は主に Episode Memory の寄与(除去で -11pt)。
| 階層 | Nousでの位置付け | 内容 |
|---|---|---|
| Episode Memory | session_events テーブル |
生の会話ログ。Topic-Aware Event-Surprise Segmentation で分割 |
| Note Memory | memories テーブル |
抽出・抽象化された知識(facts, preferences, profile) |
EpisodeSegmenter が session events を topic shift + surprise scoring で自動分割:
- Topic shift detection: LLM が隣接する turn pair を比較し「トピックが変わったか?」を判定
- Surprise signal: 「この発言は予想外か?」を 0.0-1.0 で評価、閾値 > 0.7 で即時セグメンテーション
- OR ルール: topic shift OR surprise > 0.7 → セグメント境界
- 最小セグメント長: 3 turns 未満は前セグメントにマージ(過剰分割防止)
EpisodeConsolidation が各 Episode から以下をLLM抽出 → memories に保存:
- K_fact: 事実情報
- K_pref: ユーザー好み
- K_profile: プロフィール情報
PrepareStep の検索パイプライン:
- Note Memory(
memories)を最初に検索 - 結果が
memory_preload_count未満の場合、Episode Memory(session_events)を fallback 検索 - 見つかった episode reference を context に追加
| 設定キー | デフォルト | 説明 |
|---|---|---|
episode_consolidation_enabled |
True |
Episode → Note 統合の有効/無効 |
episode_search_enabled |
True |
2-tier fallback 検索の有効/無効 |
全処理 best-effort: セグメンテーション・統合・検索のいずれかが失敗してもメインの会話フローは継続。