# NanoMem 读取与写入机制

本文档说明 `@pencil-agent/nano-mem` 的持久化存储、读取与写入的完整机制，便于理解数据流、触发时机和存储结构。

---

## 1. 概述

NanoMem 采用 **JSON 文件持久化**，所有读写均通过 `store.ts` 提供的底层 API，由 `NanoMemEngine` 编排，在 NanoPencil 中由 `extension.ts` 在会话生命周期内触发。

- **写入**：对话抽取 → 记忆条目/Episode/Work 的增删改 → 按 utility 排序与淘汰 → 写入对应 JSON 文件。
- **读取**：按需从磁盘加载各 JSON → 过滤/评分/分层（Progressive Recall）→ 组成为 system prompt 的 Memory Injection 或供工具/CLI 查询。

---

## 2. 核心理念

NanoMem 的设计围绕「有限上下文下可持续积累、按需披露」展开，主要理念如下。

- **多类型记忆**：不把记忆压成单一列表，而是区分为 **knowledge**（事实）、**lessons**（经验教训）、**preferences**（偏好）、**facets**（模式/困境）、**work**（工作摘要）、**episodes**（会话片段）。不同类型有不同的半衰期与用途，写入和读取时分别限流与预算分配。
- **双路径抽取**：有 LLM 时用结构化提示词从对话中抽取条目；无 LLM 时用正则启发式（错误、偏好、事实、决策、模式、困境等）兜底，保证离线或降级场景下仍能积累记忆。
- **Stanford 式检索评分**：每条记忆的「该不该被召回、排多前」由 **recency（新近度）**、**importance（重要性）**、**relevance（与当前 project/tags 的相关性）** 加权得到。新近度使用**艾宾浩斯衰减**（per-entry `strength`），随召回次数增强，实现间隔重复。
- **效用淘汰（Utility-based Eviction）**：单类记忆条数有上限（如 knowledge 1000、lessons 500）。写入前按 **utility**（访问频率与基础影响力加权）排序，只保留前 N 条，避免无限膨胀同时保留「用得多的」和「重要的」。
- **Mem0 式更新语义**：抽取结果按 **add / update / delete / retract** 处理——与已有条目 tag 重叠高则更新内容，明确撤销则删除，否则新增；并做重复检测（tag + 内容相似度），减少冗余。
- **A-MEM 式关联**：新条目可与已有条目建立 **relatedIds**，便于在注入时附带「相关记忆摘要」，形成小范围图结构而不引入重依赖。
- **渐进式披露**：不把全部记忆全文塞进 system prompt，而是分三层（Active / Cue / Dormant）按相关性披露，并在 Cue 层只给「名称 + 摘要 + ID」，需要时由模型调用 **recall_memory** 按 ID 拉取全文。既控制 token，又保证高相关记忆完整可见、其余按需拉取。（详见下一节。）

---

## 3. 渐进式披露（Progressive Recall）

### 3.1 动机

上下文有 token 上限，若把所有记忆的全文都注入，会占满预算且干扰当前任务。**渐进式披露**的做法是：只把「最相关」的一小部分以**全文**注入（Active），把「次相关」的以**线索**形式注入（Cue：名称 + 摘要 + ID），其余不注入（Dormant）；当模型需要某条 Cue 的细节时，通过工具 **recall_memory(id)** 按需拉取全文。这样在有限预算内既保证重点记忆完整可用，又保留大量「可检索、按需展开」的线索。

### 3.2 三层与披露程度

| 层级 | 披露内容 | 典型用途 |
|------|----------|----------|
| **Active** | 完整 **name + summary + detail**，以及相关记忆摘要（related） | 高相关、近期或高重要性的条目，直接参与当前推理 |
| **Cue** | **name + summary + [ID: xxx]**（以及 episode/work 的简要行） | 中等相关或备选记忆，模型若需要细节可调用 `recall_memory(id)` |
| **Dormant** | 不注入 | 低相关或低分条目，仅通过 `search_all_memories` 等搜索触达 |

