amrita_plugin_memory 0.5.0__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 (37) hide show
  1. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/PKG-INFO +58 -7
  2. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/README.md +57 -6
  3. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/__init__.py +2 -0
  4. {amrita_plugin_memory-0.5.0 → 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.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/_state.py +1 -1
  8. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/hooks.py +16 -7
  9. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/knowledge.py +46 -11
  10. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/runner.py +42 -18
  11. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/schemas.py +17 -1
  12. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/tools.py +239 -37
  13. amrita_plugin_memory-0.6.0/amrita_plugin_memory/tasks.py +47 -0
  14. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/vector.py +132 -13
  15. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/PKG-INFO +58 -7
  16. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/SOURCES.txt +3 -0
  17. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/pyproject.toml +1 -1
  18. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/LICENSE +0 -0
  19. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/cli.py +0 -0
  20. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/embed.py +0 -0
  21. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/embedding.py +0 -0
  22. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/keys.py +0 -0
  23. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/matchers.py +0 -0
  24. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/memo.py +0 -0
  25. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/migrations/21f55abc2b90_init.py +0 -0
  26. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/migrations/6004d221a7de_state.py +0 -0
  27. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/models.py +0 -0
  28. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/__init__.py +0 -0
  29. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/backend.py +0 -0
  30. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/consts.py +0 -0
  31. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/rethinking/types.py +0 -0
  32. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory/tools.py +0 -0
  33. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/dependency_links.txt +0 -0
  34. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/entry_points.txt +0 -0
  35. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/requires.txt +0 -0
  36. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/amrita_plugin_memory.egg-info/top_level.txt +0 -0
  37. {amrita_plugin_memory-0.5.0 → amrita_plugin_memory-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amrita_plugin_memory
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Amrita长期记忆插件
5
5
  Requires-Python: <3.14,>=3.10
6
6
  Description-Content-Type: text/markdown
@@ -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)
@@ -401,11 +428,16 @@ flowchart TD
401
428
  | `knowledge_search` | query, top_k? |
402
429
  | `knowledge_suggest` | action, title, summary, body, reason |
403
430
 
431
+ 知识库的三个只读工具 `knowledge_list` / `knowledge_read` / `knowledge_search`
432
+ 需要 LP 权限节点 `memory.knowledge`;`lp.admin` 与 `.env` 里的超级用户始终持有。
433
+ 未授权时工具返回错误 JSON,不返回任何知识内容。
434
+
404
435
  ### 巩固循环工具(`rethinking/tools.py`,共 20 个)
405
436
 
406
437
  记忆与 session/画像工具注册在隔离的 `_SUBCONSCIOUS_TOOLS` 上,不污染全局工具管理器。
407
- 知识库的 `list`/`read`/`search` 双重注册(表层 + 循环均可调用);
408
- `create`/`update`/`delete` 仅循环可用,表层通过 `knowledge_suggest` 提交建议。
438
+ 知识库的 `list`/`read`/`search` 双重注册:循环侧不受限,表层(对话 LLM)走
439
+ `memory.knowledge` 权限校验;`create`/`update`/`delete` 仅循环可用,
440
+ 表层通过 `knowledge_suggest` 提交建议。
409
441
 
410
442
  | 工具 | 用途 |
411
443
  | -------------------------------- | -------------------------------------- |
@@ -541,11 +573,22 @@ flowchart TD
541
573
 
542
574
  ```toml
543
575
  # L2 记忆
544
- short_term_expiry_days = 7
545
- long_term_expiry_days = 90
546
576
  per_session_memory_limit = 50
547
577
  memo_max_chars = 1000
548
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
+
549
592
  # L3 离线巩固循环
550
593
  [subconscious]
551
594
  enabled = false
@@ -571,8 +614,16 @@ enable_knowledge = true
571
614
 
572
615
  | 字段 | 默认值 | 说明 |
573
616
  | ---------------------------- | ------- | ------------------------------------------- |
574
- | `short_term_expiry_days` | `7` | ⚠️ **预留字段**,当前未实现自动过期清理 |
575
- | `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` | 单次清理最多扫描的记忆条数 |
576
627
  | `per_session_memory_limit` | `50` | 单个分区(uni_id)的记忆条数上限 |
577
628
  | `memo_max_chars` | `1000` | L1 备忘录字数硬上限 |
578
629
  | `enabled` | `false` | 是否启用离线巩固循环 |
@@ -235,6 +235,7 @@ LLM 读当前 memo → 融合新事实 → 输出新全文 → 服务端校验
235
235
 
236
236
  每条记忆带 `user_id` / `scope` / `tags` / `importance` / `created_at`。
237
237
  `importance` 分 `low` / `medium` / `high`,可在检索时作为过滤条件。
238
+ 检索时额外回填 `decay_score`(时间退化得分),不参与过滤。
238
239
 
239
240
  ### 检索
240
241
 
@@ -248,6 +249,32 @@ LLM 读当前 memo → 融合新事实 → 输出新全文 → 服务端校验
248
249
  > 距离是原始度量而非归一化相似度:`l2` 空间下其取值范围取决于向量是否归一化,
249
250
  > 因此不要跨模型比较绝对值,只用于同一查询内的相对排序。
250
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
+
251
278
  ---
252
279
 
253
280
  ## L3 离线巩固循环(DMN)
@@ -382,11 +409,16 @@ flowchart TD
382
409
  | `knowledge_search` | query, top_k? |
383
410
  | `knowledge_suggest` | action, title, summary, body, reason |
384
411
 
412
+ 知识库的三个只读工具 `knowledge_list` / `knowledge_read` / `knowledge_search`
413
+ 需要 LP 权限节点 `memory.knowledge`;`lp.admin` 与 `.env` 里的超级用户始终持有。
414
+ 未授权时工具返回错误 JSON,不返回任何知识内容。
415
+
385
416
  ### 巩固循环工具(`rethinking/tools.py`,共 20 个)
386
417
 
387
418
  记忆与 session/画像工具注册在隔离的 `_SUBCONSCIOUS_TOOLS` 上,不污染全局工具管理器。
388
- 知识库的 `list`/`read`/`search` 双重注册(表层 + 循环均可调用);
389
- `create`/`update`/`delete` 仅循环可用,表层通过 `knowledge_suggest` 提交建议。
419
+ 知识库的 `list`/`read`/`search` 双重注册:循环侧不受限,表层(对话 LLM)走
420
+ `memory.knowledge` 权限校验;`create`/`update`/`delete` 仅循环可用,
421
+ 表层通过 `knowledge_suggest` 提交建议。
390
422
 
391
423
  | 工具 | 用途 |
392
424
  | -------------------------------- | -------------------------------------- |
@@ -522,11 +554,22 @@ flowchart TD
522
554
 
523
555
  ```toml
524
556
  # L2 记忆
525
- short_term_expiry_days = 7
526
- long_term_expiry_days = 90
527
557
  per_session_memory_limit = 50
528
558
  memo_max_chars = 1000
529
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
+
530
573
  # L3 离线巩固循环
531
574
  [subconscious]
532
575
  enabled = false
@@ -552,8 +595,16 @@ enable_knowledge = true
552
595
 
553
596
  | 字段 | 默认值 | 说明 |
554
597
  | ---------------------------- | ------- | ------------------------------------------- |
555
- | `short_term_expiry_days` | `7` | ⚠️ **预留字段**,当前未实现自动过期清理 |
556
- | `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` | 单次清理最多扫描的记忆条数 |
557
608
  | `per_session_memory_limit` | `50` | 单个分区(uni_id)的记忆条数上限 |
558
609
  | `memo_max_chars` | `1000` | L1 备忘录字数硬上限 |
559
610
  | `enabled` | `false` | 是否启用离线巩固循环 |
@@ -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
@@ -41,7 +41,7 @@ def set_runner(r: SubconsciousRunner | None) -> None:
41
41
 
42
42
 
43
43
  def get_target_user_id() -> str:
44
- return _runner._config.target_user_id if _runner is not None else ""
44
+ return _runner.target_user_id if _runner is not None else ""
45
45
 
46
46
 
47
47
  def get_pending() -> list[PendingMsg]:
@@ -11,7 +11,6 @@ from . import _state
11
11
 
12
12
 
13
13
  async def _setup_subconscious_hook() -> None:
14
-
15
14
  pre_chat_hook = on_precompletion(priority=10, block=False)
16
15
 
17
16
  @pre_chat_hook.handle()
@@ -22,10 +21,16 @@ async def _setup_subconscious_hook() -> None:
22
21
  amrita_ctx = try_get_amrita_ctx(event.chat_object)
23
22
  if amrita_ctx is None:
24
23
  return
25
- if str(amrita_ctx["event"].user_id) != runner._config.target_user_id:
24
+ if str(amrita_ctx["event"].user_id) != runner.target_user_id:
26
25
  return
27
26
 
28
- await runner.cancel_and_reschedule()
27
+ try:
28
+ await runner.cancel_and_reschedule()
29
+ except Exception as e:
30
+ # 调度失败不能影响用户这条消息的处理
31
+ logger.opt(exception=e, colors=True, raw=True).error(
32
+ f"[Subconscious] Reschedule failed: {e}"
33
+ )
29
34
 
30
35
  pending = _state.get_pending()
31
36
  if pending and runner._config.allow_send_to_user:
@@ -53,13 +58,17 @@ async def _subconscious_startup() -> None:
53
58
  if not cfg.enabled or not cfg.target_user_id:
54
59
  logger.debug("[Subconscious] Not enabled or no target user, skip")
55
60
  return
56
- runner = SubconsciousRunner(cfg)
57
- _state.set_runner(runner)
58
- await runner.start()
61
+ # 先挂触发钩子:钩子失败就没有触发器,此时不能启动 runner,否则 DMN 静默死掉
59
62
  try:
60
63
  await _setup_subconscious_hook()
61
64
  except Exception as e:
62
- logger.warning(f"[Subconscious] Hook setup failed: {e}")
65
+ logger.opt(exception=e, colors=True, raw=True).error(
66
+ f"[Subconscious] Hook setup failed, DMN will NOT start: {e}"
67
+ )
68
+ return
69
+ runner = SubconsciousRunner(cfg)
70
+ _state.set_runner(runner)
71
+ await runner.start()
63
72
 
64
73
 
65
74
  @get_driver().on_shutdown