【#9】 OpenClaw を読み解く — 記憶のスロットの限定、二重記憶しないための仕組み
本記事のコード参照は OpenClaw
mainのcee2aca409(version2026.6.10)時点。行番号は更新でズレ得ます。
連載「OpenClaw を読み解く」
#08 のセッションは「ひとつの会話」の記憶でした。今回は会話を越えて持続する長期メモリです。OpenClaw のメモリは複数のプラグインで実装されますが、「同時に有効化できるのは1つだけ」という独特の排他スロットを持ちます。src/memory/、packages/memory-host-sdk、そして extensions/memory-* を読み解きます。
ただ一つの席 — 記憶が二重化しないための排他則
VISION.md:84 が宣言します。
Memory is a special plugin slot where only one memory plugin can be active at a time.
複数のメモリバックエンドが同時に走ると、記憶の所在が二重化して破綻します。だから排他スロット。設定では plugins.slots.memory(src/config/types.plugins.ts:43)で「どのプラグインがメモリスロットを所有するか」を選びます("none" で無効化)。
強制は src/plugins/memory-runtime.ts:11 の resolveMemoryRuntimePluginIds() です。
function resolveMemoryRuntimePluginIds(config): string[] {
const plugins = normalizePluginsConfig(config.plugins);
const memorySlot = plugins.slots.memory;
if (!plugins.enabled || typeof memorySlot !== "string" || memorySlot.trim().length === 0)
return [];
const pluginId = memorySlot.trim();
if (plugins.deny.includes(pluginId) || plugins.entries[pluginId]?.enabled === false)
return [];
return [pluginId]; // 必ず1つだけ
}
返り値は最大でも要素1つの配列。createPluginRegistry(#04)の registerMemoryCapability(src/plugins/types.ts:2882)が、この排他 capability を登録します。
記憶への問いかけ方 — MemorySearchManager という契約
メモリプラグインが実装すべき契約は MemorySearchManager(packages/memory-host-sdk/src/host/types.ts:144)です。
export interface MemorySearchManager {
search(query: string, opts?: {
maxResults?: number; minScore?: number; sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
sources?: MemorySource[]; signal?: AbortSignal;
}): Promise<MemorySearchResult[]>;
readFile(params: { relPath: string; from?: number; lines?: number }): Promise<MemoryReadResult>;
status(): MemoryProviderStatus;
sync?(params?: { reason?: string; force?: boolean; sessionFiles?: string[] }): Promise<void>;
probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>;
probeVectorAvailability(): Promise<boolean>;
close?(): Promise<void>;
}
search(検索)、readFile(読み取り)、status(状態)が必須。sync(同期)や埋め込み可用性のプローブはオプション。コアはこの契約だけを知り、背後が markdown ファイルか LanceDB かを問いません——#04 の「capability 駆動」がメモリでも貫かれています。capability 自体は MemoryPluginCapability(src/plugins/memory-state.ts:145)で、promptBuilder / runtime(MemorySearchManager を包む)/ flushPlanResolver / publicArtifacts を持ちます。
四つの記憶のかたち — 役割の分担
スロットを所有できるのは memory-core か memory-lancedb。wiki と active-memory はそれを補完する非排他プラグインです。
| プラグイン | 役割 | 特徴 |
|---|---|---|
| memory-core | 組み込みのファイルベース長期メモリ |
MEMORY.md + memory/*.md、セッション索引、FTS+ベクトルのハイブリッド検索(QMD)、"dreaming"(light/REM/deep)フェーズ |
| memory-lancedb | ベクトル特化の排他メモリ | LanceDB ベクトルストア、OpenAI/Anthropic 埋め込み、毎メッセージ自動 recall、自動 capture、カテゴリ別重要度スコア |
| memory-wiki | 永続ナレッジ vault(別スロット) | Obsidian 互換 markdown、決定論的索引とバックリンク、構造化クレーム+エビデンス、wiki_search/wiki_get
|
| active-memory | 返信前のブロッキング recall(非排他) | 返信前にサブエージェントを走らせ、限定的なメモリ文脈を注入 |
extensions/memory-wiki/README.md が立ち位置を明言します。「This plugin is separate from the active memory plugin. The active memory plugin still handles recall, promotion, and dreaming.」wiki はスロットを取らず、active なメモリプラグインを補う supplement です。
エージェントに開かれた窓 — 記憶を呼ぶツール
メモリは ツールとしてエージェントに公開されます。スロットに何が乗っているかで注入されるツールが変わります。
-
memory-core:
memory_search/memory_get -
memory-lancedb:
memory_recall/memory_store(排他) -
memory-wiki:
wiki_search/wiki_get(非排他の追加) -
active-memory: ブロッキングサブエージェントが使うツールを設定(既定は
memory_search+memory_get、lancedb ならmemory_recall)
memory_search の description が運用思想を物語ります(extensions/memory-core/src/tools.ts:393)。
return createMemoryTool({
name: "memory_search",
description: "Mandatory recall step: semantically search MEMORY.md + memory/*.md ... before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` ...",
parameters: MemorySearchSchema,
});
「過去の作業・決定・日付・人・好み・TODO を答える前の必須の recall ステップ」。corpus パラメータで wiki やセッションも横断検索できます。active-memory はこのツールを「返信前に必ず走るサブエージェント」で包み、文脈を先読みさせる仕掛けです(extensions/active-memory/index.ts:61)。
ルート AGENTS.md のメモリ注意書きも実務的です。「Memory wiki prompt digest stays tiny; prefer wiki_search / wiki_get; verify contact data before use; source-class provenance for generated people facts.」wiki のプロンプトダイジェストは小さく保ち、生成された人物事実は出所を明示し使用前に検証せよ、と。
意味を数に変える — ローカルとリモートの埋め込み
ベクトル検索には埋め込みが要ります。OpenClaw は両対応です。
-
ローカル埋め込み(
packages/memory-host-sdk/src/host/embeddings.ts:69): node-llama-cpp + GGUF モデル。ワーカースレッドまたはインプロセスで遅延ロード。local.modelPath等で設定。 -
リモート埋め込み: memory-lancedb が OpenAI / Anthropic を使う。
getMemoryEmbeddingProvider(providerId, cfg)でプロバイダを解決。
可用性は probeEmbeddingAvailability() で非同期に確認し、使えなければツールは disabled: true を返し、プローブ成功後にベクトル検索を再開します。LanceDB の recall はこうです(extensions/memory-lancedb/index.ts:1537)。
let vector: number[];
try {
vector = await embeddings.embed(normalizeRecallQuery(query, currentCfg.recallMaxChars),
{ timeoutMs: DEFAULT_TOOL_RECALL_TIMEOUT_MS });
} catch (error) {
throw new MemoryRecallEmbeddingError(error);
}
return await db.search(vector, limit + DEFAULT_TOOL_RECALL_OVERFETCH_EXTRA, 0.1);
クエリを埋め込みベクトルに変換し、LanceDB で近傍検索。埋め込みが落ちても、MemoryRecallEmbeddingError として明示的に扱われます。
待ちすぎない約束 — 必ず締め切りを切る
メモリ検索は外部依存(埋め込みプロバイダ・ベクトルストア)を含むため、デッドラインが厳格です(extensions/memory-core/src/tools.ts:126)。
async function runMemorySearchToolWithDeadline<T>(params): Promise<...> {
const controller = new AbortController();
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => {
resolve("timeout");
controller.abort(new Error(`memory_search timed out after ${...}s`));
}, params.timeoutMs);
});
const task = params.run(controller.signal);
return await Promise.race([task, timeoutPromise]);
}
Promise.race でタイムアウトとタスクを競わせ、超過すれば AbortController で中断。メモリ検索が遅いせいで返信全体が止まる、という事故を防ぎます。active-memory はさらに「preflight に 1500ms、abort 収束に 1500ms を予約」といった細かいバジェット管理を持ちます。
まとめ — 一つの席と、背後を問わぬ契約
- メモリは 排他スロット: 同時にアクティブなのは1プラグインだけ(
resolveMemoryRuntimePluginIdsが強制)。 - 契約は
MemorySearchManager(search / readFile / status が必須)で、コアは背後の実装を問わない。 - memory-core(ファイル+QMD)/ lancedb(ベクトル排他)がスロットを取り、wiki / active-memory が非排他で補完。
- メモリは ツールとしてエージェントに公開され、active-memory は返信前ブロッキング recall を担う。
- 埋め込みはローカル/リモート両対応、検索は必ずタイムアウトを切る。
次回予告 — すべての土台、状態とストレージへ
#10 は、これらすべての土台、状態・ストレージ層です。「SQLite only」「Kysely で生 SQL を書かない」「マイグレーションは database-first」という #01 で見た原則が、二層 DB(共有 / エージェント単位)と doctor マイグレーションとしてどう実装されているのかを読み解きます。