分层规则（`scoring.getInjectionLevel` / `tierEntries`）：根据每条目的 **score**（recency + importance + relevance）与配置的 **thresholdActive**、**thresholdCue** 比较；此外**最近 N 小时内创建的**或**重要性 ≥ 某值**的会强制进入 Active。

### 3.3 预算分配

- 总字符约 **tokenBudget × 4**（粗略 4 字符/token）。
- **progressiveRecall.budgetActive**（默认 15%）给 Active 层：在四类记忆（knowledge/lessons/preferences/facets）内按 score 用 **pickTop** 选条，直到占满该预算。
- **progressiveRecall.budgetCue**（默认 70%）给 Cue 层：六块（上述四类 + episodes + work）均分预算，同样用 **pickTop** 选「名称 + 摘要 + id」形式的线索。
- 剩余约 15% 未在默认配置中用于注入，可视为留给系统提示与当前对话。

### 3.4 按需披露：recall_memory

Cue 层每条都带 **\[ID: xxx\]**。模型若认为某条线索与当前任务相关但摘要不够，可调用工具 **recall_memory** 传入该 id。Engine 会：

1. **getEntryById(id)** 从四类 entry 文件中找到对应条，返回完整 name/summary/detail/tags；
2. **reinforceEntryById(id)** 对该条做一次「召回强化」（accessCount++、lastAccessed 更新、strength 增长），并写回磁盘。

这样「披露」是两阶段的：先给线索，再按需给全文，同时用召回行为更新强度，符合间隔重复的设计。

### 3.5 配置项摘要（ProgressiveRecallConfig）

- **thresholdActive** / **thresholdCue**：分数 ≥ 对应阈值则归入 Active / Cue。
- **budgetActive** / **budgetCue**：占总 token 预算的比例。
- **forceRecentHours**：创建时间在此小时数内的条目强制 Active。
- **forceImportanceMin**：importance ≥ 此值的条目强制 Active。

---

## 4. 存储目录与配置

### 4.1 目录位置

- **默认**：`~/.nanomem/memory`（由 `config.ts` 中 `homedir()` + `.nanomem/memory` 得到）。
- **NanoPencil 集成**：在 `main.ts` 中若未设置 `NANOMEM_MEMORY_DIR`，会设为 `getAgentDir() + "memory"`，即 `~/.nanopencil/agent/memory`。

### 4.2 环境变量（config.ts）

| 变量 | 含义 | 默认 |
|------|------|------|
| `NANOMEM_MEMORY_DIR` | 记忆根目录 | `~/.nanomem/memory` |
| `NANOMEM_TOKEN_BUDGET` | 注入时的总 token 预算（约 4 字符/token 用于预算计算） | 6000 |
| `NANOMEM_LOCALE` | 提示词与界面语言 | `en` / `zh`，默认 `en` |

### 4.3 存储结构（store 路径）

Engine 在构造时根据 `cfg.memoryDir` 解析出以下路径：

| 路径 | 内容类型 | 说明 |
|------|----------|------|
| `memoryDir/knowledge.json` | `MemoryEntry[]` | 事实类知识 |
| `memoryDir/lessons.json` | `MemoryEntry[]` | 经验教训 |
| `memoryDir/preferences.json` | `MemoryEntry[]` | 用户偏好 |
| `memoryDir/facets.json` | `MemoryEntry[]` | 模式(pattern)与困境(struggle) |
| `memoryDir/work.json` | `WorkEntry[]` | 工作摘要（目标+摘要） |
| `memoryDir/meta.json` | `Meta` | 会话总数、上次巩固时间等 |
| `memoryDir/episodes/` | 单文件 per Episode | `{date}-{sessionId前8位}.json`，每个文件一个 `Episode` |

---

## 5. 底层存储 API（store.ts）

所有持久化都经过 `store.ts`，保证目录存在、读写为 JSON、失败时回退到默认值。

### 5.1 通用读写

- **`readJson<T>(path, fallback): Promise<T>`**  
  - 若文件不存在或解析失败则返回 `fallback`。  
  - 用于所有「加载」场景。

