amrita_plugin_memory 0.4.1__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {amrita_plugin_memory-0.4.1/amrita_plugin_memory.egg-info → amrita_plugin_memory-0.6.0}/PKG-INFO +67 -16
  2. amrita_plugin_memory-0.4.1/PKG-INFO → amrita_plugin_memory-0.6.0/README.md +63 -31
  3. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/__init__.py +2 -0
  4. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/config.py +36 -5
  5. amrita_plugin_memory-0.6.0/amrita_plugin_memory/decay.py +184 -0
  6. amrita_plugin_memory-0.6.0/amrita_plugin_memory/permissions.py +68 -0
  7. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/__init__.py +1 -2
  8. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/_state.py +1 -1
  9. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/hooks.py +16 -7
  10. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/knowledge.py +46 -11
  11. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/runner.py +60 -54
  12. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/schemas.py +17 -1
  13. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/tools.py +239 -37
  14. amrita_plugin_memory-0.6.0/amrita_plugin_memory/tasks.py +47 -0
  15. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/vector.py +132 -13
  16. amrita_plugin_memory-0.4.1/README.md → amrita_plugin_memory-0.6.0/amrita_plugin_memory.egg-info/PKG-INFO +82 -12
  17. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/SOURCES.txt +3 -1
  18. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/requires.txt +3 -3
  19. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/pyproject.toml +4 -4
  20. amrita_plugin_memory-0.4.1/amrita_plugin_memory/rethinking/nodes.py +0 -27
  21. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/LICENSE +0 -0
  22. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/cli.py +0 -0
  23. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/embed.py +0 -0
  24. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/embedding.py +0 -0
  25. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/keys.py +0 -0
  26. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/matchers.py +0 -0
  27. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/memo.py +0 -0
  28. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/migrations/21f55abc2b90_init.py +0 -0
  29. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/migrations/6004d221a7de_state.py +0 -0
  30. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/models.py +0 -0
  31. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/backend.py +0 -0
  32. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/consts.py +0 -0
  33. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/types.py +0 -0
  34. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/tools.py +0 -0
  35. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/dependency_links.txt +0 -0
  36. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/entry_points.txt +0 -0
  37. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/top_level.txt +0 -0
  38. {amrita_plugin_memory-0.4.1 → amrita_plugin_memory-0.6.0}/setup.cfg +0 -0
@@ -1,17 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amrita_plugin_memory
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: Amrita长期记忆插件
5
5
  Requires-Python: <3.14,>=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  Requires-Dist: aiohttp>=3.13.3
9
9
  Requires-Dist: aiologic>=0.16.0
10
- Requires-Dist: amrita-core>=0.8.5
11
- Requires-Dist: amrita>=1.3
10
+ Requires-Dist: amrita-core<2.0.0,>=1.0.0
11
+ Requires-Dist: amrita>=1.10
12
12
  Requires-Dist: chromadb>=1.5.8
13
13
  Requires-Dist: click>=8.1
14
- Requires-Dist: nonebot-plugin-amrita>=0.3.0
14
+ Requires-Dist: nonebot-plugin-amrita>=0.4.0
15
15
  Requires-Dist: nonebot-plugin-apscheduler>=0.5.0
16
16
  Requires-Dist: nonebot-plugin-localstore>=0.7.4
17
17
  Requires-Dist: nonebot-plugin-orm[default]>=0.8.2
