Current section
Files
Jump to
Current section
Files
lib/cmdc/plugin/builtin/memory_flush.ex
defmodule CMDC.Plugin.Builtin.MemoryFlush do
@moduledoc """
压缩前持久化关键事实插件。
在 `Compactor` 触发前将即将被压缩丢掉的消息里的关键事实**提取并追加**到
`working_dir/MEMORY.md`,下一次会话由 `MemoryLoader` 自动加载回 system prompt,
实现"长会话不失忆"。
## 工作流程
1. 订阅 `:before_compact` 事件(Agent 在 compact 触发前 emit)
2. 调用 `extract_fn.(messages, opts)`(2 元)或 `extract_fn.(messages, ctx, opts)`
(3 元)从待压缩消息里提取 N 条关键事实。
3 元签名让提取器拿到 `ctx.user_data` 等业务标识,便于按租户路由不同 LLM。
- 默认内置启发式 `default_extract/2`(无 LLM 依赖,完全离线可测)
- 可覆盖为真实 LLM 提取函数(签名:`([Message.t()], keyword()) -> {:ok, [String.t()]} | {:error, any}`)
3. 用 `:crypto.hash(:sha256, fact)` 去重(与已写 facts 比对)
4. 追加到 `working_dir/MEMORY.md` 末尾,格式为 `- fact_text`
5. emit 内部事件 `{:memory_flushed, %{facts, count, session_id}}`
6. emit `{:plugin_event, :memory_flush, payload}`
便于集成方订阅并持久化到数据库 / 长期记忆系统
## 配置
{CMDC.Plugin.Builtin.MemoryFlush,
file: "MEMORY.md", # 持久化目标文件(相对 working_dir)
max_facts_per_flush: 10, # 单次 flush 最多提取多少条
extract_fn: &MyApp.extract/2, # 自定义提取器(返回 {:ok, [fact]} | {:error, _})
dedupe: true # 是否 sha256 去重(默认 true)
}
## 失败降级
`extract_fn` 抛异常 / 返回 `{:error, _}` 时:
- Plugin 记录 `Logger.warning`
- 不阻塞 compact(返回 `:continue`)
- 不写文件
保证"MemoryFlush 出问题也不影响主 Agent Loop"。
## 与 MemoryLoader 闭环
# 第一次会话
opts = [
plugins: [
CMDC.Plugin.Builtin.MemoryLoader,
CMDC.Plugin.Builtin.MemoryFlush
],
working_dir: "/project/path"
]
# 长对话 → 触发 compact → MemoryFlush 追加 facts 到 MEMORY.md
# 第二次会话(新进程)
# MemoryLoader 自动读取 MEMORY.md 并注入 <agent_memory> 到 system prompt
# → Agent 仍然记得第一次的关键事实
## emit 事件协议
- `{:memory_flushed, %{facts: [String.t()], count: pos_integer, session_id: String.t()}}`
- `{:plugin_event, %{kind: :memory_flush, facts, count, session_id, occurred_at, file, v: 1}}`
"""
@behaviour CMDC.Plugin
require Logger
@default_file "MEMORY.md"
@default_max_facts 10
@fact_prefix "- "
@section_header "## Auto-flushed Facts (by CMDC MemoryFlush)"
# ==========================================================================
# Plugin Callbacks
# ==========================================================================
@impl true
def init(opts) do
state = %{
file: Keyword.get(opts, :file, @default_file),
max_facts_per_flush: Keyword.get(opts, :max_facts_per_flush, @default_max_facts),
extract_fn: Keyword.get(opts, :extract_fn, &__MODULE__.default_extract/2),
dedupe: Keyword.get(opts, :dedupe, true),
seen_hashes: MapSet.new()
}
{:ok, state}
end
@impl true
def priority, do: 110
@impl true
def describe,
do: "P2 压缩前持久化:before_compact → LLM/启发式提取 facts → 追加 MEMORY.md → emit :plugin_event"
@impl true
def handle_event({:before_compact, messages}, state, ctx)
when is_list(messages) and messages != [] do
case safe_extract(state.extract_fn, messages, state, ctx) do
{:ok, []} ->
{:continue, state}
{:ok, facts} ->
do_flush(facts, state, ctx)
{:error, reason} ->
Logger.warning("[MemoryFlush] 提取失败: #{inspect(reason)},fallback 到 continue 不阻塞 compact")
{:continue, state}
end
end
def handle_event(_event, state, _ctx), do: {:continue, state}
defp do_flush(facts, state, ctx) do
facts = take_max(facts, state.max_facts_per_flush)
{fresh, seen_hashes} = dedupe(facts, state.seen_hashes, state.dedupe)
case fresh do
[] -> {:continue, %{state | seen_hashes: seen_hashes}}
_ -> write_and_emit(fresh, seen_hashes, state, ctx)
end
end
defp write_and_emit(fresh, seen_hashes, state, ctx) do
path = resolve_path(state.file, ctx.working_dir)
case append_facts(path, fresh) do
:ok ->
Logger.info(
"[MemoryFlush] flushed #{length(fresh)} facts → #{path} session=#{ctx.session_id}"
)
{:emit, build_emit_events(fresh, path, ctx), %{state | seen_hashes: seen_hashes}}
{:error, reason} ->
Logger.warning("[MemoryFlush] 写入 #{path} 失败: #{inspect(reason)},跳过此次 flush")
{:continue, state}
end
end
defp build_emit_events(fresh, path, ctx) do
occurred_at = System.system_time(:millisecond)
count = length(fresh)
[
{:memory_flushed, %{facts: fresh, count: count, session_id: ctx.session_id}},
{:plugin_event,
%{
kind: :memory_flush,
facts: fresh,
count: count,
session_id: ctx.session_id,
occurred_at: occurred_at,
file: path,
v: 1
}}
]
end
# ==========================================================================
# 默认提取器 — 启发式规则(无 LLM 依赖)
# ==========================================================================
@doc """
默认事实提取器:从消息列表里抽启发式关键句子。
**不依赖 LLM**,只做简单规则匹配,方便单元测试。生产建议覆盖:
extract_fn: fn messages, opts ->
ReqLLM.chat(
model: "anthropic:claude-haiku-4",
messages: messages,
system: opts[:extract_prompt]
)
end
启发式规则:
1. 只看 role 为 `:user` 的消息(用户显式声明的事实最重要)
2. 按换行切分,保留非空行
3. 匹配关键字模式:
- 以"记住 | remember | 注意 | 约定 | 以后"开头
- 含"必须 | 一定 | always | never | must"
- 长度 20-200 字符(过短无信息量,过长是完整指令不是 fact)
"""
@spec default_extract([CMDC.Message.t()], keyword()) :: {:ok, [String.t()]}
def default_extract(messages, _opts \\ []) do
facts =
messages
|> Enum.filter(&(&1.role == :user))
|> Enum.flat_map(&extract_from_message/1)
|> Enum.uniq()
|> Enum.reject(&(&1 == ""))
{:ok, facts}
end
defp extract_from_message(%CMDC.Message{content: content}) when is_binary(content) do
content
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.filter(&good_fact?/1)
end
defp extract_from_message(_), do: []
defp good_fact?(line) do
line != "" and
String.length(line) in 20..200 and
has_fact_keyword?(line)
end
@fact_keywords ~w(记住 注意 约定 以后 一定 必须 remember please must always never)
defp has_fact_keyword?(line) do
down = String.downcase(line)
Enum.any?(@fact_keywords, &String.contains?(down, &1))
end
# ==========================================================================
# 私有 — 提取器安全包装 & 去重
# ==========================================================================
# 支持 2 元(messages, opts)与 3 元(messages, ctx, opts)签名。
# 3 元让 extract_fn 拿到 ctx.user_data 等业务标识,便于按租户路由不同 LLM。
defp safe_extract(extract_fn, messages, state, ctx) when is_function(extract_fn) do
opts = [max_facts: state.max_facts_per_flush]
case Function.info(extract_fn, :arity) do
{:arity, 3} ->
do_safe_extract(fn -> extract_fn.(messages, ctx, opts) end)
{:arity, 2} ->
do_safe_extract(fn -> extract_fn.(messages, opts) end)
_ ->
{:error, :invalid_extract_fn_arity}
end
end
defp safe_extract(_, _, _, _), do: {:error, :invalid_extract_fn}
defp do_safe_extract(fun) do
fun.()
rescue
e -> {:error, Exception.message(e)}
catch
kind, reason -> {:error, {kind, reason}}
end
defp take_max(facts, n) when is_integer(n) and n > 0, do: Enum.take(facts, n)
defp take_max(facts, _), do: facts
defp dedupe(facts, seen, true) do
Enum.reduce(facts, {[], seen}, fn fact, {acc, seen_acc} ->
hash = :crypto.hash(:sha256, fact) |> Base.encode16(case: :lower)
if MapSet.member?(seen_acc, hash) do
{acc, seen_acc}
else
{[fact | acc], MapSet.put(seen_acc, hash)}
end
end)
|> then(fn {facts_rev, seen_acc} -> {Enum.reverse(facts_rev), seen_acc} end)
end
defp dedupe(facts, seen, false), do: {facts, seen}
# ==========================================================================
# 私有 — 文件写入
# ==========================================================================
defp resolve_path(file, working_dir)
when is_binary(working_dir) and working_dir != "" do
if Path.type(file) == :absolute, do: file, else: Path.join(working_dir, file)
end
defp resolve_path(file, _), do: file
defp append_facts(path, facts) do
content = build_append_block(facts)
ensure_parent_dir(path)
exists? = File.exists?(path)
case File.open(path, [:append, :utf8]) do
{:ok, fh} ->
header = if exists?, do: "", else: "# Agent Memory\n\n#{@section_header}\n\n"
IO.write(fh, header <> content)
File.close(fh)
:ok
{:error, reason} ->
{:error, reason}
end
end
defp build_append_block(facts) do
Enum.map_join(facts, "", fn f -> @fact_prefix <> String.trim(f) <> "\n" end)
end
defp ensure_parent_dir(path) do
path |> Path.dirname() |> File.mkdir_p()
end
end