- **`writeJson(path, data): Promise<void>`**  
  - 先 `ensureDir(dirname(path))`，再 `writeFile` 写入 `JSON.stringify(data, null, 2)`。  
  - 所有「保存」最终都调用此函数。

### 5.2 记忆条目（MemoryEntry）

- **`loadEntries(path): Promise<MemoryEntry[]>`**  
  - `readJson(path, [])`，并对每条做 `migrateEntry`（旧格式仅有 `content` 时补全 `name/summary/detail`）。

- **`saveEntries(path, entries, max, utilityFn): Promise<void>`**  
  - 若 `entries.length > max`：按 `utilityFn` 降序排序，截断到 `max` 条。  
  - 持久化时把 `detail` 同时写入 `content`（兼容旧版）。  
  - 即：**写入前会做 utility 淘汰，控制每条目的最大条数**。

### 5.3 Episode

- **`loadEpisodes(episodesDir): Promise<Episode[]>`**  
  - 列出目录下所有 `.json` 文件，逐个 `readJson` 为 `Episode`，过滤掉 null。

- **`saveEpisode(episodesDir, ep): Promise<void>`**  
  - 写入单文件：`episodesDir/${ep.date}-${ep.sessionId.slice(0,8)}.json`。

### 5.4 Work

- **`loadWork(path): Promise<WorkEntry[]>`**  
  - `readJson(path, [])`。

- **`saveWork(path, entries, max, utilityFn): Promise<void>`**  
  - 与 `saveEntries` 类似：超 `max` 时按 `utilityFn` 排序并截断，再 `writeJson`。

### 5.5 Meta

- **`loadMeta(path): Promise<Meta>`**  
  - `readJson(path, { totalSessions: 0, version: 1 })`。

Meta 的写入不单独封装，由 engine 在需要时 `writeJson(this.metaPath, meta)`（例如会话结束、巩固后更新 `totalSessions` / `lastConsolidation`）。

---

## 6. 写入流程（何时、写什么）

### 6.1 写入路径总览