@@ -165,7 +165,7 @@ L3 在代码中沿用历史标识符 `subconscious` / `rethinking`(避免破
165
165
  | `subconscious_*` 工具 | 巩固循环工具 | — |
166
166
  | 用户聊天 → `cancel_and_reschedule` | 任务负激活 | 专注任务时 DMN 被抑制 |
167
167
  | 静默期触发 `_run` | 空闲时活跃 | DMN 在无任务时活跃 |
168
- | 去重 / 压缩 / `MemoryLimiter` | 记忆巩固 | 海马 → 皮层的系统巩固 |
168
+ | 去重 / 压缩 / `ContextCompactor` | 记忆巩固 | 海马 → 皮层的系统巩固 |
169
169
  | 画像构建(`*_profile`) | 自传体记忆 | DMN 负责自我参照加工 |
170
170
  | 主动消息(`send_to_user`) | 心智游移 | 走神产生与自我相关的念头 |
171
171
  | 每轮数万 tokens | 静息高能耗 | DMN 占脑能耗约 20% |
@@ -254,6 +254,7 @@ LLM 读当前 memo → 融合新事实 → 输出新全文 → 服务端校验
254
254
 
255
255
  每条记忆带 `user_id` / `scope` / `tags` / `importance` / `created_at`。
256
256
  `importance` 分 `low` / `medium` / `high`,可在检索时作为过滤条件。
257
+ 检索时额外回填 `decay_score`(时间退化得分),不参与过滤。
257
258
 
258
259
  ### 检索
259
260
 
@@ -267,6 +268,32 @@ LLM 读当前 memo → 融合新事实 → 输出新全文 → 服务端校验
267
268
  > 距离是原始度量而非归一化相似度:`l2` 空间下其取值范围取决于向量是否归一化,
268
269
  > 因此不要跨模型比较绝对值,只用于同一查询内的相对排序。
269
270
 
271
+ ### 时间记忆退化(软衰减 + 分层清理)
272
+
273
+ 记忆不是永久等权 —— 越旧权重越低,超龄则逐级退化直至清理。分两段实现:
274
+
275
+ **软衰减(检索期)**:`query_notes` 先多取 `overfetch` 倍候选,再按
276
+
277
+ $$\text{score} = \frac{1}{1 + d} \times \max\left(0.5^{\,\text{age} / \text{half-life}},\ \text{floor}\right)$$
278
+
279
+ 重排后截回 `top_k`。半衰期随 `importance` 放大:`low` 为 `half_life_days`,
280
+ `medium` 为 `1+boost` 倍,`high` 为 `1+2·boost` 倍。得分回填到元数据的
281
+ `decay_score` 字段,便于排查。这一步**只重排、不删数据**。
282
+
283
+ **硬分层(清理期)**:每条记忆按 `importance` 有一条寿命阈值 ——
284
+ `low` = `short_term_days`、`high` = `long_term_days`、`medium` 取两者几何中值。
285
+ 后台任务按 `sweep_interval_hours` 扫描,超龄先 `high → medium → low` 逐级降级,
286
+ 降到 `low` 仍超龄才删除。降级只改 `importance`,不重新嵌入。
287
+
288
+ | 动作 | 触发条件 | 效果 |
289
+ | -------- | ------------------ | ------------------- |
290
+ | `keep` | `age ≤ 当前层阈值` | 不动 |
291
+ | `demote` | 超龄且非 `low` | `importance` 降一级 |
292
+ | `purge` | 超龄且已是 `low` | 删除该条 |
293
+
294
+ `created_at` 缺失或不可解析时按 `keep` 处理,避免误删。
295
+ 软衰减与分层清理都受 `[decay].enabled` 总开关控制。
296
+
270
297
  ---
271
298
 
272
299
  ## L3 离线巩固循环(DMN)
@@ -287,7 +314,7 @@ $$\text{delay} = \min(\text{base} \times \text{multiplier}^{\text{penalty}-1},\
287
314
  | 阶段 | 内容 |
288
315
  | -------------- | ---------------------------------------------------------- |
289
316
  | 状态加载 | 从 `SubconsciousState` 表恢复 `total_runs` / 摘要窗口 |
290
- | 记忆限幅 | Core `MemoryLimiter` 截断超限消息并生成摘要 |
317
+ | 记忆限幅 | Core `ContextCompactor` 截断超限消息并生成摘要 |
291
318
  | ReAct 循环 | Agent 调用 `subconscious_*` 工具执行整理 |
292
319
  | 后处理 | 更新全局 usage、持久化元状态、重置惩罚、调度下次运行 |
293
320
 
@@ -304,12 +331,13 @@ $$\text{delay} = \min(\text{base} \times \text{multiplier}^{\text{penalty}-1},\
304
331
 
305
332
  ```mermaid
306
333
  flowchart TD
307
- LOAD_STATE --> JINJA2_RENDER --> LIMITING_MEMORY --> BUILD_MESSAGE --> REACT_BLOCK
334
+ LOAD_STATE --> NORMALIZE_MESSAGES --> COMPACT_HISTORY --> JINJA2_RENDER --> BUILD_MESSAGE --> REACT_BLOCK
308
335
  ```
309
336
 
310
337
  `SubconsciousRunner` 把 ChatObject 当数据容器,注入自定义 `SubconsciousBackend`
311
338
  (隔离的工具 + memory 后端)与 Core `ReActAgentStrategy`。
312
- `LIMITING_MEMORY` 在 Agent Loop 前运行 Core `MemoryLimiter`。
339
+ 工作流直接复用 Core 内置的 `REACT_ONLY`,其中的 `COMPACT_HISTORY` 节点在 Agent
340
+ Loop 前运行 Core `ContextCompactor`。压缩排在渲染之前,摘要才能进入本次请求的系统提示词。
313
341
 
314
342
  ---
315
343
 
@@ -400,11 +428,16 @@ flowchart TD
400
428
  | `knowledge_search` | query, top_k? |
401
429
  | `knowledge_suggest` | action, title, summary, body, reason |
402
430
 
431
+ 知识库的三个只读工具 `knowledge_list` / `knowledge_read` / `knowledge_search`
432
+ 需要 LP 权限节点 `memory.knowledge`;`lp.admin` 与 `.env` 里的超级用户始终持有。
433
+ 未授权时工具返回错误 JSON,不返回任何知识内容。
434
+
403
435
  ### 巩固循环工具(`rethinking/tools.py`,共 20 个)
404
436
 
405
437
  记忆与 session/画像工具注册在隔离的 `_SUBCONSCIOUS_TOOLS` 上,不污染全局工具管理器。
406
- 知识库的 `list`/`read`/`search` 双重注册(表层 + 循环均可调用);
407
- `create`/`update`/`delete` 仅循环可用,表层通过 `knowledge_suggest` 提交建议。
438
+ 知识库的 `list`/`read`/`search` 双重注册:循环侧不受限,表层(对话 LLM)走
439
+ `memory.knowledge` 权限校验;`create`/`update`/`delete` 仅循环可用,
440
+ 表层通过 `knowledge_suggest` 提交建议。
408
441
 
409
442
  | 工具 | 用途 |
410
443
  | -------------------------------- | -------------------------------------- |
@@ -473,7 +506,7 @@ flowchart TD
473
506
  | ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
474
507
  | L1 备忘录 | `UserMemo` 表(插件自有 ORM,主键 = uni_id) | LLM 维护的常驻用户元信息(≤ `memo_max_chars`) |
475
508
  | 循环元状态 | `SubconsciousState` 表(插件自有 ORM,uid=`amrita_memory`) | `total_runs`、`last_abstracts`、`pending_messages`、`knowledge_suggestions` |
476
- | Session 摘要缓存 | `LRUCache[int, str]`(最大 128 条) | session DB id → LLM 生成的摘要文本,避免重复调用 MemoryLimiter |
509
+ | Session 摘要缓存 | `LRUCache[int, str]`(最大 128 条) | session DB id → LLM 生成的摘要文本,避免重复调用 `ContextCompactor` |
477
510
  | 惩罚计数器 | 内存(不持久化) | `_penalty_count`:重启后从 0 开始 |
478
511
  | L2 记忆 | ChromaDB `amrita_user_memory` | 向量 + documents + metadatas(含嵌入指纹与分区键版本) |
479
512
  | 用户画像 | `data/amrita_plugin_memory/user_profile.md` | Markdown 文件,`summary---body` 格式,行级增量更新 |
@@ -540,11 +573,22 @@ flowchart TD
540
573
 
541
574
  ```toml
542
575
  # L2 记忆
543
- short_term_expiry_days = 7
544
- long_term_expiry_days = 90
545
576
  per_session_memory_limit = 50
546
577
  memo_max_chars = 1000
547
578
 
579
+ # L2 时间记忆退化(软衰减 + 分层清理)
580
+ [decay]
581
+ enabled = true
582
+ short_term_days = 7
583
+ long_term_days = 90
584
+ half_life_days = 30.0
585
+ importance_boost = 1.0
586
+ overfetch = 3
587
+ score_floor = 0.05
588
+ sweep_enabled = true
589
+ sweep_interval_hours = 24
590
+ sweep_batch_limit = 5000
591
+
548
592
  # L3 离线巩固循环
549
593
  [subconscious]
550
594
  enabled = false
@@ -570,8 +614,16 @@ enable_knowledge = true
570
614
 
571
615
  | 字段 | 默认值 | 说明 |
572
616
  | ---------------------------- | ------- | ------------------------------------------- |
573
- | `short_term_expiry_days` | `7` | ⚠️ **预留字段**,当前未实现自动过期清理 |
574
- | `long_term_expiry_days` | `90` | ⚠️ **预留字段**,当前未实现自动过期清理 |
617
+ | `decay.enabled` | `true` | 是否启用时间记忆退化(软衰减 + 分层清理) |
618
+ | `decay.short_term_days` | `7` | `low` 层寿命阈值(天) |
619
+ | `decay.long_term_days` | `90` | `high` 层寿命阈值(天),`medium` 取几何中值 |
620
+ | `decay.half_life_days` | `30.0` | 软衰减基础半衰期(天),`low` 为基准 |
621
+ | `decay.importance_boost` | `1.0` | `high` 半衰期为 `1+2·boost` 倍 |
622
+ | `decay.overfetch` | `3` | 检索候选放大倍数 |
623
+ | `decay.score_floor` | `0.05` | 衰减因子下限,避免旧记忆权重归零 |
624
+ | `decay.sweep_enabled` | `true` | 是否启用分层清理 |
625
+ | `decay.sweep_interval_hours` | `24` | 分层清理周期(小时) |
626
+ | `decay.sweep_batch_limit` | `5000` | 单次清理最多扫描的记忆条数 |
575
627
  | `per_session_memory_limit` | `50` | 单个分区(uni_id)的记忆条数上限 |
576
628
  | `memo_max_chars` | `1000` | L1 备忘录字数硬上限 |
577
629
  | `enabled` | `false` | 是否启用离线巩固循环 |
@@ -649,7 +701,6 @@ amrita_plugin_memory/
649
701
  ├── types.py # TypedDict 定义
650
702
  ├── consts.py # 默认 Prompt 模板 / ensure_prompt_file
651
703
  ├── backend.py # SubconsciousBackend — 隔离的工具 + memory 后端
652
- ├── nodes.py # LIMITING_MEMORY 工作流节点
653
704
  ├── runner.py # SubconsciousRunner — 核心编排器
654
705
  ├── hooks.py # on_precompletion hook / 生命周期管理
655
706
  ├── tools.py # 20 个巩固循环工具 handler
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: amrita_plugin_memory
3
- Version: 0.4.1
4
- Summary: Amrita长期记忆插件
5
- Requires-Python: <3.14,>=3.10
6
- Description-Content-Type: text/markdown
7
- License-File: LICENSE
8
- Requires-Dist: aiohttp>=3.13.3
9
- Requires-Dist: aiologic>=0.16.0
10
- Requires-Dist: amrita-core>=0.8.5
11
- Requires-Dist: amrita>=1.3
12
- Requires-Dist: chromadb>=1.5.8
13
- Requires-Dist: click>=8.1
14
- Requires-Dist: nonebot-plugin-amrita>=0.3.0
15
- Requires-Dist: nonebot-plugin-apscheduler>=0.5.0
16
- Requires-Dist: nonebot-plugin-localstore>=0.7.4
17
- Requires-Dist: nonebot-plugin-orm[default]>=0.8.2
18
- Dynamic: license-file
19
-
20
1
  # amrita_plugin_memory
21
2
 
22
3
  基于向量数据库的长期记忆插件 — **三层记忆模型**:
@@ -165,7 +146,7 @@ L3 在代码中沿用历史标识符 `subconscious` / `rethinking`(避免破
165
146
  | `subconscious_*` 工具 | 巩固循环工具 | — |
166
147
  | 用户聊天 → `cancel_and_reschedule` | 任务负激活 | 专注任务时 DMN 被抑制 |
167
148
  | 静默期触发 `_run` | 空闲时活跃 | DMN 在无任务时活跃 |
168
- | 去重 / 压缩 / `MemoryLimiter` | 记忆巩固 | 海马 → 皮层的系统巩固 |
149
+ | 去重 / 压缩 / `ContextCompactor` | 记忆巩固 | 海马 → 皮层的系统巩固 |
169
150
  | 画像构建(`*_profile`) | 自传体记忆 | DMN 负责自我参照加工 |
170
151
  | 主动消息(`send_to_user`) | 心智游移 | 走神产生与自我相关的念头 |
171
152
  | 每轮数万 tokens | 静息高能耗 | DMN 占脑能耗约 20% |
@@ -254,6 +235,7 @@ LLM 读当前 memo → 融合新事实 → 输出新全文 → 服务端校验
254
235
 
255
236
  每条记忆带 `user_id` / `scope` / `tags` / `importance` / `created_at`。
256
237
  `importance` 分 `low` / `medium` / `high`,可在检索时作为过滤条件。
238
+ 检索时额外回填 `decay_score`(时间退化得分),不参与过滤。
257
239
 
258
240
  ### 检索
259
241
 
@@ -267,6 +249,32 @@ LLM 读当前 memo → 融合新事实 → 输出新全文 → 服务端校验
267
249
  > 距离是原始度量而非归一化相似度:`l2` 空间下其取值范围取决于向量是否归一化,
268
250
  > 因此不要跨模型比较绝对值,只用于同一查询内的相对排序。
269
251
 
252
+ ### 时间记忆退化(软衰减 + 分层清理)
253
+
254
+ 记忆不是永久等权 —— 越旧权重越低,超龄则逐级退化直至清理。分两段实现:
255
+
256
+ **软衰减(检索期)**:`query_notes` 先多取 `overfetch` 倍候选,再按
257
+
258
+ $$\text{score} = \frac{1}{1 + d} \times \max\left(0.5^{\,\text{age} / \text{half-life}},\ \text{floor}\right)$$
259
+
260
+ 重排后截回 `top_k`。半衰期随 `importance` 放大:`low` 为 `half_life_days`,
261
+ `medium` 为 `1+boost` 倍,`high` 为 `1+2·boost` 倍。得分回填到元数据的
262
+ `decay_score` 字段,便于排查。这一步**只重排、不删数据**。
263
+
264
+ **硬分层(清理期)**:每条记忆按 `importance` 有一条寿命阈值 ——
265
+ `low` = `short_term_days`、`high` = `long_term_days`、`medium` 取两者几何中值。
266
+ 后台任务按 `sweep_interval_hours` 扫描,超龄先 `high → medium → low` 逐级降级,
267
+ 降到 `low` 仍超龄才删除。降级只改 `importance`,不重新嵌入。
268
+
269
+ | 动作 | 触发条件 | 效果 |
270
+ | -------- | ------------------ | ------------------- |
271
+ | `keep` | `age ≤ 当前层阈值` | 不动 |
272
+ | `demote` | 超龄且非 `low` | `importance` 降一级 |
273
+ | `purge` | 超龄且已是 `low` | 删除该条 |
274
+
275
+ `created_at` 缺失或不可解析时按 `keep` 处理,避免误删。
276
+ 软衰减与分层清理都受 `[decay].enabled` 总开关控制。
277
+
270
278
  ---
271
279
 
272
280
  ## L3 离线巩固循环(DMN)
@@ -287,7 +295,7 @@ $$\text{delay} = \min(\text{base} \times \text{multiplier}^{\text{penalty}-1},\
287
295
  | 阶段 | 内容 |
288
296
  | -------------- | ---------------------------------------------------------- |
289
297
  | 状态加载 | 从 `SubconsciousState` 表恢复 `total_runs` / 摘要窗口 |
290
- | 记忆限幅 | Core `MemoryLimiter` 截断超限消息并生成摘要 |
298
+ | 记忆限幅 | Core `ContextCompactor` 截断超限消息并生成摘要 |
291
299
  | ReAct 循环 | Agent 调用 `subconscious_*` 工具执行整理 |
292
300
  | 后处理 | 更新全局 usage、持久化元状态、重置惩罚、调度下次运行 |
293
301
 
@@ -304,12 +312,13 @@ $$\text{delay} = \min(\text{base} \times \text{multiplier}^{\text{penalty}-1},\
304
312
 
305
313
  ```mermaid
306
314
  flowchart TD
307
- LOAD_STATE --> JINJA2_RENDER --> LIMITING_MEMORY --> BUILD_MESSAGE --> REACT_BLOCK
315
+ LOAD_STATE --> NORMALIZE_MESSAGES --> COMPACT_HISTORY --> JINJA2_RENDER --> BUILD_MESSAGE --> REACT_BLOCK
308
316
  ```
309
317
 
310
318
  `SubconsciousRunner` 把 ChatObject 当数据容器,注入自定义 `SubconsciousBackend`
311
319
  (隔离的工具 + memory 后端)与 Core `ReActAgentStrategy`。
312
- `LIMITING_MEMORY` 在 Agent Loop 前运行 Core `MemoryLimiter`。
320
+ 工作流直接复用 Core 内置的 `REACT_ONLY`,其中的 `COMPACT_HISTORY` 节点在 Agent
321
+ Loop 前运行 Core `ContextCompactor`。压缩排在渲染之前,摘要才能进入本次请求的系统提示词。
313
322
 
314
323
  ---
315
324
 
@@ -400,11 +409,16 @@ flowchart TD
400
409
  | `knowledge_search` | query, top_k? |
401
410
  | `knowledge_suggest` | action, title, summary, body, reason |
402
411
 
412
+ 知识库的三个只读工具 `knowledge_list` / `knowledge_read` / `knowledge_search`
413
+ 需要 LP 权限节点 `memory.knowledge`;`lp.admin` 与 `.env` 里的超级用户始终持有。
414
+ 未授权时工具返回错误 JSON,不返回任何知识内容。
415
+
403
416
  ### 巩固循环工具(`rethinking/tools.py`,共 20 个)
404
417
 
405
418
  记忆与 session/画像工具注册在隔离的 `_SUBCONSCIOUS_TOOLS` 上,不污染全局工具管理器。
406
- 知识库的 `list`/`read`/`search` 双重注册(表层 + 循环均可调用);
407
- `create`/`update`/`delete` 仅循环可用,表层通过 `knowledge_suggest` 提交建议。
419
+ 知识库的 `list`/`read`/`search` 双重注册:循环侧不受限,表层(对话 LLM)走
420
+ `memory.knowledge` 权限校验;`create`/`update`/`delete` 仅循环可用,
421
+ 表层通过 `knowledge_suggest` 提交建议。
408
422
 
409
423
  | 工具 | 用途 |
410
424
  | -------------------------------- | -------------------------------------- |
@@ -473,7 +487,7 @@ flowchart TD
473
487
  | ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
474
488
  | L1 备忘录 | `UserMemo` 表(插件自有 ORM,主键 = uni_id) | LLM 维护的常驻用户元信息(≤ `memo_max_chars`) |
475
489
  | 循环元状态 | `SubconsciousState` 表(插件自有 ORM,uid=`amrita_memory`) | `total_runs`、`last_abstracts`、`pending_messages`、`knowledge_suggestions` |
476
- | Session 摘要缓存 | `LRUCache[int, str]`(最大 128 条) | session DB id → LLM 生成的摘要文本,避免重复调用 MemoryLimiter |
490
+ | Session 摘要缓存 | `LRUCache[int, str]`(最大 128 条) | session DB id → LLM 生成的摘要文本,避免重复调用 `ContextCompactor` |
477
491
  | 惩罚计数器 | 内存(不持久化) | `_penalty_count`:重启后从 0 开始 |
478
492
  | L2 记忆 | ChromaDB `amrita_user_memory` | 向量 + documents + metadatas(含嵌入指纹与分区键版本) |
479
493
  | 用户画像 | `data/amrita_plugin_memory/user_profile.md` | Markdown 文件,`summary---body` 格式,行级增量更新 |
@@ -540,11 +554,22 @@ flowchart TD
540
554
 
541
555
  ```toml
542
556
  # L2 记忆
543
- short_term_expiry_days = 7
544
- long_term_expiry_days = 90
545
557
  per_session_memory_limit = 50
546
558
  memo_max_chars = 1000
547
559
 
560
+ # L2 时间记忆退化(软衰减 + 分层清理)
561
+ [decay]
562
+ enabled = true
563
+ short_term_days = 7
564
+ long_term_days = 90
565
+ half_life_days = 30.0
566
+ importance_boost = 1.0
567
+ overfetch = 3
568
+ score_floor = 0.05
569
+ sweep_enabled = true
570
+ sweep_interval_hours = 24
571
+ sweep_batch_limit = 5000
572
+
548
573
  # L3 离线巩固循环
549
574
  [subconscious]
550
575
  enabled = false
@@ -570,8 +595,16 @@ enable_knowledge = true
570
595
 
571
596
  | 字段 | 默认值 | 说明 |
572
597
  | ---------------------------- | ------- | ------------------------------------------- |
573
- | `short_term_expiry_days` | `7` | ⚠️ **预留字段**,当前未实现自动过期清理 |
574
- | `long_term_expiry_days` | `90` | ⚠️ **预留字段**,当前未实现自动过期清理 |
598
+ | `decay.enabled` | `true` | 是否启用时间记忆退化(软衰减 + 分层清理) |
599
+ | `decay.short_term_days` | `7` | `low` 层寿命阈值(天) |
600
+ | `decay.long_term_days` | `90` | `high` 层寿命阈值(天),`medium` 取几何中值 |
601
+ | `decay.half_life_days` | `30.0` | 软衰减基础半衰期(天),`low` 为基准 |
602
+ | `decay.importance_boost` | `1.0` | `high` 半衰期为 `1+2·boost` 倍 |
603
+ | `decay.overfetch` | `3` | 检索候选放大倍数 |
604
+ | `decay.score_floor` | `0.05` | 衰减因子下限,避免旧记忆权重归零 |
605
+ | `decay.sweep_enabled` | `true` | 是否启用分层清理 |
606
+ | `decay.sweep_interval_hours` | `24` | 分层清理周期(小时) |
607
+ | `decay.sweep_batch_limit` | `5000` | 单次清理最多扫描的记忆条数 |
575
608
  | `per_session_memory_limit` | `50` | 单个分区(uni_id)的记忆条数上限 |
576
609
  | `memo_max_chars` | `1000` | L1 备忘录字数硬上限 |
577
610
  | `enabled` | `false` | 是否启用离线巩固循环 |
@@ -649,7 +682,6 @@ amrita_plugin_memory/
649
682
  ├── types.py # TypedDict 定义
650
683
  ├── consts.py # 默认 Prompt 模板 / ensure_prompt_file
651
684
  ├── backend.py # SubconsciousBackend — 隔离的工具 + memory 后端
652
- ├── nodes.py # LIMITING_MEMORY 工作流节点
653
685
  ├── runner.py # SubconsciousRunner — 核心编排器
654
686
  ├── hooks.py # on_precompletion hook / 生命周期管理
655
687
  ├── tools.py # 20 个巩固循环工具 handler
@@ -15,6 +15,7 @@ from . import (
15
15
  memo,
16
16
  models,
17
17
  rethinking,
18
+ tasks,
18
19
  tools,
19
20
  vector,
20
21
  )
@@ -38,6 +39,7 @@ __all__ = [
38
39
  "memo",
39
40
  "models",
40
41
  "rethinking",
42
+ "tasks",
41
43
  "tools",
42
44
  "vector",
43
45
  ]
@@ -91,14 +91,45 @@ class SubconsciousConfig(BaseModel):
91
91
  )
92
92
 
93
93
 
94
+ class DecayConfig(BaseModel):
95
+ """时间记忆退化配置:软衰减(检索重排)+ 硬分层(降级 / 清理)。"""
96
+
97
+ enabled: bool = Field(default=True, description="是否启用时间记忆退化")
98
+ short_term_days: int = Field(
99
+ default=7, ge=1, description="短期记忆保留天数(low 层寿命)"
100
+ )
101
+ long_term_days: int = Field(
102
+ default=90, ge=1, description="长期记忆保留天数(high 层寿命)"
103
+ )
104
+ half_life_days: float = Field(
105
+ default=30.0, ge=1.0, description="软衰减基础半衰期(天),low 为基准"
106
+ )
107
+ importance_boost: float = Field(
108
+ default=1.0,
109
+ ge=0.0,
110
+ le=5.0,
111
+ description="importance 对半衰期的放大系数,high 为 (1+2*boost) 倍",
112
+ )
113
+ overfetch: int = Field(
114
+ default=3, ge=1, le=10, description="软衰减检索的候选放大倍数"
115
+ )
116
+ score_floor: float = Field(
117
+ default=0.05, ge=0.0, le=1.0, description="衰减因子下限,避免旧记忆权重归零"
118
+ )
119
+ sweep_enabled: bool = Field(default=True, description="是否启用分层清理")
120
+ sweep_interval_hours: int = Field(
121
+ default=24, ge=1, description="分层清理周期(小时)"
122
+ )
123
+ sweep_batch_limit: int = Field(
124
+ default=5000, ge=100, description="单次清理最多扫描的记忆条数"
125
+ )
126
+
127
+
94
128
  class ConfigFile(BaseModel):
95
129
  """配置文件"""
96
130
 
97
- short_term_expiry_days: int = Field(
98
- default=7, description="短期记忆的过期天数,默认为7天"
99
- )
100
- long_term_expiry_days: int = Field(
101
- default=90, description="长期记忆的过期天数,默认为90天"
131
+ decay: DecayConfig = Field(
132
+ default_factory=DecayConfig, description="时间记忆退化配置"
102
133
  )
103
134
  per_session_memory_limit: int = Field(
104
135
  default=100, description="每个会话的记忆数量限制,默认为50条"
@@ -0,0 +1,184 @@
1
+ """时间记忆退化:软衰减(检索重排)与硬分层(降级 / 清理)。
2
+
3
+ 本模块只放纯逻辑,不碰 I/O,便于单独验证。
4
+ - 软衰减:检索时按 created_at 的年龄压低得分,不删数据。
5
+ - 硬分层:按 importance 决定寿命阈值,超龄先降级、降到 low 再清理。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Mapping
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Literal
14
+
15
+ Importance = Literal["low", "medium", "high"]
16
+
17
+ #: importance 的序,越大寿命越长
18
+ IMPORTANCE_RANK: dict[str, int] = {"low": 0, "medium": 1, "high": 2}
19
+ #: 降级链,走到尽头即清理
20
+ DEMOTE_CHAIN: dict[str, str] = {"high": "medium", "medium": "low"}
21
+
22
+ _SECONDS_PER_DAY = 86400.0
23
+
24
+
25
+ def parse_created_at(value: Any) -> datetime | None:
26
+ """解析元数据里的 created_at,统一成 aware UTC;不可解析返回 None。"""
27
+ if isinstance(value, datetime):
28
+ dt = value
29
+ elif isinstance(value, str) and value:
30
+ try:
31
+ dt = datetime.fromisoformat(value)
32
+ except ValueError:
33
+ return None
34
+ else:
35
+ return None
36
+ if dt.tzinfo is None:
37
+ dt = dt.replace(tzinfo=timezone.utc)
38
+ return dt.astimezone(timezone.utc)
39
+
40
+
41
+ def age_days(created_at: datetime, now: datetime) -> float:
42
+ """年龄(天),未来时间按 0 处理。"""
43
+ return max((now - created_at).total_seconds() / _SECONDS_PER_DAY, 0.0)
44
+
45
+
46
+ def importance_half_life(base_days: float, importance: str, boost: float) -> float:
47
+ """importance 越高,有效半衰期越长;low 为基准。"""
48
+ return base_days * (1.0 + boost * IMPORTANCE_RANK.get(importance, 1))
49
+
50
+
51
+ def decay_factor(age: float, half_life: float, floor: float) -> float:
52
+ """指数衰减因子,下限为 floor,避免旧记忆权重归零。"""
53
+ if half_life <= 0:
54
+ return 1.0
55
+ return max(0.5 ** (age / half_life), floor)
56
+
57
+
58
+ def similarity_from_distance(distance: float) -> float:
59
+ """把 Chroma 距离映射到 (0,1],对 l2 / cosine 都单调。"""
60
+ try:
61
+ d = float(distance)
62
+ except (TypeError, ValueError):
63
+ d = 0.0
64
+ return 1.0 / (1.0 + max(d, 0.0))
65
+
66
+
67
+ def decay_score(
68
+ similarity: float,
69
+ age: float,
70
+ importance: str,
71
+ base_half_life: float,
72
+ boost: float,
73
+ floor: float,
74
+ ) -> float:
75
+ """相似度 × 时间衰减因子。"""
76
+ half_life = importance_half_life(base_half_life, importance, boost)
77
+ return similarity * decay_factor(age, half_life, floor)
78
+
79
+
80
+ def tier_days(importance: str, short_days: float, long_days: float) -> float:
81
+ """该 importance 层的寿命阈值(天)。medium 取 short/long 的几何中值。
82
+
83
+ 两个配置项写反时按大小重排,保证 low ≤ medium ≤ high 单调,
84
+ 否则 high 会比 medium 先过期。
85
+ """
86
+ low_days, high_days = sorted((float(short_days), float(long_days)))
87
+ if importance == "low":
88
+ return low_days
89
+ if importance == "high":
90
+ return high_days
91
+ if high_days > low_days:
92
+ return math.sqrt(low_days * high_days)
93
+ return low_days
94
+
95
+
96
+ def plan_action(
97
+ metadata: dict, now: datetime, short_days: float, long_days: float
98
+ ) -> tuple[str, str | None]:
99
+ """给出单条记忆的退化动作。
100
+
101
+ Returns:
102
+ (动作, 新 importance),动作 ∈ {"keep", "demote", "purge"}。
103
+ 仅 demote 会带新的 importance。
104
+ """
105
+ created = parse_created_at(metadata.get("created_at"))
106
+ if created is None:
107
+ return "keep", None
108
+ importance = str(metadata.get("importance", "medium"))
109
+ if importance not in IMPORTANCE_RANK:
110
+ importance = "medium"
111
+ if age_days(created, now) <= tier_days(importance, short_days, long_days):
112
+ return "keep", None
113
+ nxt = DEMOTE_CHAIN.get(importance)
114
+ if nxt is None:
115
+ return "purge", None
116
+ return "demote", nxt
117
+
118
+
119
+ def _inner_list(value: Any, size: int) -> list:
120
+ """取 QueryResult 里 list-of-list 的内层;兼容 numpy 数组;缺失时补 None。"""
121
+ if hasattr(value, "tolist"): # chroma 的 embeddings 可能是 ndarray
122
+ value = value.tolist()
123
+ if isinstance(value, list) and value and isinstance(value[0], list):
124
+ inner = list(value[0])
125
+ else:
126
+ inner = []
127
+ if len(inner) < size:
128
+ inner.extend([None] * (size - len(inner)))
129
+ return inner
130
+
131
+
132
+ def rerank_query_result(
133
+ result: Mapping[str, Any],
134
+ top_k: int,
135
+ base_half_life: float,
136
+ boost: float,
137
+ floor: float,
138
+ now: datetime | None = None,
139
+ ) -> dict[str, Any]:
140
+ """按衰减得分重排 QueryResult,并截断到 top_k;meta 里回填 decay_score。"""
141
+ now = now or datetime.now(timezone.utc)
142
+ ids_nested = result.get("ids")
143
+ if not ids_nested or not ids_nested[0]:
144
+ return dict(result)
145
+ ids = list(ids_nested[0])
146
+ size = len(ids)
147
+ metas = _inner_list(result.get("metadatas"), size)
148
+ dists = _inner_list(result.get("distances"), size)
149
+
150
+ scores: list[float] = []
151
+ for i in range(size):
152
+ meta = metas[i] if isinstance(metas[i], dict) else {}
153
+ created = parse_created_at(meta.get("created_at"))
154
+ age = age_days(created, now) if created else 0.0
155
+ similarity = similarity_from_distance(dists[i])
156
+ scores.append(
157
+ decay_score(
158
+ similarity,
159
+ age,
160
+ str(meta.get("importance", "medium")),
161
+ base_half_life,
162
+ boost,
163
+ floor,
164
+ )
165
+ )
166
+
167
+ order = sorted(range(size), key=lambda i: scores[i], reverse=True)[:top_k]
168
+
169
+ out = dict(result)
170
+ for key in ("ids", "documents", "metadatas", "distances", "embeddings"):
171
+ value = result.get(key)
172
+ if value is None:
173
+ continue
174
+ inner = _inner_list(value, size)
175
+ out[key] = [[inner[i] for i in order]]
176
+
177
+ if result.get("metadatas") is not None:
178
+ reordered = []
179
+ for i in order:
180
+ meta = dict(metas[i]) if isinstance(metas[i], dict) else {}
181
+ meta["decay_score"] = round(scores[i], 4)
182
+ reordered.append(meta)
183
+ out["metadatas"] = [reordered]
184
+ return out
@@ -0,0 +1,68 @@
1
+ """知识库读取的权限校验。
2
+
3
+ 会话侧(对话 LLM)可以读全局知识库,但需要 LP 节点 ``memory.knowledge``;
4
+ ``lp.admin`` 与 ``.env`` 里的超级用户始终持有该权限。
5
+ 潜意识循环内部调用不受此限制。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from nonebot import get_driver, logger
13
+
14
+ if TYPE_CHECKING:
15
+ from nonebot.adapters.onebot.v11 import Event
16
+
17
+ #: 读取知识库所需的权限节点
18
+ KNOWLEDGE_NODE = "memory.knowledge"
19
+ #: 管理员节点,始终持有 KNOWLEDGE_NODE
20
+ ADMIN_NODE = "lp.admin"
21
+
22
+ _ENV_ADMINS = get_driver().config.superusers
23
+
24
+ _lp_warned = False
25
+
26
+
27
+ async def _has_node(event: Event, node: str) -> bool:
28
+ """查询 LP 节点;LP 不可用时按无权限处理。"""
29
+ global _lp_warned
30
+ try:
31
+ from amrita.plugins.perm.API.rules import any_has_permission
32
+ except Exception:
33
+ if not _lp_warned:
34
+ _lp_warned = True
35
+ logger.warning("[Memory] LitePerm 不可用,知识库读取仅对 .env 超级用户开放")
36
+ return False
37
+ try:
38
+ return bool(await any_has_permission(node)(event))
39
+ except Exception as e:
40
+ logger.opt(exception=e, colors=True, raw=True).warning(
41
+ f"[Memory] 权限节点 {node} 校验失败: {e}"
42
+ )
43
+ return False
44
+
45
+
46
+ async def can_read_knowledge(event: Event | None) -> bool:
47
+ """是否允许读取全局知识库:超级用户 / lp.admin / memory.knowledge。"""
48
+ if event is None:
49
+ return False
50
+ if event.get_user_id() in _ENV_ADMINS:
51
+ return True
52
+ if await _has_node(event, ADMIN_NODE):
53
+ return True
54
+ return await _has_node(event, KNOWLEDGE_NODE)
55
+
56
+
57
+ def get_event_from_tool_ctx(ctx: Any) -> Event | None:
58
+ """从 ToolContext 里取出触发本次调用的原始事件。"""
59
+ try:
60
+ from amrita.plugins.chat.runtime import try_get_amrita_ctx
61
+
62
+ amrita_ctx = try_get_amrita_ctx(ctx.ctx.chat_object)
63
+ if amrita_ctx is None:
64
+ return None
65
+ return amrita_ctx["event"]
66
+ except Exception as e:
67
+ logger.debug(f"[Memory] 取事件失败: {e}")
68
+ return None