| 触发时机 | 入口 | 写入内容 | 涉及文件 |
|----------|------|----------|----------|
| 每次 Agent 回合结束 | extension `agent_end` | 对话抽取的记忆 + 可选 Work | knowledge/lessons/preferences/facets，或 work.json |
| 会话关闭 | extension `session_shutdown` | 本会话 Episode | episodes/*.json + meta.json |
| 会话开始 | extension `session_start` | 巩固产生的长期记忆 | knowledge/lessons + episodes 中已巩固的标记 + meta.json |
| 注入时强化 | engine `getMemoryInjection` 内 | 被召回条目的 accessCount/lastAccessed/strength | knowledge/lessons/preferences/facets/work.json |
| 工具 `recall_memory` | extension 工具 | 单条 reinforce | 对应类型的 json |
| 工具 `search_all_memories` | 只读 | 无 | — |
| CLI forget | engine `forgetEntry` | 删除一条 | 对应类型的 json |
| CLI dedup | engine `deduplicateAll` | 批量去重、合并 relatedIds | 全部 json |

### 6.2 对话 → 长期记忆（extractAndStore）

1. **extension** 在 `agent_end` 用 `buildTranscript(event.messages)` 得到本轮对话文本，然后：
   - `engine.extractAndStore(transcript, project)`  
   - 若有实质对话或工具活动，再 `engine.extractAndStoreWork(transcript, project, sessionGoal)`。

2. **engine.extractAndStore**：
   - 调用 **extraction.extractMemories(conversation, cfg, llmFn)**：  
     - 有 LLM 则用 LLM 抽取结构化条目（含 type、name、summary、detail、facetData 等）；  
     - 否则用 **extractHeuristic** 的正则匹配（lesson/preference/fact/decision/pattern/struggle）。
   - 从磁盘 **loadEntries** 四个文件：knowledge、lessons、preferences、facets。
   - 对每个抽取项调用 **update.applyExtraction(target, item, project, cfg)**：  
     - `retract` → 按 tag 重叠删除已有条目；  
     - 否则：先查重（tag + 内容相似度），再查是否可 UPDATE（tag 重叠 ≥ 0.7）；  
     - 若为 UPDATE 则更新该条 name/summary/detail/tags/lastAccessed；  
     - 否则 **ADD**：生成新 `MemoryEntry`，**linking.linkNewEntry** 挂 relatedIds，push 到对应数组。
   - 然后对四个数组分别 **saveEntries(path, arr, maxEntries.xxx, utilityEntry)**，即写入前按 utility 排序并截断到 max。

3. **engine.extractAndStoreWork**：
   - **extraction.extractWork**（LLM 或启发式）得到 `{ goal, summary, detail }`。
   - **loadWork(workPath)**，构造新 `WorkEntry`，push 后 **saveWork(..., maxEntries.work, utilityWork)**。

因此：**写入长期记忆的时机是「每轮 agent 结束」**，写入的是「本轮对话抽取出的记忆 + 本会话 work」；写入前会做去重、更新或新增，并按 utility 做条目数上限控制。

**Work 去重**：`extractAndStoreWork` 在写入前调用 `checkWorkDuplicate`：若 goal+summary 或 tags 与已有 work 高度相似则跳过；若 tag 重叠 ≥ 0.65 则更新已有条目；否则新增。

### 6.3 Episode 写入（session_shutdown）

- **extension** 在 `session_shutdown` 中，若有工具活动或会话目标，调用：
  - **engine.saveEpisode({ sessionId, project, date, summary, userGoal, filesModified, toolsUsed, keyObservations, errors, tags, importance, consolidated: false })**
- **engine.saveEpisode**：
  - **persistEpisode(episodesDir, ep)** → 即 store 的 **saveEpisode**，写入 `episodes/{date}-{sessionId前8位}.json`。
  - **loadMeta** → `totalSessions++` → **writeJson(metaPath, meta)**。

即：**Episode 的写入发生在「会话关闭」时**，每个会话一个 JSON 文件，并更新 meta 中的会话计数。

### 6.4 巩固（consolidate）产生的写入

- **extension** 在 `session_start` 时调用 **engine.consolidate()**。
- **engine.consolidate**：
  - **loadEpisodes(episodesDir)**，过滤出未巩固的 episode（`!ep.consolidated`）。
  - 若未巩固数量 ≥ `consolidationThreshold`：  
    - **consolidation.consolidateEpisodes**：有 LLM 则用 LLM 从多段 episode 摘要中抽取 fact/lesson，否则启发式（高频文件、重复错误等）生成新 `MemoryEntry`。
  - 新条目做 **linkNewEntry**，分别 push 到 knowledge/lessons。
  - **saveEntries** 写回 knowledge.json、lessons.json（同样带 max + utility 淘汰）。
  - 把已巩固的 episode 再写回（**persistEpisode**），更新 meta 的 **lastConsolidation**。

即：**巩固在「新会话开始」时执行**，把多段 Episode 提炼成长期 knowledge/lessons 并写回磁盘，同时标记 episode 为已巩固并更新 meta。

**去重**：写入前对每条新条目调用 `checkConsolidationEntry`，与已有 knowledge+lessons 比对。若重复（tag 或内容相似）则跳过；若存在可更新候选（tag 重叠 ≥ 0.7）则更新；否则新增。避免 consolidate 与 extractAndStore 产生重复条目。

### 6.5 注入时的强化写入（Reinforcement）

在 **getMemoryInjection** 中，会对被选入 Active / Cue 的条目做「召回强化」：

- **reinforceEntries**（knowledge/lessons/preferences/facets）：  
  - 对被召回的条目：`accessCount++`，`lastAccessed = now`，`strength *= strengthGrowthFactor`。  
   - 然后 **saveEntries** 写回对应 json。
- **reinforceWork**：对召回的 work 做同样更新后 **saveWork**。

工具 **recall_memory** 调用 **engine.reinforceEntryById(id)**，对单条做同样强化并 **saveEntries**。

因此：**每次「注入」或「recall_memory」都会触发一次对应文件的读→改→写**，实现间隔重复（spaced repetition）的强度与访问计数更新。

### 6.6 删除（forgetEntry）

- **engine.forgetEntry(id)**：在四个 entry 文件中找到 id，splice 掉该条，再 **saveEntries(..., Infinity, utilityEntry)**（不限制条数，只写回当前列表）。

---

## 7. 读取流程（何时、读什么）

### 7.1 读取路径总览

| 场景 | 入口 | 读取内容 | 用途 |
|------|------|----------|------|
| 每次 Agent 开始前 | extension `before_agent_start` | 全部记忆 + episodes + work | 组装 Memory Injection 拼进 system prompt |
| 注入内部 | engine `getMemoryInjection` | 同上 | 分层、预算分配、组文案、强化 |
| 工具 recall_memory | engine `getEntryById` | 四个 entry 文件 | 按 id 取单条详情并强化 |
| 工具 search_all_memories | engine `searchAllEntries` | 四个 entry 文件 | 按 query 做 tag/name/summary 匹配 |
| /mem-search | engine `searchEntries` | 同上 + scope 过滤 | 扩展命令 |
| /mem-stats | engine `getStats` | 各文件 length + meta | 统计展示 |
| CLI stats/search/export | engine | 各 load* | 统计、搜索、导出 |
| 巩固 | engine `consolidate` | loadEpisodes + 之后 load knowledge/lessons | 巩固输入 |
| 抽取后写入前 | engine extractAndStore/consolidate | loadEntries/loadWork | 在内存中合并后再写回 |

### 7.2 Memory Injection 的完整读取流程（getMemoryInjection）

这是最主要的「读取」使用场景，步骤概括如下：

1. **并行加载所有数据**  
   - `loadEntries`：knowledge、lessons、preferences、facets。  
   - `loadEpisodes`：episodes 目录下所有 json。  
   - `loadWork`：work.json。

2. **过滤与清理**  
   - **filterAndCleanEntries**：先 **evictExpiredEntries**（按 TTL 等剔除过期），再 **filterByScope**（若传了 scope）。  
   - **filterAndCleanWork**：同理。  
   - Episodes 仅 **filterByScope**。

3. **Progressive Recall 分层**（即 §3 渐进式披露）  
   - 对 knowledge/lessons/preferences/facets 分别调用 **scoring.tierEntries**，得到每类的 `{ active, cue, dormant }`（基于 score 与 config 的 thresholdActive/thresholdCue、forceRecentHours、forceImportanceMin）。  
   - Episodes 和 Work 不 tier，只参与后面的 cue 层预算。

4. **预算分配**  
   - 总字符约 `tokenBudget * 4`，按 `progressiveRecall.budgetActive` / `budgetCue` 分给 Active 与 Cue。  
   - Active 再均分给 4 类；Cue 均分给 6 块（4 类 + episodes + work）。  
   - 用 **pickTop** 按 score 和长度在预算内选取条目。

5. **强化被召回的条目**  
   - 对本次选中的 Active+Cue 条目调用 **reinforceEntries** / **reinforceWork**，更新 accessCount、lastAccessed、strength 并 **saveEntries/saveWork**（见 6.5）。

6. **（可选）重巩固**  
   - 若配置了 LLM，对召回但与当前 contextTags 重叠较低的条目做 **reconsolidateIfNeeded**（更新 detail/tags 等），不在此文档展开。

7. **组文案**  
   - **buildProgressiveInjectionText**：  
     - Active：完整 name + summary + detail + related 摘要。  
     - Cue：name + summary + id（以及 episode/work 的简要行）。  
   - 加上 i18n 的 section 标题与「记忆使用说明」行为块，返回一大段 Markdown 字符串，供 extension 拼到 system prompt。

因此：**读取的时机是「每次 before_agent_start」**（或 30s 内用缓存再后台刷新）；读取会触发「加载 → 分层 → 选条 → 强化并写回」的完整链路。

### 7.3 按 ID 读取（getEntryById）

- 依次 **loadEntries** knowledge、lessons、preferences、facets 四个路径，找到 `e.id === id` 即返回。  
- **recall_memory** 工具会在此基础上再调用 **reinforceEntryById**，触发一次写回。

### 7.4 全量搜索（searchAllEntries）

- 并行 **loadEntries** 四个文件，合并为数组。  
- 用 query 的 **extractTags** 与 name/summary 的简单匹配 + **tagOverlap** 打分，排序后取前 `limit` 条。  
- 不写回，纯读。

---

## 8. 数据流简图（文字）

```
Extension 生命周期:
  session_start     → consolidate()           → 读 episodes → 写 knowledge/lessons/meta + 写回 episodes
  before_agent_start → getMemoryInjection()  → 读 4 类 + episodes + work → 分层/选条 → 强化并写回 → 返回文案
  agent_end         → extractAndStore()      → 读 4 类 → 抽取 → 更新/新增 → 写回 4 类
                    → extractAndStoreWork()  → 读 work → 追加 → 写回 work
  session_shutdown  → saveEpisode()          → 写 episodes/*.json + meta

工具:
  recall_memory(id)       → getEntryById → reinforceEntryById → 写回对应 json
  search_all_memories(q)  → loadEntries 四文件 → 搜索 → 只读
```

---

## 9. 淘汰与容量控制（Eviction）

- **saveEntries(path, entries, max, utilityFn)**：  
  - 当 `entries.length > max` 时，按 `utilityFn(e)` 降序排序，保留前 `max` 条再写入。  
- **utilityFn** 来自 **eviction.utilityEntry**（或 work 的 **utilityWork**），综合 halfLife、evictionWeights、accessCount、importance、时间等计算「效用」，用于决定谁被保留。  
- 各类型 max 在 config 的 **maxEntries** 中（如 knowledge: 1000, lessons: 500, preferences: 200, work: 400, facets: 400）。  
- 因此：**写入时既做「单文件内按 utility 的淘汰」，也做「条数上限」控制**；读取不删条，删条只发生在 forgetEntry、retract 或 saveEntries 时的截断。

---

## 10. 小结

- **存储**：所有持久化都在 `store.ts`，JSON 文件，目录由 `NANOMEM_MEMORY_DIR`（或 NanoPencil 的 `~/.nanopencil/agent/memory`）决定。  
- **写**：  
  - 对话结束 → 抽取记忆与 work → 更新/新增 → saveEntries/saveWork；  
  - 会话结束 → saveEpisode + meta；  
  - 会话开始 → consolidate → 写 knowledge/lessons/episodes/meta；  
  - 注入/recall → 强化被召回的条目 → saveEntries/saveWork。  
- **读**：  
  - 每次 agent 开始前 getMemoryInjection 会读全部 4 类 + episodes + work，分层、选条、组文案，并顺带强化写回；  
  - 工具/CLI 的按 id、按 query 查询都是对上述文件的 loadEntries 后再过滤/搜索。  
- **一致性**：无数据库事务；并发下同一文件多次写可能互相覆盖，通常单进程单会话使用，扩展层避免并发写同一 engine 实例即可。
- **去重**：extractAndStore 用 `applyExtraction` 做同类型内去重；consolidate 用 `checkConsolidationEntry` 避免与已有 knowledge/lessons 重复；extractAndStoreWork 用 `checkWorkDuplicate` 避免重复 work。已有重复数据可运行 `nanomem dedup` 批量清理。

### 10.1 去重机制小结

| 场景 | 入口 | 去重逻辑 |
|------|------|----------|
| 对话抽取 | applyExtraction | 同类型内 tag≥0.8 或 content≥0.85 视为重复；tag≥0.7 则更新 |
| 巩固 | checkConsolidationEntry | 与 knowledge+lessons 比对，重复则跳过，可更新则更新 |
| Work | checkWorkDuplicate | tag≥0.75 或 content≥0.8 跳过；tag≥0.65 更新 |
| 批量清理 | `nanomem dedup` | 按 tag/content 相似度合并，保留最优，合并 relatedIds |

若需对某一步（例如 extraction、linking、scoring 的公式）做更细说明，可再单独展开对应模块。
