From 634cfe1de01fb102a15c30292ef32d0c9215dfcf Mon Sep 17 00:00:00 2001
From: AmriaLin <325560502+AmriaLin@users.noreply.github.com>
Date: Mon, 19 Oct 2026 09:58:30 +0000
Subject: [PATCH 1/6] =?UTF-8?q?feat(dmn):=20=E7=A9=BA=E9=97=B2=E7=AA=97?=
=?UTF-8?q?=E5=8F=A3=E8=B0=83=E5=BA=A6=E4=B8=8E=20prompt=20=E9=9D=99?=
=?UTF-8?q?=E6=80=81=E5=8C=96=20(0.8.0)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
调度层
- 新增 schedule.py:7×12 热度图(2h 槽),每条用户消息加权并随时间衰减
- 细/中/粗三层(单片星期几、工作日-周末、全局)按有效样本降级,
细层向全局做 n/(n+k) 收缩,避免稀疏样本失真
- 冷槽双条件判定(占比 + 原始计数),环形扫描支持跨午夜冷段
- 段内回到原始时间戳:噪声合并后二分求可用空闲上界,扣 buffer 得窗口
- penalty 语义改为"要求的冷段长度",不足时减半放宽三次,
最后退到热度最低段,彻底消除静默停摆
- 用户全活跃时不返回空,冷启动走固定 target_hour
- 状态持久化热度图、last_run_ts、last_iter_summary;重排带一分钟节流
prompt 层
- system 剥离全部动态信息:total_runs、精确时间、上轮摘要、
待审建议计数、记忆库膨胀告警
- 日期降到日粒度,同日内 system 前缀逐字节一致,可命中前缀缓存
- 新增 subconscious_get_context 聚合工具,单项失败标 stale 不中断
- 规则中强制每轮先调用它,未调用视为本轮无效
- 旧 penalty 升级时清零一次(语义不可换算)
其他
- 新增 18 个 idle_* 配置项,use_idle_window_planner 可控回退旧路径
- tests/test_schedule.py 13 条单测;pyproject 启用 pytest
- 版本 0.7.1 -> 0.8.0
---
amrita_plugin_memory/config.py | 53 ++
amrita_plugin_memory/rethinking/_state.py | 21 +
amrita_plugin_memory/rethinking/consts.py | 39 +-
amrita_plugin_memory/rethinking/hooks.py | 4 +-
amrita_plugin_memory/rethinking/runner.py | 171 +++---
amrita_plugin_memory/rethinking/schedule.py | 573 ++++++++++++++++++++
amrita_plugin_memory/rethinking/schemas.py | 12 +
amrita_plugin_memory/rethinking/tools.py | 92 +++-
pyproject.toml | 4 +-
tests/test_schedule.py | 161 ++++++
10 files changed, 1026 insertions(+), 104 deletions(-)
create mode 100644 amrita_plugin_memory/rethinking/schedule.py
create mode 100644 tests/test_schedule.py
diff --git a/amrita_plugin_memory/config.py b/amrita_plugin_memory/config.py
index ff5f8cb..0a93e30 100644
--- a/amrita_plugin_memory/config.py
+++ b/amrita_plugin_memory/config.py
@@ -42,6 +42,59 @@ class SubconsciousConfig(BaseModel):
rethink_max_delay_minutes: int = Field(
default=1440, ge=60, le=10080, description="惩罚延迟上限(分钟),默认1天"
)
+ use_idle_window_planner: bool = Field(
+ default=True,
+ description="是否启用空闲窗口调度(热度图 + 冷段二分);关闭后回退到旧的固定延迟方案",
+ )
+ idle_timezone: str = Field(
+ default="Asia/Shanghai", description="热度图与窗口计算使用的时区"
+ )
+ idle_fine_tau_days: float = Field(
+ default=28.0, ge=1.0, description="单片星期几层的衰减常数(天)"
+ )
+ idle_coarse_tau_days: float = Field(
+ default=14.0, ge=1.0, description="全局层的衰减常数(天)"
+ )
+ idle_shrink_k: float = Field(
+ default=4.0, ge=0.0, description="细层向全局收缩的强度,越大越保守"
+ )
+ idle_min_sample_days_fine: int = Field(
+ default=6, ge=1, description="启用单片星期几层所需的最少活跃天数"
+ )
+ idle_min_sample_days_mid: int = Field(
+ default=3, ge=1, description="启用工作日/周末层所需的最少活跃天数"
+ )
+ idle_cold_pct_max: float = Field(
+ default=0.05, ge=0.0, le=1.0, description="冷槽的占比上限"
+ )
+ idle_cold_abs_max: float = Field(
+ default=1.0, ge=0.0, description="冷槽的原始计数上限,避免稀疏样本全部判冷"
+ )
+ idle_target_hour: int = Field(
+ default=3, ge=0, le=23, description="偏好触发时刻(本地时区小时)"
+ )
+ idle_base_slots: int = Field(
+ default=3, ge=1, description="首次计划要求的最短冷段(槽,1 槽=2h)"
+ )
+ idle_max_slots: int = Field(default=6, ge=1, description="冷段要求的封顶槽数")
+ idle_penalty_multiplier: float = Field(
+ default=1.5, ge=1.0, le=10.0, description="惩罚对冷段要求的放大倍率"
+ )
+ idle_max_relax_steps: int = Field(
+ default=3, ge=0, le=6, description="冷段不足时允许的减半放宽次数"
+ )
+ idle_buffer_minutes: int = Field(
+ default=15, ge=0, description="窗口起止的安全缓冲(分钟)"
+ )
+ idle_run_estimate_minutes: int = Field(
+ default=10, ge=0, description="单轮 DMN 预计耗时(分钟),用于扣减可用窗口"
+ )
+ idle_fallback_minutes: int = Field(
+ default=120, ge=10, description="冷启动或规划失败时的固定窗口长度(分钟)"
+ )
+ idle_noise_gap_minutes: int = Field(
+ default=15, ge=0, description="连发消息合并为单点的最小间隔(分钟)"
+ )
prompt_file: str = Field(
default="prompt/subconscious_main.md.jinja2",
description="主推理提示词文件名,相对于 config/amrita_plugin_memory/",
diff --git a/amrita_plugin_memory/rethinking/_state.py b/amrita_plugin_memory/rethinking/_state.py
index 75917b1..c44c3ca 100644
--- a/amrita_plugin_memory/rethinking/_state.py
+++ b/amrita_plugin_memory/rethinking/_state.py
@@ -18,6 +18,9 @@
_penalty_count: int = 0
# 知识建议队列 — 对话 LLM 提议创建/更新知识,潜意识 Agent 下一轮审查
_knowledge_suggestions: list[dict[str, str]] = []
+# 上一轮 iter_stop 的摘要与结束时间 — 供 subconscious_get_context 读取
+_last_iter_summary: str = ""
+_last_run_at: float = 0.0
# 隔离的工具管理器 — 所有 @on_tools 装饰器通过 bound_to 注册到这里,不污染全局 ToolsManager
_SUBCONSCIOUS_TOOLS = MultiToolsManager()
@@ -89,3 +92,21 @@ def set_knowledge_suggestions(suggestions: list[dict[str, str]]) -> None:
def add_knowledge_suggestion(suggestion: dict[str, str]) -> None:
global _knowledge_suggestions
_knowledge_suggestions.append(suggestion)
+
+
+def get_last_iter_summary() -> str:
+ return _last_iter_summary
+
+
+def set_last_iter_summary(text: str) -> None:
+ global _last_iter_summary
+ _last_iter_summary = text
+
+
+def get_last_run_at() -> float:
+ return _last_run_at
+
+
+def set_last_run_at(ts: float) -> None:
+ global _last_run_at
+ _last_run_at = ts
diff --git a/amrita_plugin_memory/rethinking/consts.py b/amrita_plugin_memory/rethinking/consts.py
index 8458bb8..c77c62e 100644
--- a/amrita_plugin_memory/rethinking/consts.py
+++ b/amrita_plugin_memory/rethinking/consts.py
@@ -6,8 +6,9 @@
DEFAULT_SUBCONSCIOUS_PROMPT = """\
{# 潜意识推理循环 - 系统提示词 #}
-{# 变量: last_run, current_time, target_user_id, total_runs, character_prompt #}
-{# 编辑此文件即可自定义提示词,无需改动代码。 #}
+{# 变量: character_prompt, target_user_id, current_date #}
+{# 本模板只放不变量:所有会随循环变化的信息(时间、待办计数、热度统计) #}
+{# 一律通过 subconscious_get_context 按需拉取,不要写回这里。 #}
你是用户的记忆管家(Memory Curator),在后台静默运行。你的职责是整理、优化和组织用户的长期记忆库。
@@ -16,9 +17,8 @@
{{ character_prompt }}
{% endif %}
-当前时间: {{ current_time }}
+日期: {{ current_date }}
用户ID: {{ target_user_id }}
-这是第 {{ total_runs }} 次推理循环。
@@ -27,15 +27,20 @@
1. 你**必须**使用提供的工具来检查和操作用户的记忆库。不要凭空想象或推测记忆内容。
-2. 每次推理循环中,你应该:
+2. 每轮**第一件事**必须调用 subconscious_get_context 获取当前环境
+ (精确时间、距上轮间隔、上次摘要、待审查建议、记忆库规模)。
+ 不调用则视为本轮无效,不要凭上一轮的印象行动。
+3. 之后你应该:
a. 先调用 subconscious_list_memory 或 subconscious_read_memory 了解记忆库当前状态
b. 识别需要处理的问题(重复记忆、矛盾记忆、过期信息、缺失标签等)
c. 使用 subconscious_write_memory / subconscious_update_memory / subconscious_delete_memory 执行整理操作
-3. 当本轮整理工作完成(或没有新发现需要处理),你**必须**调用 subconscious_iter_stop 来结束本次推理循环。
-4. 利用 subconscious_read_sessions 和 subconscious_read_chat_context 了解用户近况。
-5. 利用 subconscious_get_profile / subconscious_update_profile 渐进式构建用户画像。
-6. 不要在单次循环中做太多事情, 保持每次循环简洁高效。
-7. 所有记忆操作都作用于目标用户(user_id={{ target_user_id }}), scope 固定为 user。
+4. 当本轮整理工作完成(或没有新发现需要处理),你**必须**调用 subconscious_iter_stop 来结束本次推理循环。
+5. 利用 subconscious_read_sessions 和 subconscious_read_chat_context 了解用户近况。
+6. 利用 subconscious_get_profile / subconscious_update_profile 渐进式构建用户画像。
+7. 不要在单次循环中做太多事情, 保持每次循环简洁高效。
+8. 所有记忆操作都作用于目标用户(user_id={{ target_user_id }}), scope 固定为 user。
+9. 记忆库规模、待审查建议数量这类数字只以 subconscious_get_context 的返回为准,
+ 不要假设它们在本轮内保持不变,也不要自己推算轮数或进度。
@@ -46,13 +51,6 @@
- 过时信息: 识别明显过时的信息, 考虑更新或降低重要性
-{% if last_run %}
-
-上一次推理循环的摘要:
-{{ last_run }}
-
-{% endif %}
-
你不是在和用户对话。你只通过工具调用(tool calls)和最终调用 subconscious_iter_stop 来表达你的工作。
在 iter_stop 的 summary 字段中, 请简要描述你本轮做了什么(例如: "检查了5条记忆, 合并了2条重复项, 为3条记忆补充了标签")。
@@ -214,7 +212,7 @@ async def load_character_prompt() -> str:
| 文件 | 用途 | 可用变量 |
| ----------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------ |
-| `subconscious_main.md.jinja2` | 主推理循环提示词 | `character_prompt`, `last_run`, `current_time`, `target_user_id`, `total_runs` |
+| `subconscious_main.md.jinja2` | 主推理循环提示词 | `character_prompt`, `current_date`, `target_user_id` |
| `subconscious_send.md.jinja2` | 向用户主动发消息的生成模板 | `character_prompt`, `intent`, `memory_context`, `current_time` |
| `knowledge_guide.md.jinja2` | 全局知识库使用指南(注入到主 prompt 末尾) | 无(纯静态文本) |
| `profile_guide.md.jinja2` | 用户画像渐进式构建指南(注入到主 prompt 末尾) | 无(纯静态文本) |
@@ -233,8 +231,9 @@ async def load_character_prompt() -> str:
## 注意事项
- 不要删除 `{% if character_prompt %}` 块——它用于注入 Bot 的角色设定
-- `last_run` 变量只在非首次循环时有值
-- `current_time` 格式为 `YYYY-MM-DD HH:MM:SS UTC`
+- `current_date` 只到日粒度,用来保持 system 前缀跨轮稳定(可命中缓存)
+- 精确时间、上轮摘要、待办计数、记忆库规模一律不进模板,
+ 由工具 `subconscious_get_context` 按需拉取,避免把过期快照写进 system
- `target_user_id` 是配置中指定的数字 ID 字符串
## 默认行为
diff --git a/amrita_plugin_memory/rethinking/hooks.py b/amrita_plugin_memory/rethinking/hooks.py
index 4986817..265dbd2 100644
--- a/amrita_plugin_memory/rethinking/hooks.py
+++ b/amrita_plugin_memory/rethinking/hooks.py
@@ -1,5 +1,7 @@
"""Hook 注册与生命周期管理。"""
+import time
+
from amrita.plugins.chat.runtime import try_get_amrita_ctx
from amrita_core.hook.event import PreCompletionEvent
from amrita_core.hook.on import on_precompletion
@@ -25,7 +27,7 @@ async def _subconscious_pre_hook(event: PreCompletionEvent) -> None:
return
try:
- await runner.cancel_and_reschedule()
+ await runner.cancel_and_reschedule(chat_ts=time.time())
except Exception as e:
# 调度失败不能影响用户这条消息的处理
logger.opt(exception=e, colors=True, raw=True).error(
diff --git a/amrita_plugin_memory/rethinking/runner.py b/amrita_plugin_memory/rethinking/runner.py
index b9a2af2..ccfa6a0 100644
--- a/amrita_plugin_memory/rethinking/runner.py
+++ b/amrita_plugin_memory/rethinking/runner.py
@@ -5,6 +5,7 @@
import asyncio
import contextlib
import json
+import time
from datetime import datetime, timedelta, timezone
from typing import Any
@@ -31,7 +32,6 @@
from ..config import DATA_PATH, SubconsciousConfig
from ..keys import make_scope_id
from ..models import SubconsciousState
-from ..vector import AsyncUserMemory, get_db_conn
from . import _state
from .backend import SubconsciousBackend
from .consts import (
@@ -43,9 +43,12 @@
load_character_prompt,
)
from .knowledge import KnowledgeBaseManager
+from .schedule import IdlePlanner, describe
from .types import ProfileResult, SessionSummary
_STATE_UID = "amrita_memory"
+#: 重排节流窗口(秒)
+_REPLAN_THROTTLE_SECONDS = 60.0
_LEGACY_REPO_UID = (
"amrita_memory" # 旧版本元状态占用的 Bot Memory 表 uid(仅用于升级迁移)
)
@@ -79,6 +82,11 @@ def __init__(self, config: SubconsciousConfig) -> None:
# session 摘要缓存(LRU):session DB id -> 摘要文本,最多 128 条
self._session_cache: LRUCache[int, str] = LRUCache(128)
self._profile_path = DATA_PATH / "user_profile.md"
+ # 空闲窗口规划器(热度图 + 冷段二分)
+ self._planner = IdlePlanner(config)
+ # 重排节流:一分钟内重复触发只更新惩罚与热度,不重复规划
+ self._last_replan_at: float = 0.0
+ self._replan_throttle: float = _REPLAN_THROTTLE_SECONDS
@property
def is_running(self) -> bool:
@@ -123,26 +131,47 @@ def stop(self) -> None:
if self._manual_task is not None and not self._manual_task.done():
self._manual_task.cancel()
- async def cancel_and_reschedule(self) -> None:
- """取消当前计划,递增惩罚计数器,重新计算延迟并调度一次运行。
+ async def cancel_and_reschedule(self, chat_ts: float | None = None) -> None:
+ """取消当前计划并重新规划下一个空闲窗口。
- 用户每次发消息时调用此方法。不 dump/恢复上下文——
+ 用户每次发消息时调用:先给热度图记一次观测,再递增惩罚,
+ 然后按新的冷段门槛重新选窗口。不 dump/恢复上下文——
用户聊完天后有新内容,旧推理上下文应直接丢弃。
"""
+ # 热度图观测:每条消息都算,加深该时间窗口的热点
with contextlib.suppress(Exception):
- scheduler.remove_job(self._job_id)
+ self._planner.observe(chat_ts)
penalty = _state.increment_penalty()
- base = self._config.rethink_base_delay_minutes
- multiplier = self._config.rethink_penalty_multiplier
- cap = self._config.rethink_max_delay_minutes
- raw = base * (multiplier ** (penalty - 1))
- delay_minutes = min(raw, cap)
- logger.info(
- f"[Subconscious] Rescheduled: penalty=#{penalty}, "
- f"delay={delay_minutes:.0f}min (raw={raw:.0f}min, cap={cap}min)"
- )
- self._schedule_once(int(delay_minutes * 60))
- # 惩罚计数落盘,避免重启后丢掉已累积的退避
+ now_mono = time.monotonic()
+ if now_mono - self._last_replan_at < self._replan_throttle:
+ # 同窗口内的后续消息不重排,避免每条消息都扫一遍热度图
+ with contextlib.suppress(Exception):
+ await self._save_state()
+ return
+ self._last_replan_at = now_mono
+ with contextlib.suppress(Exception):
+ scheduler.remove_job(self._job_id)
+ if self._config.use_idle_window_planner:
+ plan = self._planner.plan(penalty)
+ logger.info(
+ f"[Subconscious] Rescheduled: penalty=#{penalty}, {describe(plan)}"
+ )
+ delay_seconds = int(
+ max(60.0, (plan.start - datetime.now(timezone.utc)).total_seconds())
+ )
+ else:
+ base = self._config.rethink_base_delay_minutes
+ multiplier = self._config.rethink_penalty_multiplier
+ cap = self._config.rethink_max_delay_minutes
+ raw = base * (multiplier ** (penalty - 1))
+ delay_minutes = min(raw, cap)
+ logger.info(
+ f"[Subconscious] Rescheduled(legacy): penalty=#{penalty}, "
+ f"delay={delay_minutes:.0f}min (raw={raw:.0f}min, cap={cap}min)"
+ )
+ delay_seconds = int(delay_minutes * 60)
+ self._schedule_once(delay_seconds)
+ # 惩罚计数与热度图落盘,避免重启后丢掉已累积的退避
with contextlib.suppress(Exception):
await self._save_state()
@@ -173,6 +202,9 @@ async def _run(self) -> None:
return
self._is_running = True
try:
+ # 窗口已实际占用:记一次观测,避免下一轮重复选同一段
+ with contextlib.suppress(Exception):
+ self._planner.note_window_taken(time.time())
await self._run_cycle()
finally:
self._is_running = False
@@ -187,9 +219,9 @@ async def _run_cycle(self) -> None:
return
prompt_text = await self._load_prompt()
user_msg_text = (
- f"现在是第 {self._total_runs + 1} 次潜意识推理循环。"
- f"请检查用户 (ID: {self._config.target_user_id}) 的记忆库,执行必要的整理操作。"
- f"完成后务必调用 subconscious_iter_stop。"
+ f"请先调用 subconscious_get_context 了解当前环境,"
+ f"再检查用户 (ID: {self._config.target_user_id}) 的记忆库,"
+ f"执行必要的整理操作。完成后务必调用 subconscious_iter_stop。"
)
train = Message(role="system", content=prompt_text)
user_input = user_msg_text
@@ -210,7 +242,7 @@ async def _run_cycle(self) -> None:
self._chat_obj = chat_obj
try:
- logger.info(f"[Subconscious] Cycle #{self._total_runs + 1}")
+ logger.info("[Subconscious] Cycle start")
chat_obj.begin()
await chat_obj
except Exception as e:
@@ -245,6 +277,7 @@ async def _post_process(self, chat_obj: ChatObject) -> None:
f"[Subconscious] Abstract saved ({len(self._last_abstracts)}/{self._config.max_abstracts})"
)
self._total_runs += 1
+ _state.set_last_run_at(datetime.now(timezone.utc).timestamp())
# 先持久化,任一失败则不重置惩罚(保留重试机会)
save_ok = True
@@ -368,6 +401,17 @@ async def _load_state(self) -> None:
suggestions = payload.get("knowledge_suggestions", [])
if isinstance(suggestions, list):
_state.set_knowledge_suggestions(suggestions)
+ _state.set_last_iter_summary(str(payload.get("last_iter_summary", "")))
+ _state.set_last_run_at(float(payload.get("last_run_ts", 0.0) or 0.0))
+ self._planner.load(payload)
+ # 旧 penalty 语义是"被推迟的次数",与新语义(要求的冷段长度)不可换算,
+ # 升级后清零一次,避免带着旧值把门槛顶到上限
+ if "idle_planner" not in payload and _state.get_penalty_count() > 0:
+ logger.info(
+ "[Subconscious] Migrate: reset legacy penalty "
+ f"#{_state.get_penalty_count()} (old delay-based semantics)"
+ )
+ _state.reset_penalty()
except Exception as e:
logger.warning(f"[Subconscious] Load state failed: {e}")
@@ -404,19 +448,27 @@ async def _migrate_legacy_state(self) -> dict[str, Any]:
async def _save_state(self) -> None:
"""保存元状态到 SubconsciousState 表。"""
- await self._write_state_payload(
- _STATE_UID,
- {
- "total_runs": self._total_runs,
- "last_run_time": datetime.now(timezone.utc).isoformat(),
- "penalty": _state.get_penalty_count(),
- "last_abstracts": self._last_abstracts,
- "pending_messages": _state.get_pending(),
- "knowledge_suggestions": _state.get_knowledge_suggestions(),
- },
- )
+ payload: dict[str, Any] = {
+ "total_runs": self._total_runs,
+ "last_run_time": datetime.now(timezone.utc).isoformat(),
+ "last_run_ts": _state.get_last_run_at(),
+ "last_iter_summary": _state.get_last_iter_summary(),
+ "penalty": _state.get_penalty_count(),
+ "last_abstracts": self._last_abstracts,
+ "pending_messages": _state.get_pending(),
+ "knowledge_suggestions": _state.get_knowledge_suggestions(),
+ }
+ payload.update(self._planner.dump())
+ await self._write_state_payload(_STATE_UID, payload)
async def _load_prompt(self) -> str:
+ """渲染 system prompt。
+
+ 只允许不变量进入 system:角色、规则、质量口径、输出格式,
+ 以及静态的画像/知识库指南。精确时间、上轮摘要、待办计数、
+ 记忆库规模等动态信息一律交给 subconscious_get_context 按需拉取,
+ 这样跨轮的 system 前缀逐字节一致,能吃到 provider 的前缀缓存。
+ """
main_path = (self._prompt_dir / self._config.prompt_file).resolve()
kn_path = (self._prompt_dir / self._config.prompt_knowledge_file).resolve()
pr_path = (self._prompt_dir / self._config.prompt_profile_file).resolve()
@@ -427,62 +479,25 @@ async def _load_prompt(self) -> str:
character_prompt = await load_character_prompt()
template: Template = Template(main_path.read_text(encoding="utf-8"))
- now = datetime.now(timezone.utc)
+ # 日粒度日期:每天才变一次,不影响缓存命中,又能给记忆新旧提供锚点
+ current_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
prompt = await asyncio.to_thread(
template.render,
- last_abstracts=self._last_abstracts,
- last_run=(self._last_abstracts[-1] if self._last_abstracts else ""),
- current_time=now.strftime("%Y-%m-%d %H:%M:%S UTC"),
+ current_date=current_date,
target_user_id=self._config.target_user_id or "(未配置)",
- total_runs=self._total_runs,
character_prompt=character_prompt,
)
- # 注入知识库 & 画像指南(Jinja2 渲染,支持变量插值)
- common_vars = {
- "current_time": now.strftime("%Y-%m-%d %H:%M:%S UTC"),
+ # 指南文件仍走 Jinja2,但只提供不变量:旧文件里的 {{ current_time }}
+ # 会被渲染成空串,而不是原样泄漏到 system 里
+ static_vars = {
+ "current_date": current_date,
"target_user_id": self._config.target_user_id or "(未配置)",
}
- prompt += "\n\n" + await asyncio.to_thread(
- Template(kn_path.read_text(encoding="utf-8")).render, **common_vars
- )
- prompt += "\n\n" + await asyncio.to_thread(
- Template(pr_path.read_text(encoding="utf-8")).render, **common_vars
- )
-
- # 待审查的知识建议 — 注入提示让 Agent 主动拉取
- suggestions = _state.get_knowledge_suggestions()
- if suggestions:
- prompt += (
- f"\n\n📋 有 {len(suggestions)} 条来自对话 LLM 的知识建议等待审查。"
- f"请调用 subconscious_read_suggestions 获取详情,"
- f"审查后对值得保留的用 subconscious_knowledge_create/update 实际写入。"
+ for guide in (kn_path, pr_path):
+ prompt += "\n\n" + await asyncio.to_thread(
+ Template(guide.read_text(encoding="utf-8")).render, **static_vars
)
-
- # 膨胀感知:查 ChromaDB 总量,超阈值注入警告
- try:
- pid = make_scope_id(self._config.target_user_id, is_group=False)
- ope = AsyncUserMemory(get_db_conn())
- await ope.init()
- # 先取轻量计数,超阈值才拉全量算分布
- total = await ope.count_user_notes(pid)
- if total > self._config.memory_warn_threshold:
- result = await ope.get_all_notes(pid, include=["metadatas"])
- low_count = sum(
- 1
- for m in (result.get("metadatas") or [])
- if isinstance(m, dict) and m.get("importance") == "low"
- )
- prompt += (
- f"\n\n⚠️ 当前记忆库有 {total} 条记忆"
- f"(其中低重要性 {low_count} 条)。"
- f"建议本轮优先调用 subconscious_get_memory_stats 分析分布,"
- f"然后用 subconscious_duplicate_helper 获取待整理列表,"
- f"进行合并去重和低质记忆清理。"
- )
- except Exception as e:
- logger.debug(f"[Subconscious] Memory stats query failed: {e}")
-
return prompt
async def _read_recent_sessions(self, n: int = 5) -> list[SessionSummary]:
diff --git a/amrita_plugin_memory/rethinking/schedule.py b/amrita_plugin_memory/rethinking/schedule.py
new file mode 100644
index 0000000..f084105
--- /dev/null
+++ b/amrita_plugin_memory/rethinking/schedule.py
@@ -0,0 +1,573 @@
+"""DMN 空闲窗口调度:7×12 热度图 + 冷段扫描 + 段内二分。
+
+设计要点
+- 观测:用户每条消息让对应 (星期几, 2h 槽) 权重 +1,并随时间衰减。
+- 分层:fine(单片星期几) / mid(工作日-周末) / coarse(全局),逐级降级。
+- 收缩:fine/mid 向 coarse 按有效样本天数加权,避免稀薄样本失真。
+- 规划:环形找连续冷槽 -> 段内用原始时间戳做分钟级二分 -> 扣 buffer 得可用窗口。
+- 保底:冷段不足门槛时逐次减半放宽,最后强制取最长冷段。
+
+本模块不依赖 nonebot,纯数据 + 纯函数,便于独立单测。
+"""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Sequence
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+from typing import Any
+from zoneinfo import ZoneInfo
+
+SLOT_MINUTES = 120
+SLOTS_PER_DAY = 12
+DAYS = 7
+CELLS = DAYS * SLOTS_PER_DAY
+MINUTES_PER_DAY = SLOT_MINUTES * SLOTS_PER_DAY
+
+#: 最近消息时间戳缓冲上限(分钟级细化用,不能无限涨)
+_MAX_RECENT = 500
+#: 活跃日期登记上限
+_MAX_SEEN_DAYS = 240
+
+_WD_NAMES = ("周一", "周二", "周三", "周四", "周五", "周六", "周日")
+
+
+def _cell(wd: int, slot: int) -> int:
+ return wd * SLOTS_PER_DAY + (slot % SLOTS_PER_DAY)
+
+
+def slot_of(ts: float, tz: ZoneInfo) -> tuple[int, int]:
+ """把 unix 时间戳换成 (星期几 0=周一, 槽位 0..11)。"""
+ dt = datetime.fromtimestamp(ts, tz)
+ wd = dt.weekday()
+ slot = (dt.hour * 60 + dt.minute) // SLOT_MINUTES
+ return wd, slot
+
+
+# ---------------------------------------------------------------- 纯函数工具
+
+
+def merge_noise(ts: Sequence[float], gap_seconds: float) -> list[float]:
+ """把一波连发的消息压成单点:间距小于 gap 的后来者直接并掉。"""
+ out: list[float] = []
+ prev: float | None = None
+ for t in sorted(ts):
+ if prev is not None and t - prev < gap_seconds:
+ continue
+ out.append(t)
+ prev = t
+ return out
+
+
+def free_gaps(ts: Sequence[float], start: float, end: float) -> list[tuple[float, float]]:
+ """返回 [start, end) 内的全部无消息区间。"""
+ if end <= start:
+ return []
+ inner = sorted(t for t in ts if start < t < end)
+ pts = [start, *inner, end]
+ return [(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
+
+
+def longest_free(
+ ts: Sequence[float], start: float, end: float
+) -> tuple[float, float]:
+ """[start, end) 内最长的无消息区间。"""
+ gaps = free_gaps(ts, start, end)
+ if not gaps:
+ return start, end
+ return max(gaps, key=lambda g: g[1] - g[0])
+
+
+def feasible_minutes(
+ ts: Sequence[float], start: float, end: float, minutes: int
+) -> bool:
+ """是否存在一段连续 >= minutes 的空闲。(随 minutes 单调递减)"""
+ if minutes <= 0:
+ return end > start
+ need = minutes * 60
+ return any(b - a >= need for a, b in free_gaps(ts, start, end))
+
+
+def bisect_free_minutes(
+ ts: Sequence[float], start: float, end: float, hi: int | None = None
+) -> int:
+ """二分求窗口内最长连续空闲(分钟)。判定函数单调,收敛到分钟精度。"""
+ if end <= start:
+ return 0
+ top = hi if hi is not None else int((end - start) // 60)
+ lo = 0
+ while lo < top:
+ mid = (lo + top + 1) // 2
+ if feasible_minutes(ts, start, end, mid):
+ lo = mid
+ else:
+ top = mid - 1
+ return lo
+
+
+def _normalize(values: Sequence[float]) -> list[float]:
+ total = sum(values)
+ if total <= 1e-9:
+ return [0.0] * len(values)
+ return [v / total for v in values]
+
+
+def _runs(mask: Sequence[bool], limit: int) -> list[tuple[int, int]]:
+ """环形找连续 True 段,返回 (起点, 长度),长度不超过 limit。"""
+ n = len(mask)
+ if n == 0:
+ return []
+ if all(mask):
+ return [(0, limit)]
+ if not any(mask):
+ return []
+ start = mask.index(False)
+ seq = list(mask[start:]) + list(mask[:start])
+ out: list[tuple[int, int]] = []
+ i = 0
+ while i < len(seq):
+ if not seq[i]:
+ i += 1
+ continue
+ j = i
+ while j < len(seq) and seq[j]:
+ j += 1
+ out.append(((start + i) % n, min(j - i, limit)))
+ i = j
+ return out
+
+
+# ---------------------------------------------------------------- 规划结果
+
+
+@dataclass
+class Plan:
+ """一次调度规划的结果。"""
+
+ start: datetime
+ end: datetime
+ length_minutes: int
+ tier: str = "coarse"
+ relaxed_steps: int = 0
+ forced: bool = False
+ penalty: int = 0
+ note: str = ""
+
+ @property
+ def delay_seconds(self) -> int:
+ return max(1, int((self.start - datetime.now(timezone.utc)).total_seconds()))
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "start": self.start.isoformat(),
+ "end": self.end.isoformat(),
+ "length_minutes": self.length_minutes,
+ "tier": self.tier,
+ "relaxed_steps": self.relaxed_steps,
+ "forced": self.forced,
+ "penalty": self.penalty,
+ "note": self.note,
+ }
+
+
+@dataclass
+class PlannerState:
+ """可持久化的热度图状态。"""
+
+ fine: list[float] = field(default_factory=lambda: [0.0] * CELLS)
+ coarse: list[float] = field(default_factory=lambda: [0.0] * SLOTS_PER_DAY)
+ seen_days: list[str] = field(default_factory=list)
+ recent: list[float] = field(default_factory=list)
+ last_update: float = 0.0
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "fine": [round(v, 6) for v in self.fine],
+ "coarse": [round(v, 6) for v in self.coarse],
+ "seen_days": self.seen_days[-_MAX_SEEN_DAYS:],
+ "recent": self.recent[-_MAX_RECENT:],
+ "last_update": self.last_update,
+ }
+
+
+# ---------------------------------------------------------------- 规划器
+
+
+class IdlePlanner:
+ """基于热度图的空闲窗口规划器。"""
+
+ def __init__(self, cfg: Any) -> None:
+ self._cfg = cfg
+ tz_name = getattr(cfg, "idle_timezone", "Asia/Shanghai")
+ try:
+ self.tz = ZoneInfo(tz_name)
+ except Exception:
+ self.tz = timezone(timedelta(hours=8))
+ self.state = PlannerState()
+
+ self.noise_gap = getattr(cfg, "idle_noise_gap_minutes", 15) * 60
+ self.tau_fine = max(1.0, getattr(cfg, "idle_fine_tau_days", 28))
+ self.tau_coarse = max(1.0, getattr(cfg, "idle_coarse_tau_days", 14))
+ self.shrink_k = max(0.0, getattr(cfg, "idle_shrink_k", 4))
+ self.cold_pct_max = getattr(cfg, "idle_cold_pct_max", 0.05)
+ self.cold_abs_max = getattr(cfg, "idle_cold_abs_max", 1.0)
+ self.min_weeks_fine = getattr(cfg, "idle_min_sample_days_fine", 6)
+ self.min_weeks_mid = getattr(cfg, "idle_min_sample_days_mid", 3)
+ self.target_hour = getattr(cfg, "idle_target_hour", 3)
+ self.base_slots = getattr(cfg, "idle_base_slots", 3)
+ self.max_slots = getattr(cfg, "idle_max_slots", 6)
+ self.multiplier = getattr(cfg, "idle_penalty_multiplier", 1.5)
+ self.max_relax = getattr(cfg, "idle_max_relax_steps", 3)
+ self.buffer_minutes = getattr(cfg, "idle_buffer_minutes", 15)
+ self.run_estimate = getattr(cfg, "idle_run_estimate_minutes", 10)
+ self.fallback_minutes = getattr(cfg, "idle_fallback_minutes", 120)
+ self._last_plan: Plan | None = None
+
+ # ------------------------------------------------------------ 持久化
+
+ def load(self, payload: dict[str, Any]) -> None:
+ raw = payload.get("idle_planner") if isinstance(payload, dict) else None
+ if not isinstance(raw, dict):
+ return
+ try:
+ fine = [float(v) for v in raw.get("fine", [])]
+ coarse = [float(v) for v in raw.get("coarse", [])]
+ if len(fine) == CELLS:
+ self.state.fine = fine
+ if len(coarse) == SLOTS_PER_DAY:
+ self.state.coarse = coarse
+ seen = raw.get("seen_days", [])
+ if isinstance(seen, list):
+ self.state.seen_days = [str(d) for d in seen][-_MAX_SEEN_DAYS:]
+ recent = raw.get("recent", [])
+ if isinstance(recent, list):
+ self.state.recent = [float(v) for v in recent][-_MAX_RECENT:]
+ self.state.last_update = float(raw.get("last_update", 0.0))
+ except (TypeError, ValueError):
+ self.state = PlannerState()
+
+ def dump(self) -> dict[str, Any]:
+ return {"idle_planner": self.state.to_dict()}
+
+ # ------------------------------------------------------------ 观测
+
+ def _decay(self, now_ts: float) -> None:
+ if self.state.last_update <= 0:
+ self.state.last_update = now_ts
+ return
+ dt_days = (now_ts - self.state.last_update) / 86400
+ if dt_days <= 1e-6:
+ return
+ f_fine = 2 ** (-dt_days / self.tau_fine)
+ f_coarse = 2 ** (-dt_days / self.tau_coarse)
+ self.state.fine = [v * f_fine for v in self.state.fine]
+ self.state.coarse = [v * f_coarse for v in self.state.coarse]
+ self.state.last_update = now_ts
+
+ def observe(self, ts: float | None = None) -> None:
+ """记录一条用户消息,加深该时间窗口的热点。"""
+ now_ts = time.time() if ts is None else float(ts)
+ self._decay(now_ts)
+ wd, slot = slot_of(now_ts, self.tz)
+ self.state.fine[_cell(wd, slot)] += 1.0
+ self.state.coarse[slot] += 1.0
+ day_key = datetime.fromtimestamp(now_ts, self.tz).strftime("%Y-%m-%d")
+ if day_key not in self.state.seen_days:
+ self.state.seen_days.append(day_key)
+ if len(self.state.seen_days) > _MAX_SEEN_DAYS:
+ self.state.seen_days = self.state.seen_days[-_MAX_SEEN_DAYS:]
+ self.state.recent.append(now_ts)
+ if len(self.state.recent) > _MAX_RECENT:
+ self.state.recent = self.state.recent[-_MAX_RECENT:]
+
+ def last_plan(self) -> Plan | None:
+ return self._last_plan
+
+ def last_plan_dict(self) -> dict[str, Any] | None:
+ plan = self._last_plan
+ if plan is None:
+ return None
+ local_end = plan.end.astimezone(self.tz)
+ return {
+ "window_start": plan.start.strftime("%Y-%m-%d %H:%M"),
+ "window_end": local_end.strftime("%Y-%m-%d %H:%M"),
+ "planned_minutes": plan.length_minutes,
+ "tier": plan.tier,
+ "relaxed_steps": plan.relaxed_steps,
+ "forced": plan.forced,
+ "note": plan.note,
+ }
+
+ def note_window_taken(self, start_ts: float) -> None:
+ """窗口实际被占用后,把该时段计入一次观测(避免反复选同一段)。"""
+ self.observe(start_ts + 60)
+
+ # ------------------------------------------------------------ 分层分值
+
+ def active_days_of(self, weekdays: Sequence[int]) -> int:
+ cnt = 0
+ keys = set(self.state.seen_days)
+ for key in keys:
+ try:
+ dt = datetime.strptime(key, "%Y-%m-%d")
+ except ValueError:
+ continue
+ if dt.weekday() in weekdays:
+ cnt += 1
+ return cnt
+
+ def _shrink(self, cells: Sequence[float], n_days: int) -> list[float]:
+ alpha = n_days / (n_days + self.shrink_k) if (n_days + self.shrink_k) > 0 else 0.0
+ base = self.state.coarse
+ return [alpha * cells[i] + (1 - alpha) * base[i] for i in range(SLOTS_PER_DAY)]
+
+ def tier_table(self, tier: str, wd: int) -> tuple[list[float], list[float], int]:
+ """返回 (槽位权重, 槽位原始计数, 有效样本天数)。"""
+ if tier == "fine":
+ cells = self.state.fine[wd * SLOTS_PER_DAY : (wd + 1) * SLOTS_PER_DAY]
+ n_days = self.active_days_of([wd])
+ return self._shrink(cells, n_days), list(cells), n_days
+ if tier == "mid":
+ days = [0, 1, 2, 3, 4] if wd <= 4 else [5, 6]
+ cells = [
+ sum(self.state.fine[_cell(d, s)] for d in days) / len(days)
+ for s in range(SLOTS_PER_DAY)
+ ]
+ n_days = self.active_days_of(days)
+ return self._shrink(cells, n_days), cells, n_days
+ cells = list(self.state.coarse)
+ return cells, cells, self.active_days_of(list(range(DAYS)))
+
+ def _tiers_for(self, wd: int) -> list[str]:
+ tiers: list[str] = []
+ if self.active_days_of([wd]) >= self.min_weeks_fine:
+ tiers.append("fine")
+ days = [0, 1, 2, 3, 4] if wd <= 4 else [5, 6]
+ if self.active_days_of(days) >= self.min_weeks_mid:
+ tiers.append("mid")
+ tiers.append("coarse")
+ return tiers
+
+ # ------------------------------------------------------------ 冷段
+
+ def _cold_runs(
+ self, tier: str, wd: int
+ ) -> list[tuple[int, int, int]]:
+ """返回 [(环形起点槽, 长度, 归属星期几)],长度上限一天。"""
+ weights, counts, _ = self.tier_table(tier, wd)
+ pcts = _normalize(weights)
+ mask = [
+ pcts[s] <= self.cold_pct_max and counts[s] <= self.cold_abs_max
+ for s in range(SLOTS_PER_DAY)
+ ]
+ # 跨午夜:接上下一星期几的表,否则 22:00-08:00 的安静段会被切断
+ w2, c2, _ = self.tier_table(tier, (wd + 1) % DAYS)
+ p2 = _normalize(w2)
+ mask2 = [
+ p2[s] <= self.cold_pct_max and c2[s] <= self.cold_abs_max
+ for s in range(SLOTS_PER_DAY)
+ ]
+ ring = mask + mask2
+ return [(start, length, wd) for start, length in _runs(ring, SLOTS_PER_DAY)]
+
+ def _lightest_run(
+ self, tier: str, wd: int, length_slots: int
+ ) -> tuple[int, int] | None:
+ """环形滑动窗口找总权重最低的连续 length_slots 槽段。"""
+ weights, _, _ = self.tier_table(tier, wd)
+ pcts = _normalize(weights)
+ if sum(pcts) <= 1e-9:
+ return None
+ n = SLOTS_PER_DAY
+ ring = pcts + pcts
+ best: tuple[int, int] | None = None
+ best_sum = float("inf")
+ for start in range(n):
+ total = sum(ring[start : start + length_slots])
+ if total < best_sum - 1e-12:
+ best_sum = total
+ best = (start, length_slots)
+ return best
+
+ def _next_occurrence(self, wd: int, now: datetime) -> datetime:
+ """本周/下周该星期几的 00:00(不早于 now 所在日期)。"""
+ today = now.astimezone(self.tz).replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
+ delta = (wd - today.weekday()) % DAYS
+ cand = today + timedelta(days=delta)
+ if cand + timedelta(days=1) <= now.astimezone(self.tz):
+ cand += timedelta(days=DAYS)
+ return cand
+
+ def _refine(
+ self,
+ start_slot: int,
+ length_slots: int,
+ wd: int,
+ now: datetime,
+ tier: str,
+ penalty: int,
+ relaxed: int,
+ forced: bool,
+ ) -> Plan:
+ """把冷段换成具体时间窗:段内找最长空闲,二分校核,扣 buffer。"""
+ day0 = self._next_occurrence(wd, now)
+ seg_start = day0 + timedelta(minutes=start_slot * SLOT_MINUTES)
+ seg_end = seg_start + timedelta(minutes=length_slots * SLOT_MINUTES)
+ now_local = now.astimezone(self.tz)
+ if seg_end <= now_local:
+ seg_start += timedelta(days=DAYS)
+ seg_end += timedelta(days=DAYS)
+ if seg_start < now_local:
+ seg_start = now_local + timedelta(minutes=1)
+
+ # 只信原始时间戳:连发消息先合并,再取段内最长无消息区间
+ ts = merge_noise(self.state.recent, self.noise_gap)
+ s_ts, e_ts = seg_start.timestamp(), seg_end.timestamp()
+ f_start, f_end = longest_free(ts, s_ts, e_ts)
+ # 二分只为确认这段空闲的上界(段内可能有更细的分布)
+ capacity = bisect_free_minutes(ts, s_ts, e_ts)
+ gap_minutes = int((f_end - f_start) // 60)
+ # 二分给出理论上界,实际可用不得超出真实无消息区间
+ free_minutes = min(capacity, gap_minutes)
+
+ need = self.buffer_minutes + self.run_estimate
+ target = seg_start.replace(
+ hour=self.target_hour, minute=0, second=0, microsecond=0
+ )
+ if target < seg_start:
+ target += timedelta(days=1)
+ # 起点尽量靠近偏好时刻,但必须落在空闲段内且留够运行空间
+ latest_start = max(f_start, f_end - need * 60)
+ start_ts = min(max(f_start, target.timestamp()), latest_start)
+ start_ts = max(start_ts, f_start, now.timestamp() + 60)
+
+ if f_end - start_ts < need * 60:
+ # 段内剩余空间放不下一次运行,标记但不放弃(保底语义)
+ length_minutes = max(1, int((f_end - start_ts) // 60))
+ note = "insufficient_free_space"
+ start_ts = f_start
+ length_minutes = max(1, int((f_end - f_start) // 60))
+ else:
+ # 结束时间严格受空闲段与冷段双重约束
+ length_minutes = int((f_end - start_ts) // 60)
+ if free_minutes < length_minutes: # 保守取真实空闲长度
+ length_minutes = free_minutes
+ note = ""
+
+ start = datetime.fromtimestamp(start_ts, self.tz)
+ end = min(start + timedelta(minutes=length_minutes),
+ datetime.fromtimestamp(f_end, self.tz))
+ return Plan(
+ start=start,
+ end=end,
+ length_minutes=length_minutes,
+ tier=tier,
+ relaxed_steps=relaxed,
+ forced=forced,
+ penalty=penalty,
+ note=note,
+ )
+
+ # ------------------------------------------------------------ 主流程
+
+ def _fallback(self, now: datetime, penalty: int, note: str) -> Plan:
+ local = now.astimezone(self.tz)
+ target = local.replace(
+ hour=self.target_hour, minute=0, second=0, microsecond=0
+ )
+ if target <= local + timedelta(minutes=1):
+ target += timedelta(days=1)
+ return Plan(
+ start=target,
+ end=target + timedelta(minutes=self.fallback_minutes),
+ length_minutes=self.fallback_minutes,
+ tier="fallback",
+ forced=True,
+ penalty=penalty,
+ note=note,
+ )
+
+ def plan(self, penalty: int = 1, now: datetime | None = None) -> Plan:
+ result = self._plan(penalty, now)
+ self._last_plan = result
+ return result
+
+ def _plan(self, penalty: int = 1, now: datetime | None = None) -> Plan:
+ now = now or datetime.now(timezone.utc)
+ local = now.astimezone(self.tz)
+ wd = local.weekday()
+
+ if sum(self.state.coarse) <= 1e-6:
+ return self._fallback(local, penalty, "no_history")
+
+ want = self.base_slots * (self.multiplier ** max(0, penalty - 1))
+ want = max(1, min(round(want), self.max_slots))
+
+ tiers = self._tiers_for(wd)
+ for step in range(self.max_relax + 1):
+ need_slots = max(1, want >> step)
+ for tier in tiers:
+ runs = [
+ r for r in self._cold_runs(tier, wd) if r[1] >= need_slots
+ ]
+ if not runs:
+ continue
+ start_slot, length_slots, owner = max(
+ runs, key=lambda r: (r[1], -r[0])
+ )
+ return self._refine(
+ start_slot,
+ length_slots,
+ owner,
+ now,
+ tier,
+ penalty,
+ step,
+ forced=False,
+ )
+
+ # 全部放宽仍不足:强制取最长冷段
+ best: tuple[int, int, int, str] | None = None
+ for tier in tiers:
+ for run in self._cold_runs(tier, wd):
+ if best is None or run[1] > best[1]:
+ best = (run[0], run[1], run[2], tier)
+ if best is not None:
+ return self._refine(
+ best[0], best[1], best[2], now, best[3], penalty,
+ self.max_relax, forced=True,
+ )
+ # 完全没有冷槽(用户几乎全天活跃):退到热度最低的连续段
+ for tier in tiers:
+ floor_run = self._lightest_run(tier, wd, 1)
+ if floor_run is not None:
+ return self._refine(
+ floor_run[0], floor_run[1], wd, now, tier, penalty,
+ self.max_relax, forced=True,
+ )
+ return self._fallback(local, penalty, "no_cold_segment")
+ return self._refine(
+ best[0],
+ best[1],
+ best[2],
+ now,
+ best[3],
+ penalty,
+ self.max_relax,
+ forced=True,
+ )
+
+
+def describe(plan: Plan) -> str:
+ """日志用的一行描述。"""
+ local = plan.start
+ wd = _WD_NAMES[local.weekday()]
+ return (
+ f"{wd} {local.strftime('%H:%M')}-{plan.end.strftime('%H:%M')} "
+ f"({plan.length_minutes}min, tier={plan.tier}, "
+ f"relax={plan.relaxed_steps}, forced={plan.forced}"
+ f"{', ' + plan.note if plan.note else ''})"
+ )
diff --git a/amrita_plugin_memory/rethinking/schemas.py b/amrita_plugin_memory/rethinking/schemas.py
index 9c45534..c30b69f 100644
--- a/amrita_plugin_memory/rethinking/schemas.py
+++ b/amrita_plugin_memory/rethinking/schemas.py
@@ -192,6 +192,17 @@
),
)
+GET_CONTEXT_SCHEMA = FunctionDefinitionSchema(
+ name="subconscious_get_context",
+ description=(
+ "获取本轮运行环境:精确时间、距上轮间隔、上轮摘要、待审查知识建议、"
+ "记忆库规模与热度窗口。每轮应先调用本工具,再决定做什么。"
+ "返回中的 stale 字段列出本次未取到的项,不要推测它们的值。"
+ ),
+ parameters=FunctionParametersSchema(type="object", properties={}, required=[]),
+)
+
+
GET_MEMORY_STATS_SCHEMA = FunctionDefinitionSchema(
name="subconscious_get_memory_stats",
description="获取记忆库统计概览:总数、重要性分布、标签分布、时间范围。用于判断是否需要压缩。",
@@ -418,6 +429,7 @@
LIST_MEMORY_SCHEMA,
ITER_STOP_SCHEMA,
DUPLICATE_HELPER_SCHEMA,
+ GET_CONTEXT_SCHEMA,
GET_MEMORY_STATS_SCHEMA,
KNOWLEDGE_LIST_SCHEMA,
KNOWLEDGE_READ_SCHEMA,
diff --git a/amrita_plugin_memory/rethinking/tools.py b/amrita_plugin_memory/rethinking/tools.py
index 06a4d3b..c86fc29 100644
--- a/amrita_plugin_memory/rethinking/tools.py
+++ b/amrita_plugin_memory/rethinking/tools.py
@@ -27,6 +27,7 @@
from .schemas import (
DELETE_MEMORY_SCHEMA,
DUPLICATE_HELPER_SCHEMA,
+ GET_CONTEXT_SCHEMA,
GET_MEMORY_STATS_SCHEMA,
GET_PROFILE_SCHEMA,
ITER_STOP_SCHEMA,
@@ -241,9 +242,9 @@ async def subconscious_list_memory(data: dict[str, Any]) -> str:
@on_tools(ITER_STOP_SCHEMA, strict=True, bound_to=_SUBCONSCIOUS_TOOLS)
async def subconscious_iter_stop(data: dict[str, Any]) -> str:
- logger.info(
- f"[Subconscious] iter_stop: summary={str(data.get('summary', ''))[:80]}"
- )
+ summary = str(data.get("summary", ""))
+ _state.set_last_iter_summary(summary)
+ logger.info(f"[Subconscious] iter_stop: summary={summary[:80]}")
return json.dumps(
{"status": "acknowledged", "message": "推理循环已确认结束"}, ensure_ascii=False
)
@@ -580,6 +581,91 @@ async def subconscious_duplicate_helper(data: dict[str, Any]) -> str:
return _tools_err(str(e))
+@on_tools(GET_CONTEXT_SCHEMA, strict=True, bound_to=_SUBCONSCIOUS_TOOLS)
+async def subconscious_get_context(data: dict[str, Any]) -> str:
+ """聚合本轮运行环境;单项失败只标 stale,不中断。"""
+ stale: list[str] = []
+ runner = _state.get_runner()
+ cfg = runner._config if runner is not None else None
+
+ tz_name = getattr(cfg, "idle_timezone", "Asia/Shanghai") if cfg else "Asia/Shanghai"
+ try:
+ from zoneinfo import ZoneInfo
+
+ tz = ZoneInfo(tz_name)
+ except Exception:
+ tz = timezone.utc
+ stale.append("timezone")
+
+ now = datetime.now(timezone.utc)
+ local = now.astimezone(tz)
+
+ last_at = _state.get_last_run_at()
+ if last_at > 0:
+ gap_minutes = int((now.timestamp() - last_at) // 60)
+ last_run_iso = datetime.fromtimestamp(last_at, tz).isoformat()
+ else:
+ gap_minutes = None
+ last_run_iso = None
+
+ suggested = _state.get_knowledge_suggestions()
+
+ total = None
+ low_count = None
+ threshold = None
+ memory_hint = ""
+ try:
+ ope = _make_operator()
+ await ope.init()
+ pid = _get_partition_id()
+ total = await ope.count_user_notes(pid)
+ threshold = getattr(cfg, "memory_warn_threshold", 100) if cfg else 100
+ if total > threshold:
+ result = await ope.get_all_notes(pid, include=["metadatas"])
+ low_count = sum(
+ 1
+ for m in (result.get("metadatas") or [])
+ if isinstance(m, dict) and m.get("importance") == "low"
+ )
+ memory_hint = (
+ f"记忆库已有 {total} 条(低重要性 {low_count} 条),"
+ "建议本轮优先用 subconscious_duplicate_helper 整理合并。"
+ )
+ except Exception as e:
+ logger.warning(f"[Subconscious] get_context memory stats failed: {e}")
+ stale.append("memory_stats")
+
+ plan_info = None
+ try:
+ if runner is not None and getattr(runner, "_planner", None) is not None:
+ plan_info = runner._planner.last_plan_dict()
+ except Exception:
+ stale.append("idle_plan")
+
+ return json.dumps(
+ {
+ "status": "success",
+ "now": local.strftime("%Y-%m-%d %H:%M:%S"),
+ "timezone": tz_name,
+ "weekday": local.strftime("%A"),
+ "last_run_at": last_run_iso,
+ "minutes_since_last_run": gap_minutes,
+ "last_run_summary": _state.get_last_iter_summary() or None,
+ "pending_knowledge_suggestions": {
+ "count": len(suggested),
+ "ids": [s.get("kid") or s.get("title") or "?" for s in suggested][:20],
+ },
+ "memory_total": total,
+ "memory_low_importance": low_count,
+ "memory_warn_threshold": threshold,
+ "memory_hint": memory_hint,
+ "idle_plan": plan_info,
+ "stale": stale,
+ },
+ ensure_ascii=False,
+ )
+
+
@on_tools(GET_MEMORY_STATS_SCHEMA, strict=True, bound_to=_SUBCONSCIOUS_TOOLS)
async def subconscious_get_memory_stats(data: dict[str, Any]) -> str:
"""返回记忆库统计概览。"""
diff --git a/pyproject.toml b/pyproject.toml
index d9c7888..0043cdb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "amrita_plugin_memory"
description = "Amrita长期记忆插件"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"aiohttp>=3.13.3",
"aiologic>=0.16.0",
@@ -37,7 +37,7 @@ module_name = "nonebot.adapters.onebot.v11"
[tool.uv]
dev-dependencies = [
"ruff>=0.12.8",
- #"pytest>=8.4.1",
+ "pytest>=8.4.1",
# `ambot memory` 依赖 0.2.1 起的 entry point extras([full_load])支持
"ambot-inlinectl>=0.2.1",
]
diff --git a/tests/test_schedule.py b/tests/test_schedule.py
new file mode 100644
index 0000000..3e538b4
--- /dev/null
+++ b/tests/test_schedule.py
@@ -0,0 +1,161 @@
+"""DMN 空闲窗口调度的纯函数与规划器单测。
+
+只测 schedule.py,不加载 nonebot,便于快速回归。
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from types import SimpleNamespace as NS
+from zoneinfo import ZoneInfo
+
+from amrita_plugin_memory.rethinking.schedule import (
+ CELLS,
+ SLOTS_PER_DAY,
+ IdlePlanner,
+ _runs,
+ bisect_free_minutes,
+ feasible_minutes,
+ free_gaps,
+ longest_free,
+ merge_noise,
+ slot_of,
+)
+
+TZ = ZoneInfo("Asia/Shanghai")
+
+
+def make_cfg(**over) -> NS:
+ base = dict(
+ idle_timezone="Asia/Shanghai",
+ idle_noise_gap_minutes=15,
+ idle_fine_tau_days=28.0,
+ idle_coarse_tau_days=14.0,
+ idle_shrink_k=4.0,
+ idle_cold_pct_max=0.05,
+ idle_cold_abs_max=1.0,
+ idle_min_sample_days_fine=6,
+ idle_min_sample_days_mid=3,
+ idle_target_hour=3,
+ idle_base_slots=3,
+ idle_max_slots=6,
+ idle_penalty_multiplier=1.5,
+ idle_max_relax_steps=3,
+ idle_buffer_minutes=15,
+ idle_run_estimate_minutes=10,
+ idle_fallback_minutes=120,
+ )
+ base.update(over)
+ return NS(**base)
+
+
+def seed_daytime(planner: IdlePlanner, days: int = 14) -> None:
+ """灌入白天活跃、夜间安静的历史。"""
+ base = datetime(2026, 10, 5, tzinfo=TZ)
+ for d in range(days):
+ for h in range(9, 22, 2):
+ for mi in (0, 40):
+ planner.observe((base + timedelta(days=d, hours=h, minutes=mi)).timestamp())
+
+
+def test_merge_noise_collapses_bursts():
+ assert merge_noise([0, 60, 600, 1000], 900) == [0, 1000]
+
+
+def test_free_gaps_and_longest():
+ assert free_gaps([100, 200], 0, 300) == [(0, 100), (100, 200), (200, 300)]
+ assert longest_free([100, 200], 0, 300) == (0, 100)
+ assert free_gaps([], 0, 0) == []
+
+
+def test_feasible_is_monotonic():
+ ts = [3600 * 2]
+ for minutes in range(0, 240, 10):
+ if feasible_minutes(ts, 0, 3600 * 4, minutes):
+ assert feasible_minutes(ts, 0, 3600 * 4, max(0, minutes - 10))
+
+
+def test_bisect_matches_bruteforce():
+ ts = [3600 * 2]
+ got = bisect_free_minutes(ts, 0, 3600 * 4)
+ brute = max(m for m in range(0, 241) if feasible_minutes(ts, 0, 3600 * 4, m))
+ assert got == brute == 120
+
+
+def test_ring_runs_wrap_around():
+ assert _runs([True] * 24, 12) == [(0, 12)]
+ mask = [True, True] + [False] * 20 + [True, True]
+ assert dict(_runs(mask, 24))[22] == 4
+
+
+def test_slot_of_two_hour_grid():
+ ts = datetime(2026, 10, 19, 23, 30, tzinfo=TZ).timestamp()
+ wd, slot = slot_of(ts, TZ)
+ assert wd == 0 and slot == 11
+
+
+def test_cold_start_falls_back_to_target_hour():
+ planner = IdlePlanner(make_cfg())
+ plan = planner.plan(1, datetime(2026, 10, 19, 9, 0, tzinfo=TZ))
+ assert plan.tier == "fallback"
+ assert plan.forced
+ assert plan.start.hour == 3
+
+
+def test_plan_lands_in_night_and_respects_capacity():
+ planner = IdlePlanner(make_cfg())
+ seed_daytime(planner)
+ now = datetime(2026, 10, 19, 9, 0, tzinfo=TZ)
+ plan = planner.plan(1, now)
+ assert not plan.forced
+ assert plan.start >= now
+ assert plan.end > plan.start
+ assert plan.end.hour <= 9
+
+
+def test_penalty_raises_requirement_but_stays_in_window():
+ planner = IdlePlanner(make_cfg())
+ seed_daytime(planner)
+ now = datetime(2026, 10, 19, 9, 0, tzinfo=TZ)
+ plans = [planner.plan(p, now) for p in (1, 3, 8)]
+ assert all(not p.forced for p in plans)
+ assert all(p.start.hour == 3 for p in plans)
+
+
+def test_no_deadlock_when_fully_active():
+ """用户几乎全天活跃时也必须给出窗口,不能返回空。"""
+ planner = IdlePlanner(make_cfg())
+ base = datetime(2026, 10, 5, tzinfo=TZ)
+ for d in range(21):
+ for s in range(SLOTS_PER_DAY):
+ planner.observe((base + timedelta(days=d, hours=s * 2)).timestamp())
+ plan = planner.plan(6, datetime(2026, 10, 19, 9, 0, tzinfo=TZ))
+ assert plan.start is not None
+ assert plan.forced
+
+
+def test_state_roundtrip():
+ planner = IdlePlanner(make_cfg())
+ seed_daytime(planner, days=5)
+ dumped = planner.dump()
+ assert len(dumped["idle_planner"]["fine"]) == CELLS
+ other = IdlePlanner(make_cfg())
+ other.load(dumped)
+ assert other.state.seen_days == planner.state.seen_days
+ assert abs(sum(other.state.coarse) - sum(planner.state.coarse)) < 1e-2
+
+
+def test_decay_reduces_history():
+ planner = IdlePlanner(make_cfg())
+ seed_daytime(planner, days=7)
+ before = sum(planner.state.coarse)
+ planner.observe(datetime(2026, 11, 30, tzinfo=TZ).timestamp())
+ assert sum(planner.state.coarse) < before
+
+
+def test_sparse_history_still_plans():
+ planner = IdlePlanner(make_cfg())
+ planner.observe(datetime(2026, 10, 12, 15, 0, tzinfo=TZ).timestamp())
+ plan = planner.plan(1, datetime(2026, 10, 19, 9, 0, tzinfo=TZ))
+ assert plan.start is not None
+ assert plan.length_minutes >= 1
From 794e382571b16a37bda5a0de2ef14c08ff6d2782 Mon Sep 17 00:00:00 2001
From: AmriaLin <325560502+AmriaLin@users.noreply.github.com>
Date: Tue, 20 Oct 2026 05:40:48 +0000
Subject: [PATCH 2/6] =?UTF-8?q?feat(profile):=20=E6=97=A5=E5=B8=B8?=
=?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=BC=80=E6=94=BE=E7=94=A8=E6=88=B7=E7=94=BB?=
=?UTF-8?q?=E5=83=8F=E5=8F=AA=E8=AF=BB=EF=BC=88=E4=BB=85=20DMN=20=E7=9B=AE?=
=?UTF-8?q?=E6=A0=87=E7=94=A8=E6=88=B7=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 抽出 profile_store:画像路径 PROFILE_PATH 与纯函数 read_profile,运行器与会话侧共用
- permissions.can_read_profile:比对 subconscious.target_user_id,仅本人可读
- 新增表层工具 read_profile(custom_run 校验),写入仍只留在循环内部
- README 补充只读口径说明
---
README.md | 6 ++
amrita_plugin_memory/permissions.py | 40 ++++++++++++-
amrita_plugin_memory/profile_store.py | 67 ++++++++++++++++++++++
amrita_plugin_memory/rethinking/runner.py | 45 ++-------------
amrita_plugin_memory/rethinking/schemas.py | 21 +++++++
amrita_plugin_memory/rethinking/tools.py | 34 ++++++++++-
amrita_plugin_memory/rethinking/types.py | 10 ----
7 files changed, 168 insertions(+), 55 deletions(-)
create mode 100644 amrita_plugin_memory/profile_store.py
diff --git a/README.md b/README.md
index cf26699..9bbc054 100644
--- a/README.md
+++ b/README.md
@@ -410,11 +410,17 @@ flowchart TD
| `knowledge_read` | kid, start_line?, end_line? |
| `knowledge_search` | query, top_k? |
| `knowledge_suggest` | action, title, summary, body, reason |
+| `read_profile` | start_line?, end_line? |
知识库的三个只读工具 `knowledge_list` / `knowledge_read` / `knowledge_search`
需要 LP 权限节点 `memory.knowledge`;`lp.admin` 与 `.env` 里的超级用户始终持有。
未授权时工具返回错误 JSON,不返回任何知识内容。
+用户画像的只读工具 `read_profile` 走另一条口径:只对离线巩固循环的目标用户
+(`subconscious.target_user_id`)开放,且只能读自己那份画像,不占用权限节点。
+返回值与循环侧一致:`summary` + `content` 行窗口 + `total_lines` / `range_start` / `range_end`。
+画像写入仍只发生在循环内部,表层不开放。
+
### 巩固循环工具(`rethinking/tools.py`,共 20 个)
记忆与 session/画像工具注册在隔离的 `_SUBCONSCIOUS_TOOLS` 上,不污染全局工具管理器。
diff --git a/amrita_plugin_memory/permissions.py b/amrita_plugin_memory/permissions.py
index 4970f83..be91bc3 100644
--- a/amrita_plugin_memory/permissions.py
+++ b/amrita_plugin_memory/permissions.py
@@ -1,8 +1,12 @@
-"""知识库读取的权限校验。
+"""会话侧读取权限校验(知识库 / 用户画像)。
-会话侧(对话 LLM)可以读全局知识库,但需要 LP 节点 ``memory.knowledge``;
+知识库:会话侧(对话 LLM)可以读全局知识库,但需要 LP 节点 ``memory.knowledge``;
``lp.admin`` 与 ``.env`` 里的超级用户始终持有该权限。
-潜意识循环内部调用不受此限制。
+
+用户画像:只对离线巩固循环(DMN)的目标用户开放只读,
+比对 ``subconscious.target_user_id``,不做权限节点扩散。
+
+潜意识循环内部调用均不受此限制。
"""
from __future__ import annotations
@@ -54,6 +58,36 @@ async def can_read_knowledge(event: Event | None) -> bool:
return await _has_node(event, KNOWLEDGE_NODE)
+async def get_dmn_target_user_id() -> str:
+ """当前 DMN 目标用户 ID:优先取运行器中实际生效的值,回退到配置。"""
+ try:
+ from .rethinking import _state
+
+ uid = _state.get_target_user_id()
+ if uid:
+ return str(uid)
+ except Exception as e:
+ logger.debug(f"[Memory] 取 DMN 运行器失败: {e}")
+ try:
+ from .config import DataManager
+
+ cfg = (await DataManager().safe_get_config()).subconscious
+ return str(cfg.target_user_id or "")
+ except Exception as e:
+ logger.debug(f"[Memory] 取 DMN 配置失败: {e}")
+ return ""
+
+
+async def can_read_profile(event: Event | None) -> bool:
+ """是否允许读取用户画像:仅 DMN 目标用户本人。"""
+ if event is None:
+ return False
+ target = await get_dmn_target_user_id()
+ if not target:
+ return False
+ return str(event.get_user_id()) == target
+
+
def get_event_from_tool_ctx(ctx: Any) -> Event | None:
"""从 ToolContext 里取出触发本次调用的原始事件。"""
try:
diff --git a/amrita_plugin_memory/profile_store.py b/amrita_plugin_memory/profile_store.py
new file mode 100644
index 0000000..6f96f4b
--- /dev/null
+++ b/amrita_plugin_memory/profile_store.py
@@ -0,0 +1,67 @@
+"""用户画像文件(Markdown)的路径与解析。
+
+会话侧只读入口与潜意识循环共用同一份解析逻辑,
+避免为读取画像而依赖运行器实例。
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TypedDict
+
+from nonebot import logger
+
+from .config import DATA_PATH
+
+
+class ProfileResult(TypedDict):
+ """用户画像读取返回值。"""
+
+ summary: str
+ content: str
+ total_lines: int
+ range_start: int
+ range_end: int
+
+
+#: 画像文件路径
+PROFILE_PATH: Path = DATA_PATH / "user_profile.md"
+
+
+def read_profile(
+ path: Path,
+ start_line: int | None = None,
+ end_line: int | None = None,
+) -> ProfileResult:
+ """解析画像 Markdown(summary + "---" + body),按行返回滑动窗口。
+
+ 与运行器解耦:潜意识循环未启动时,会话侧也能读到既有画像文件。
+ """
+ empty: ProfileResult = {
+ "summary": "",
+ "content": "",
+ "total_lines": 0,
+ "range_start": 0,
+ "range_end": 0,
+ }
+ if not path.exists():
+ return empty
+ try:
+ text = path.read_text(encoding="utf-8")
+ parts = text.split("---", 1)
+ summary = parts[0].strip() if parts else ""
+ body = parts[1].strip() if len(parts) > 1 else ""
+ body_lines = body.split("\n") if body else []
+ total = len(body_lines)
+ s = 0 if start_line is None else max(0, start_line)
+ e = total if end_line is None else max(s, min(total, end_line))
+ return {
+ "summary": summary,
+ "content": "\n".join(body_lines[s:e]),
+ "total_lines": total,
+ "range_start": s,
+ "range_end": e,
+ }
+ except Exception as e:
+ logger.warning(f"[Subconscious] Read profile failed: {e}")
+ return empty
diff --git a/amrita_plugin_memory/rethinking/runner.py b/amrita_plugin_memory/rethinking/runner.py
index ccfa6a0..a8f0308 100644
--- a/amrita_plugin_memory/rethinking/runner.py
+++ b/amrita_plugin_memory/rethinking/runner.py
@@ -32,6 +32,7 @@
from ..config import DATA_PATH, SubconsciousConfig
from ..keys import make_scope_id
from ..models import SubconsciousState
+from ..profile_store import PROFILE_PATH, ProfileResult, read_profile
from . import _state
from .backend import SubconsciousBackend
from .consts import (
@@ -44,7 +45,7 @@
)
from .knowledge import KnowledgeBaseManager
from .schedule import IdlePlanner, describe
-from .types import ProfileResult, SessionSummary
+from .types import SessionSummary
_STATE_UID = "amrita_memory"
#: 重排节流窗口(秒)
@@ -81,7 +82,7 @@ def __init__(self, config: SubconsciousConfig) -> None:
self._kb_manager: KnowledgeBaseManager | None = None
# session 摘要缓存(LRU):session DB id -> 摘要文本,最多 128 条
self._session_cache: LRUCache[int, str] = LRUCache(128)
- self._profile_path = DATA_PATH / "user_profile.md"
+ self._profile_path = PROFILE_PATH
# 空闲窗口规划器(热度图 + 冷段二分)
self._planner = IdlePlanner(config)
# 重排节流:一分钟内重复触发只更新惩罚与热度,不重复规划
@@ -571,45 +572,7 @@ async def _read_profile(
self, start_line: int | None = None, end_line: int | None = None
) -> ProfileResult:
"""读取用户画像 Markdown 文件,支持行数滑动窗口。"""
- if not self._profile_path.exists():
- return {
- "summary": "",
- "content": "",
- "total_lines": 0,
- "range_start": 0,
- "range_end": 0,
- }
- try:
- text = self._profile_path.read_text(encoding="utf-8")
- parts = text.split("---", 1)
- summary = parts[0].strip() if parts else ""
- body = parts[1].strip() if len(parts) > 1 else ""
- body_lines = body.split("\n") if body else []
- total = len(body_lines)
- if start_line is None:
- s = 0
- else:
- s = max(0, start_line)
- if end_line is None:
- e = total
- else:
- e = max(s, min(total, end_line))
- return {
- "summary": summary,
- "content": "\n".join(body_lines[s:e]),
- "total_lines": total,
- "range_start": s,
- "range_end": e,
- }
- except Exception as e:
- logger.warning(f"[Subconscious] Read profile failed: {e}")
- return {
- "summary": "",
- "content": "",
- "total_lines": 0,
- "range_start": 0,
- "range_end": 0,
- }
+ return read_profile(self._profile_path, start_line, end_line)
async def _update_profile(
self,
diff --git a/amrita_plugin_memory/rethinking/schemas.py b/amrita_plugin_memory/rethinking/schemas.py
index c30b69f..59d536b 100644
--- a/amrita_plugin_memory/rethinking/schemas.py
+++ b/amrita_plugin_memory/rethinking/schemas.py
@@ -391,6 +391,27 @@
),
)
+READ_PROFILE_SCHEMA = FunctionDefinitionSchema(
+ name="read_profile",
+ description=(
+ "读取本机离线巩固循环为你维护的用户画像(摘要 + 正文)。"
+ "返回值含 summary、content、total_lines、range_start、range_end;"
+ "正文较长时用 start_line / end_line 分段读取。仅画像所有者本人可读"
+ ),
+ parameters=FunctionParametersSchema(
+ type="object",
+ properties={
+ "start_line": FunctionPropertySchema(
+ type="integer", description="起始行号(0-based),默认全文"
+ ),
+ "end_line": FunctionPropertySchema(
+ type="integer", description="结束行号(不含),默认全文"
+ ),
+ },
+ required=[],
+ ),
+)
+
UPDATE_PROFILE_SCHEMA = FunctionDefinitionSchema(
name="subconscious_update_profile",
description=(
diff --git a/amrita_plugin_memory/rethinking/tools.py b/amrita_plugin_memory/rethinking/tools.py
index c86fc29..dc7a5e4 100644
--- a/amrita_plugin_memory/rethinking/tools.py
+++ b/amrita_plugin_memory/rethinking/tools.py
@@ -20,7 +20,12 @@
from nonebot import logger
from ..keys import make_scope_id
-from ..permissions import can_read_knowledge, get_event_from_tool_ctx
+from ..permissions import (
+ can_read_knowledge,
+ can_read_profile,
+ get_event_from_tool_ctx,
+)
+from ..profile_store import PROFILE_PATH, read_profile
from ..vector import AsyncUserMemory, MemoryMetadata, get_db_conn
from . import _state
from .consts import DEFAULT_SEND_PROMPT, ensure_prompt_file, load_character_prompt
@@ -41,6 +46,7 @@
LIST_MEMORY_SCHEMA,
READ_CHAT_CONTEXT_SCHEMA,
READ_MEMORY_SCHEMA,
+ READ_PROFILE_SCHEMA,
READ_SESSIONS_SCHEMA,
READ_SUGGESTIONS_SCHEMA,
SEND_TO_USER_SCHEMA,
@@ -893,6 +899,32 @@ async def knowledge_search_authorized(ctx: ToolContext) -> str | None:
return await subconscious_knowledge_search(ctx.data)
+# 会话侧只读入口:用户画像,仅画像所有者(DMN 目标用户)可读
+
+
+def _deny_profile() -> str:
+ return _tools_err("无权限读取该画像(仅离线巩固循环的目标用户可读自己的画像)")
+
+
+@on_tools(READ_PROFILE_SCHEMA, strict=True, custom_run=True)
+async def read_profile_authorized(ctx: ToolContext) -> str | None:
+ """对话 LLM 读取用户画像,仅限 DMN 目标用户本人。"""
+ if not await can_read_profile(get_event_from_tool_ctx(ctx)):
+ return _deny_profile()
+ try:
+ profile = read_profile(
+ PROFILE_PATH,
+ start_line=ctx.data.get("start_line"),
+ end_line=ctx.data.get("end_line"),
+ )
+ return _tools_ok(**profile)
+ except Exception as e:
+ logger.opt(exception=e, colors=True, raw=True).exception(
+ f"read_profile error: {e}"
+ )
+ return _tools_err(str(e))
+
+
# 知识建议:对话 LLM -> 潜意识 Agent 审查管线
diff --git a/amrita_plugin_memory/rethinking/types.py b/amrita_plugin_memory/rethinking/types.py
index 9b924ba..c086635 100644
--- a/amrita_plugin_memory/rethinking/types.py
+++ b/amrita_plugin_memory/rethinking/types.py
@@ -67,13 +67,3 @@ class SessionSummary(TypedDict):
created_at: float
summary: str
message_count: int
-
-
-class ProfileResult(TypedDict):
- """用户画像读取返回值。"""
-
- summary: str
- content: str
- total_lines: int
- range_start: int
- range_end: int
From 8486079021add457c3a98614784bb9d433325e62 Mon Sep 17 00:00:00 2001
From: AmriaLin <325560502+AmriaLin@users.noreply.github.com>
Date: Tue, 20 Oct 2026 06:39:06 +0000
Subject: [PATCH 3/6] =?UTF-8?q?chore:=20=E8=A1=A5=20AGENTS.md=20=E5=B9=B6?=
=?UTF-8?q?=E4=BF=AE=E6=97=B6=E5=8C=BA=E7=B1=BB=E5=9E=8B=E6=A0=87=E6=B3=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 AGENTS.md:模块边界、静态检查命令、工具注册三条路径、权限口径、数据布局
- schedule.py:slot_of 的 tz 形参放宽为 tzinfo,IdlePlanner.tz 显式标注,消除 pyright 类型错误
---
AGENTS.md | 94 +++++++++++++++++++++
amrita_plugin_memory/rethinking/schedule.py | 5 +-
2 files changed, 97 insertions(+), 2 deletions(-)
create mode 100644 AGENTS.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..a74f9c8
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,94 @@
+# AGENTS.md
+
+给在本仓库工作的 AI 代理(以及新加入的人)看的约定。只写与改动直接相关的硬性要求,风格问题按现有代码对齐。
+
+## 项目概况
+
+- AmritaBot 长期记忆插件:L1 备忘录、L2 向量记忆、L3 离线巩固循环(DMN)三层。
+- 表层工具(对话 LLM 可用)注册在全局 ToolsManager;`subconscious_*` 通过 `bound_to=_SUBCONSCIOUS_TOOLS` 注册到隔离管理器,不污染全局。
+- 插件入口 `amrita_plugin_memory/__init__.py`,命令入口 `cli.py`(`ambot memory`),对话命令在 `matchers.py`(`/memory`)。
+
+## 模块边界
+
+| 位置 | 职责 |
+| --- | --- |
+| `config.py` | EnvConfig / ConfigFile(pydantic + uniconf),DATA_PATH 等路径常量 |
+| `keys.py` | uni_id 生成与 scope 解析 |
+| `vector.py` | ChromaDB 封装、AsyncUserMemory 记忆读写 |
+| `embedding.py` / `embed.py` | 嵌入模型与指纹校验、重映射 |
+| `memo.py` | L1 备忘录读写 |
+| `decay.py` | 时间记忆退化与分层清理 |
+| `permissions.py` | 会话侧读取权限校验 |
+| `profile_store.py` | 画像路径与 Markdown 解析(纯函数,不依赖 nonebot 运行时) |
+| `rethinking/` | DMN 循环:runner、tools、schemas、knowledge、schedule |
+
+新增纯逻辑优先放进不依赖 nonebot 的模块(如 `profile_store.py`、`rethinking/schedule.py`),运行器只做编排。
+
+## 环境与依赖
+
+- Python 3.10–3.13,包管理只用 uv。改依赖后跑 `uv sync`,不要用 `pip install` 补装单包。
+- 类型检查用 `pyright`,配置在 `pyproject.toml` 的 `[tool.pyright]`(standard 模式)。
+- 本仓库 `.venv` 里通常没有 pyright / ruff,用 `--pythonpath .venv/bin/python` 指向仓库环境,否则会因解析不到 amrita_core / chromadb 报一片 Import 错误。
+
+## 常用命令
+
+```bash
+uv run ruff check amrita_plugin_memory
+uv run ruff format --check amrita_plugin_memory
+uv run pyright --pythonpath .venv/bin/python amrita_plugin_memory
+python -m py_compile amrita_plugin_memory/**/*.py
+```
+
+## 测试要求
+
+本仓库不补单元测试,验证以静态检查加手动冒烟为准:
+
+1. `ruff check` 与 `ruff format --check` 通过
+2. `pyright` 零 error(用上面的 pythonpath 跑)
+3. 改到的模块 `py_compile` 通过
+4. 手动跑一遍改动的命令(`/memory`、`ambot memory`)或工具调用,并在 PR 里说明验证方式与结果
+
+纯逻辑尽量写成可独立执行的形式,方便用 `python -c` 或临时脚本验证;不要把临时脚本提交进仓库。
+`tests/` 下既有的 `test_schedule.py` 是需要 nonebot 初始化才能跑的历史文件,不作为改动门槛。
+
+## 工具注册的三条路径
+
+- 表层可读、需权限:实现放 `rethinking/tools.py`,`@on_tools(SCHEMA, strict=True, custom_run=True)` 注册到全局,进函数先校验 `permissions.py` 里的判定。
+- 仅循环可用:同上但带 `bound_to=_SUBCONSCIOUS_TOOLS`。
+- 新工具要同时加入 `rethinking/schemas.py` 的 `_SUBCONSCIOUS_TOOL_FUNCTIONS`(若循环也要用),并在 README 的工具表里登记。
+
+新增工具时注意:schema 的 `name` 是工具名,函数名可另取;`custom_run=True` 时函数签名收 `ToolContext`,返回值必须是字符串。
+
+## 权限口径
+
+- 知识库只读:`permissions.can_read_knowledge`,超用户 / `lp.admin` / `memory.knowledge` 节点三者之一。
+- 用户画像只读:`permissions.can_read_profile`,仅 `subconscious.target_user_id` 本人,不扩散权限节点。
+- 取值优先用 `_state.get_target_user_id()`(运行器实际生效值),回退 `DataManager` 配置。空值一律拒。
+
+## 数据与持久化
+
+- 画像:`DATA_PATH/user_profile.md`,格式 `summary` + `---` + 正文;行级增量更新,写入只发生在循环内部。
+- 知识库:`DATA_PATH/knowledge/` + `knowledge_index.json` + ChromaDB,文件/索引/向量三方同步。
+- DMN 元状态:`SubconsciousState` 表;向量记忆在 ChromaDB collection。
+- 写入路径优先走读-改-写整体替换,避免嵌入失败时丢数据(参考 `25f1078`)。
+
+## 代码注释风格
+
+- 注释与 docstring 一律中文,单行短句,讲“为什么”和约束,不复述代码本身在做什么。
+- 模块顶部 docstring 写职责、关键约束、与其他模块的边界;函数 docstring 写清参数语义与副作用。
+- 不写分节注释(`# ---- 小节名`、`# ====`),用空行和函数拆分表达结构。
+- 不写连续多行 `#` 注释;需要成段解释时写进模块或函数 docstring。
+- 待办写成 `# TODO(review #):具体动作`。
+- 类型注解必须齐全;pydantic 字段用 `Field(description=...)`,描述用中文。
+- 不写注释掉的旧代码,不写“新增/修改”这类只在 diff 里有意义的注释。
+
+## 配置改动
+
+- 配置型在 `config.py`(pydantic + `nonebot_plugin_uniconf`)。新字段必须有中文 `description` 和保守默认值,默认值要保证旧配置文件行为不变。
+- 新增开关涉及 DMN 行为时,同步检查 `matchers.py` 的 `/memory dmn` 前置校验与 README 的配置表。
+
+## Git 与 PR
+
+- 分支名用 `feat/` `fix/` `refactor/` 前缀;提交信息中文,首行说明改了什么,正文写动机与影响面。
+- PR 拿到 approval 不等于授权合并;合并默认 squash,等明确指令再动。
+- GitHub raw 域名可能不可达,取仓库内容走 `api.github.com`。
diff --git a/amrita_plugin_memory/rethinking/schedule.py b/amrita_plugin_memory/rethinking/schedule.py
index f084105..3398ffe 100644
--- a/amrita_plugin_memory/rethinking/schedule.py
+++ b/amrita_plugin_memory/rethinking/schedule.py
@@ -15,7 +15,7 @@
import time
from collections.abc import Sequence
from dataclasses import dataclass, field
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timedelta, timezone, tzinfo
from typing import Any
from zoneinfo import ZoneInfo
@@ -37,7 +37,7 @@ def _cell(wd: int, slot: int) -> int:
return wd * SLOTS_PER_DAY + (slot % SLOTS_PER_DAY)
-def slot_of(ts: float, tz: ZoneInfo) -> tuple[int, int]:
+def slot_of(ts: float, tz: tzinfo) -> tuple[int, int]:
"""把 unix 时间戳换成 (星期几 0=周一, 槽位 0..11)。"""
dt = datetime.fromtimestamp(ts, tz)
wd = dt.weekday()
@@ -199,6 +199,7 @@ class IdlePlanner:
def __init__(self, cfg: Any) -> None:
self._cfg = cfg
+ self.tz: tzinfo
tz_name = getattr(cfg, "idle_timezone", "Asia/Shanghai")
try:
self.tz = ZoneInfo(tz_name)
From 5ae37248a71b0ece64967f4f8e45c43d4701e818 Mon Sep 17 00:00:00 2001
From: AmriaLin <325560502+AmriaLin@users.noreply.github.com>
Date: Tue, 20 Oct 2026 06:42:27 +0000
Subject: [PATCH 4/6] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=20tests=20?=
=?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=8E=20pytest=20=E4=BE=9D=E8=B5=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 删掉 tests/test_schedule.py,与不补单测的约定对齐
- pyproject.toml 去掉 pytest dev 依赖
- AGENTS.md 明确本仓库不建 tests/ 目录
---
AGENTS.md | 2 +-
pyproject.toml | 1 -
tests/test_schedule.py | 161 -----------------------------------------
3 files changed, 1 insertion(+), 163 deletions(-)
delete mode 100644 tests/test_schedule.py
diff --git a/AGENTS.md b/AGENTS.md
index a74f9c8..546bbd3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,7 +49,7 @@ python -m py_compile amrita_plugin_memory/**/*.py
4. 手动跑一遍改动的命令(`/memory`、`ambot memory`)或工具调用,并在 PR 里说明验证方式与结果
纯逻辑尽量写成可独立执行的形式,方便用 `python -c` 或临时脚本验证;不要把临时脚本提交进仓库。
-`tests/` 下既有的 `test_schedule.py` 是需要 nonebot 初始化才能跑的历史文件,不作为改动门槛。
+本仓库无 `tests/` 目录,也不要新建。纯逻辑写成可独立执行的形式,用临时脚本验证后不提交。
## 工具注册的三条路径
diff --git a/pyproject.toml b/pyproject.toml
index 0043cdb..c53420a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,7 +37,6 @@ module_name = "nonebot.adapters.onebot.v11"
[tool.uv]
dev-dependencies = [
"ruff>=0.12.8",
- "pytest>=8.4.1",
# `ambot memory` 依赖 0.2.1 起的 entry point extras([full_load])支持
"ambot-inlinectl>=0.2.1",
]
diff --git a/tests/test_schedule.py b/tests/test_schedule.py
deleted file mode 100644
index 3e538b4..0000000
--- a/tests/test_schedule.py
+++ /dev/null
@@ -1,161 +0,0 @@
-"""DMN 空闲窗口调度的纯函数与规划器单测。
-
-只测 schedule.py,不加载 nonebot,便于快速回归。
-"""
-
-from __future__ import annotations
-
-from datetime import datetime, timedelta
-from types import SimpleNamespace as NS
-from zoneinfo import ZoneInfo
-
-from amrita_plugin_memory.rethinking.schedule import (
- CELLS,
- SLOTS_PER_DAY,
- IdlePlanner,
- _runs,
- bisect_free_minutes,
- feasible_minutes,
- free_gaps,
- longest_free,
- merge_noise,
- slot_of,
-)
-
-TZ = ZoneInfo("Asia/Shanghai")
-
-
-def make_cfg(**over) -> NS:
- base = dict(
- idle_timezone="Asia/Shanghai",
- idle_noise_gap_minutes=15,
- idle_fine_tau_days=28.0,
- idle_coarse_tau_days=14.0,
- idle_shrink_k=4.0,
- idle_cold_pct_max=0.05,
- idle_cold_abs_max=1.0,
- idle_min_sample_days_fine=6,
- idle_min_sample_days_mid=3,
- idle_target_hour=3,
- idle_base_slots=3,
- idle_max_slots=6,
- idle_penalty_multiplier=1.5,
- idle_max_relax_steps=3,
- idle_buffer_minutes=15,
- idle_run_estimate_minutes=10,
- idle_fallback_minutes=120,
- )
- base.update(over)
- return NS(**base)
-
-
-def seed_daytime(planner: IdlePlanner, days: int = 14) -> None:
- """灌入白天活跃、夜间安静的历史。"""
- base = datetime(2026, 10, 5, tzinfo=TZ)
- for d in range(days):
- for h in range(9, 22, 2):
- for mi in (0, 40):
- planner.observe((base + timedelta(days=d, hours=h, minutes=mi)).timestamp())
-
-
-def test_merge_noise_collapses_bursts():
- assert merge_noise([0, 60, 600, 1000], 900) == [0, 1000]
-
-
-def test_free_gaps_and_longest():
- assert free_gaps([100, 200], 0, 300) == [(0, 100), (100, 200), (200, 300)]
- assert longest_free([100, 200], 0, 300) == (0, 100)
- assert free_gaps([], 0, 0) == []
-
-
-def test_feasible_is_monotonic():
- ts = [3600 * 2]
- for minutes in range(0, 240, 10):
- if feasible_minutes(ts, 0, 3600 * 4, minutes):
- assert feasible_minutes(ts, 0, 3600 * 4, max(0, minutes - 10))
-
-
-def test_bisect_matches_bruteforce():
- ts = [3600 * 2]
- got = bisect_free_minutes(ts, 0, 3600 * 4)
- brute = max(m for m in range(0, 241) if feasible_minutes(ts, 0, 3600 * 4, m))
- assert got == brute == 120
-
-
-def test_ring_runs_wrap_around():
- assert _runs([True] * 24, 12) == [(0, 12)]
- mask = [True, True] + [False] * 20 + [True, True]
- assert dict(_runs(mask, 24))[22] == 4
-
-
-def test_slot_of_two_hour_grid():
- ts = datetime(2026, 10, 19, 23, 30, tzinfo=TZ).timestamp()
- wd, slot = slot_of(ts, TZ)
- assert wd == 0 and slot == 11
-
-
-def test_cold_start_falls_back_to_target_hour():
- planner = IdlePlanner(make_cfg())
- plan = planner.plan(1, datetime(2026, 10, 19, 9, 0, tzinfo=TZ))
- assert plan.tier == "fallback"
- assert plan.forced
- assert plan.start.hour == 3
-
-
-def test_plan_lands_in_night_and_respects_capacity():
- planner = IdlePlanner(make_cfg())
- seed_daytime(planner)
- now = datetime(2026, 10, 19, 9, 0, tzinfo=TZ)
- plan = planner.plan(1, now)
- assert not plan.forced
- assert plan.start >= now
- assert plan.end > plan.start
- assert plan.end.hour <= 9
-
-
-def test_penalty_raises_requirement_but_stays_in_window():
- planner = IdlePlanner(make_cfg())
- seed_daytime(planner)
- now = datetime(2026, 10, 19, 9, 0, tzinfo=TZ)
- plans = [planner.plan(p, now) for p in (1, 3, 8)]
- assert all(not p.forced for p in plans)
- assert all(p.start.hour == 3 for p in plans)
-
-
-def test_no_deadlock_when_fully_active():
- """用户几乎全天活跃时也必须给出窗口,不能返回空。"""
- planner = IdlePlanner(make_cfg())
- base = datetime(2026, 10, 5, tzinfo=TZ)
- for d in range(21):
- for s in range(SLOTS_PER_DAY):
- planner.observe((base + timedelta(days=d, hours=s * 2)).timestamp())
- plan = planner.plan(6, datetime(2026, 10, 19, 9, 0, tzinfo=TZ))
- assert plan.start is not None
- assert plan.forced
-
-
-def test_state_roundtrip():
- planner = IdlePlanner(make_cfg())
- seed_daytime(planner, days=5)
- dumped = planner.dump()
- assert len(dumped["idle_planner"]["fine"]) == CELLS
- other = IdlePlanner(make_cfg())
- other.load(dumped)
- assert other.state.seen_days == planner.state.seen_days
- assert abs(sum(other.state.coarse) - sum(planner.state.coarse)) < 1e-2
-
-
-def test_decay_reduces_history():
- planner = IdlePlanner(make_cfg())
- seed_daytime(planner, days=7)
- before = sum(planner.state.coarse)
- planner.observe(datetime(2026, 11, 30, tzinfo=TZ).timestamp())
- assert sum(planner.state.coarse) < before
-
-
-def test_sparse_history_still_plans():
- planner = IdlePlanner(make_cfg())
- planner.observe(datetime(2026, 10, 12, 15, 0, tzinfo=TZ).timestamp())
- plan = planner.plan(1, datetime(2026, 10, 19, 9, 0, tzinfo=TZ))
- assert plan.start is not None
- assert plan.length_minutes >= 1
From d6042b154e321ef9f1ae9d608254f30c80488ae5 Mon Sep 17 00:00:00 2001
From: AmriaLin <325560502+AmriaLin@users.noreply.github.com>
Date: Tue, 20 Oct 2026 07:28:51 +0000
Subject: [PATCH 5/6] =?UTF-8?q?fix(dmn):=20=E5=A4=84=E7=90=86=20Sourcery?=
=?UTF-8?q?=20=E8=AF=84=E5=AE=A1=E7=9A=84=2022=20=E9=A1=B9=E9=97=AE?=
=?UTF-8?q?=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
调度正确性
- 两天拼接的冷槽不再按环形相连,避免把隔天两组冷槽误并成一段
- 惩罚指数先封顶再取整,溢出时退到上限而不是抛异常
- 冷段选择改为时间感知:优先尚未到来的窗口,全等权重时按最近时间取
- _refine 尾部 buffer 不计入回报窗口;段内放不下一次运行就换段而非硬报
- 跨午夜扫描时相邻星期几独立挑分层,样本不足的天不会以 0 计数当冷槽
- active_days_of 只看近期,换作息后旧日期不再撑着 fine/mid 层
- _shrink 先各自归一化再混合,细层不再被全局层绝对计数压过
- 时区解析收敛成 resolve_timezone,调度与会话侧共用,回退时如实回报
状态与并发
- _save_state 加锁,并发写不会让旧快照盖掉新观测
- 写入前保留本实例不认识的字段,老版本实例不会再抹掉热度图
- 旧调度模式下不写 idle_planner,升级迁移不再误判已完成
- 仅启用空闲窗口调度时才清零旧语义 penalty
- 上轮运行时间兼容 0.7.1 的 last_run_time
- 热度图记录时区与目标用户,任一变就重建
- 收尾只在惩罚未被新消息改动时才清零
生命周期
- 用户新消息到来时掐断正在跑的周期,旧上下文不再写回记忆
- 节流期内仍顺延待执行任务,旧任务不会撞进新的活动窗口
- 首条消息不再绕过冷启动保底窗口
- 状态写入失败改为记日志,不再静默丢退避
工具
- iter_stop 校验本轮是否取过上下文,空转周期标记无效且不清惩罚
- get_context 的向量库操作加超时,超时标 stale 不阻塞循环
---
AGENTS.md | 1 +
amrita_plugin_memory/rethinking/_state.py | 32 ++
amrita_plugin_memory/rethinking/runner.py | 183 +++++++++--
amrita_plugin_memory/rethinking/schedule.py | 325 ++++++++++++++------
amrita_plugin_memory/rethinking/tools.py | 37 ++-
5 files changed, 446 insertions(+), 132 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 546bbd3..1ce3dda 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,7 @@
| `permissions.py` | 会话侧读取权限校验 |
| `profile_store.py` | 画像路径与 Markdown 解析(纯函数,不依赖 nonebot 运行时) |
| `rethinking/` | DMN 循环:runner、tools、schemas、knowledge、schedule |
+| `rethinking/schedule.py` | 空闲窗口规划(纯数据 + 纯函数,`resolve_timezone` 是全局唯一的时区解析入口) |
新增纯逻辑优先放进不依赖 nonebot 的模块(如 `profile_store.py`、`rethinking/schedule.py`),运行器只做编排。
diff --git a/amrita_plugin_memory/rethinking/_state.py b/amrita_plugin_memory/rethinking/_state.py
index c44c3ca..1c6b36e 100644
--- a/amrita_plugin_memory/rethinking/_state.py
+++ b/amrita_plugin_memory/rethinking/_state.py
@@ -21,6 +21,10 @@
# 上一轮 iter_stop 的摘要与结束时间 — 供 subconscious_get_context 读取
_last_iter_summary: str = ""
_last_run_at: float = 0.0
+# 本轮是否已调过 subconscious_get_context — iter_stop 据此判定周期是否有效
+_context_fetched: bool = False
+# 本轮是否算一次有效周期 — 空转的周期不重置惩罚
+_cycle_valid: bool = True
# 隔离的工具管理器 — 所有 @on_tools 装饰器通过 bound_to 注册到这里,不污染全局 ToolsManager
_SUBCONSCIOUS_TOOLS = MultiToolsManager()
@@ -103,6 +107,34 @@ def set_last_iter_summary(text: str) -> None:
_last_iter_summary = text
+def mark_context_fetched() -> None:
+ global _context_fetched
+ _context_fetched = True
+
+
+def is_context_fetched() -> bool:
+ return _context_fetched
+
+
+def reset_context_fetched() -> None:
+ global _context_fetched
+ _context_fetched = False
+
+
+def mark_cycle_invalid() -> None:
+ global _cycle_valid
+ _cycle_valid = False
+
+
+def is_cycle_valid() -> bool:
+ return _cycle_valid
+
+
+def reset_cycle_valid() -> None:
+ global _cycle_valid
+ _cycle_valid = True
+
+
def get_last_run_at() -> float:
return _last_run_at
diff --git a/amrita_plugin_memory/rethinking/runner.py b/amrita_plugin_memory/rethinking/runner.py
index a8f0308..818469a 100644
--- a/amrita_plugin_memory/rethinking/runner.py
+++ b/amrita_plugin_memory/rethinking/runner.py
@@ -88,6 +88,13 @@ def __init__(self, config: SubconsciousConfig) -> None:
# 重排节流:一分钟内重复触发只更新惩罚与热度,不重复规划
self._last_replan_at: float = 0.0
self._replan_throttle: float = _REPLAN_THROTTLE_SECONDS
+ # 状态写入串行化:并发 save 先后到达时,旧快照不能盖掉新快照
+ self._state_lock = asyncio.Lock()
+ # 正在跑的周期任务,用户新消息到来时可以掐断
+ self._cycle_task: asyncio.Task | None = None
+ # 本轮是否已取过上下文,供 iter_stop 校验
+ _state.reset_context_fetched()
+ _state.reset_cycle_valid()
@property
def is_running(self) -> bool:
@@ -139,42 +146,92 @@ async def cancel_and_reschedule(self, chat_ts: float | None = None) -> None:
然后按新的冷段门槛重新选窗口。不 dump/恢复上下文——
用户聊完天后有新内容,旧推理上下文应直接丢弃。
"""
- # 热度图观测:每条消息都算,加深该时间窗口的热点
+ # 先记下历史是否存在:首条消息会把热度图从空变成非空,
+ # 若观测后再判断,冷启动就会被自己这条消息绕过
+ had_history = True
with contextlib.suppress(Exception):
+ had_history = self._planner.has_history()
self._planner.observe(chat_ts)
penalty = _state.increment_penalty()
+
+ # 用户在聊天,正在跑的那一轮已经过时了,掐掉避免它把旧结论写回记忆
+ self._abort_running_cycle()
+
now_mono = time.monotonic()
if now_mono - self._last_replan_at < self._replan_throttle:
- # 同窗口内的后续消息不重排,避免每条消息都扫一遍热度图
- with contextlib.suppress(Exception):
- await self._save_state()
+ # 同窗口内的后续消息不重排,但仍把待执行任务往后挪,
+ # 否则旧任务会在用户刚开始的这轮活动里跑起来
+ self._defer_pending_job(penalty, had_history)
+ await self._save_state_logged()
return
self._last_replan_at = now_mono
with contextlib.suppress(Exception):
scheduler.remove_job(self._job_id)
- if self._config.use_idle_window_planner:
- plan = self._planner.plan(penalty)
- logger.info(
- f"[Subconscious] Rescheduled: penalty=#{penalty}, {describe(plan)}"
- )
- delay_seconds = int(
- max(60.0, (plan.start - datetime.now(timezone.utc)).total_seconds())
- )
- else:
- base = self._config.rethink_base_delay_minutes
+
+ delay_seconds = self._compute_delay(penalty, had_history)
+ self._schedule_once(delay_seconds)
+ # 惩罚计数与热度图落盘,避免重启后丢掉已累积的退避
+ await self._save_state_logged()
+
+ def _compute_delay(self, penalty: int, had_history: bool) -> int:
+ """算下一次运行要等多久(秒)。规划失败时退到保底窗口。"""
+ base = self._config.rethink_base_delay_minutes
+ cap = self._config.rethink_max_delay_minutes
+ if not self._config.use_idle_window_planner:
multiplier = self._config.rethink_penalty_multiplier
- cap = self._config.rethink_max_delay_minutes
- raw = base * (multiplier ** (penalty - 1))
+ raw = base * (multiplier ** min(max(penalty - 1, 0), 32))
delay_minutes = min(raw, cap)
logger.info(
f"[Subconscious] Rescheduled(legacy): penalty=#{penalty}, "
- f"delay={delay_minutes:.0f}min (raw={raw:.0f}min, cap={cap}min)"
+ f"delay={delay_minutes:.0f}min (cap={cap}min)"
)
- delay_seconds = int(delay_minutes * 60)
- self._schedule_once(delay_seconds)
- # 惩罚计数与热度图落盘,避免重启后丢掉已累积的退避
- with contextlib.suppress(Exception):
+ return int(delay_minutes * 60)
+
+ try:
+ if had_history:
+ plan = self._planner.plan(penalty)
+ else:
+ # 冷启动只攒到一条样本,不足以判断作息,先走保底窗口
+ plan = self._planner.fallback_plan(penalty)
+ except Exception as e:
+ logger.opt(exception=e, colors=True, raw=True).error(
+ f"[Subconscious] Plan failed, fall back to fixed delay: {e}"
+ )
+ return int(min(base, cap) * 60)
+ logger.info(f"[Subconscious] Rescheduled: penalty=#{penalty}, {describe(plan)}")
+ return int(max(60.0, (plan.start - datetime.now(timezone.utc)).total_seconds()))
+
+ def _defer_pending_job(self, penalty: int, had_history: bool) -> None:
+ """节流期内不重新规划,只把待执行任务顺延到新的候选窗口。"""
+ try:
+ job = scheduler.get_job(self._job_id)
+ if job is None:
+ # 本就没有待执行任务(可能在运行中),补一个
+ self._schedule_once(self._compute_delay(penalty, had_history))
+ return
+ delay = self._compute_delay(penalty, had_history)
+ run_date = datetime.now(timezone.utc) + timedelta(seconds=delay)
+ if job.next_run_time is not None and job.next_run_time < run_date:
+ scheduler.reschedule_job(
+ self._job_id, trigger="date", run_date=run_date
+ )
+ except Exception as e:
+ logger.warning(f"[Subconscious] Defer pending job failed: {e}")
+
+ def _abort_running_cycle(self) -> None:
+ """用户发了新消息,掐断正在跑的那一轮。"""
+ task = self._cycle_task
+ if task is None or task.done():
+ return
+ logger.info("[Subconscious] New chat arrived, aborting in-flight cycle")
+ task.cancel()
+
+ async def _save_state_logged(self) -> None:
+ """保存状态,失败要出声:写盘失败会让重启后的退避凭空消失。"""
+ try:
await self._save_state()
+ except Exception as e:
+ logger.warning(f"[Subconscious] Save state failed: {e}")
def _schedule_once(self, delay_seconds: int) -> None:
run_date = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
@@ -202,6 +259,8 @@ async def _run(self) -> None:
logger.warning("[Subconscious] Already running, skip")
return
self._is_running = True
+ self._cycle_task = asyncio.current_task()
+ self._context_fetched = False
try:
# 窗口已实际占用:记一次观测,避免下一轮重复选同一段
with contextlib.suppress(Exception):
@@ -246,6 +305,11 @@ async def _run_cycle(self) -> None:
logger.info("[Subconscious] Cycle start")
chat_obj.begin()
await chat_obj
+ except asyncio.CancelledError:
+ # 用户发来新消息把本轮掐了:旧上下文作废,不写摘要也不动惩罚
+ logger.info("[Subconscious] Cycle aborted by new user message")
+ self._chat_obj = None
+ raise
except Exception as e:
logger.opt(exception=e, colors=True, raw=True).exception(
f"[Subconscious] Error: {e}"
@@ -280,7 +344,10 @@ async def _post_process(self, chat_obj: ChatObject) -> None:
self._total_runs += 1
_state.set_last_run_at(datetime.now(timezone.utc).timestamp())
- # 先持久化,任一失败则不重置惩罚(保留重试机会)
+ # 先持久化,任一失败则不重置惩罚(保留重试机会)。
+ # 记下快照时的计数:保存期间用户可能又发消息把惩罚顶上去,
+ # 那部分增量不能被这次 reset 抹掉。
+ penalty_before = _state.get_penalty_count()
save_ok = True
try:
await self._save_state()
@@ -288,7 +355,20 @@ async def _post_process(self, chat_obj: ChatObject) -> None:
logger.warning(f"[Subconscious] Save state failed: {e}")
save_ok = False
- if save_ok:
+ if not save_ok:
+ logger.warning(
+ f"[Subconscious] Cycle done but save failed, "
+ f"penalty NOT reset (penalty={_state.get_penalty_count()})"
+ )
+ elif not _state.is_cycle_valid():
+ # 空转周期(没取上下文就收工)不清惩罚,保留压力等下一轮
+ logger.warning("[Subconscious] Cycle invalid, penalty kept")
+ elif _state.get_penalty_count() != penalty_before:
+ # 保存期间用户又发了消息,新增的那部分惩罚不能被这次收尾抹掉
+ logger.info(
+ "[Subconscious] Penalty kept: new chat arrived during the cycle"
+ )
+ else:
_state.reset_penalty()
# 惩罚清零也要落盘,否则重启会加载旧退避
try:
@@ -299,11 +379,6 @@ async def _post_process(self, chat_obj: ChatObject) -> None:
f"[Subconscious] Cycle done (total_runs={self._total_runs}), "
f"penalty reset, waiting for next user chat"
)
- else:
- logger.warning(
- f"[Subconscious] Cycle done but save failed, "
- f"penalty NOT reset (penalty={_state.get_penalty_count()})"
- )
async def _update_usage(self, chat_obj: ChatObject) -> None:
"""更新全局 InsightsModel 与目标用户的 usage,复用 Bot 的 add_usage 逻辑。
@@ -403,11 +478,16 @@ async def _load_state(self) -> None:
if isinstance(suggestions, list):
_state.set_knowledge_suggestions(suggestions)
_state.set_last_iter_summary(str(payload.get("last_iter_summary", "")))
- _state.set_last_run_at(float(payload.get("last_run_ts", 0.0) or 0.0))
+ _state.set_last_run_at(self._read_last_run_ts(payload))
self._planner.load(payload)
# 旧 penalty 语义是"被推迟的次数",与新语义(要求的冷段长度)不可换算,
- # 升级后清零一次,避免带着旧值把门槛顶到上限
- if "idle_planner" not in payload and _state.get_penalty_count() > 0:
+ # 升级后清零一次,避免带着旧值把门槛顶到上限。
+ # 仍走旧调度时数值是自洽的,别动它。
+ if (
+ self._config.use_idle_window_planner
+ and "idle_planner" not in payload
+ and _state.get_penalty_count() > 0
+ ):
logger.info(
"[Subconscious] Migrate: reset legacy penalty "
f"#{_state.get_penalty_count()} (old delay-based semantics)"
@@ -416,6 +496,23 @@ async def _load_state(self) -> None:
except Exception as e:
logger.warning(f"[Subconscious] Load state failed: {e}")
+ @staticmethod
+ def _read_last_run_ts(payload: dict[str, Any]) -> float:
+ """取上轮运行时间,兼容 0.7.1 只写 last_run_time 的旧快照。"""
+ raw = payload.get("last_run_ts")
+ try:
+ if raw is not None:
+ return float(raw or 0.0)
+ except (TypeError, ValueError):
+ pass
+ legacy = payload.get("last_run_time")
+ if isinstance(legacy, str) and legacy:
+ try:
+ return datetime.fromisoformat(legacy).timestamp()
+ except ValueError:
+ return 0.0
+ return 0.0
+
async def _migrate_legacy_state(self) -> dict[str, Any]:
"""迁移旧版本挪用的 Bot Memory 表元状态(extra_prompt/memory_json)。"""
payload: dict[str, Any] = {}
@@ -448,7 +545,15 @@ async def _migrate_legacy_state(self) -> dict[str, Any]:
return payload
async def _save_state(self) -> None:
- """保存元状态到 SubconsciousState 表。"""
+ """保存元状态到 SubconsciousState 表。
+
+ 加锁串行化:并发调用时若旧快照后提交,会把新的热度与惩罚盖掉。
+ """
+ async with self._state_lock:
+ await self._save_state_locked()
+
+ async def _save_state_locked(self) -> None:
+ """真正写盘的部分,调用方需已持有 _state_lock。"""
payload: dict[str, Any] = {
"total_runs": self._total_runs,
"last_run_time": datetime.now(timezone.utc).isoformat(),
@@ -459,7 +564,19 @@ async def _save_state(self) -> None:
"pending_messages": _state.get_pending(),
"knowledge_suggestions": _state.get_knowledge_suggestions(),
}
- payload.update(self._planner.dump())
+ # 只有启用空闲窗口调度时才写热度图:旧调度模式下写进去,
+ # 会让下次升级的迁移判断误以为已是新格式,跳过旧 penalty 清零
+ if self._config.use_idle_window_planner:
+ payload.update(self._planner.dump())
+ # 整行替换前先补齐老版本实例写过、本实例不认识的字段,
+ # 否则旧实例一存盘,热度图累积就被抹成"从没规划过"
+ try:
+ existing = await self._read_state_payload(_STATE_UID)
+ except Exception:
+ existing = {}
+ for key, value in existing.items():
+ if key not in payload:
+ payload[key] = value
await self._write_state_payload(_STATE_UID, payload)
async def _load_prompt(self) -> str:
diff --git a/amrita_plugin_memory/rethinking/schedule.py b/amrita_plugin_memory/rethinking/schedule.py
index 3398ffe..14f18ac 100644
--- a/amrita_plugin_memory/rethinking/schedule.py
+++ b/amrita_plugin_memory/rethinking/schedule.py
@@ -12,6 +12,7 @@
from __future__ import annotations
+import logging
import time
from collections.abc import Sequence
from dataclasses import dataclass, field
@@ -30,9 +31,24 @@
#: 活跃日期登记上限
_MAX_SEEN_DAYS = 240
+_LOG = logging.getLogger(__name__)
+
_WD_NAMES = ("周一", "周二", "周三", "周四", "周五", "周六", "周日")
+def resolve_timezone(name: str | None) -> tuple[tzinfo, str, bool]:
+ """把配置里的时区名解析成 tzinfo。
+
+ 返回 (tz, 实际生效的名字, 是否回退)。解析失败回退 UTC+08:00,
+ 会话侧与调度侧共用这一份,避免两边报出不同的钟点。
+ """
+ raw = (name or "").strip() or "Asia/Shanghai"
+ try:
+ return ZoneInfo(raw), raw, False
+ except Exception:
+ return timezone(timedelta(hours=8)), "UTC+08:00", True
+
+
def _cell(wd: int, slot: int) -> int:
return wd * SLOTS_PER_DAY + (slot % SLOTS_PER_DAY)
@@ -60,7 +76,9 @@ def merge_noise(ts: Sequence[float], gap_seconds: float) -> list[float]:
return out
-def free_gaps(ts: Sequence[float], start: float, end: float) -> list[tuple[float, float]]:
+def free_gaps(
+ ts: Sequence[float], start: float, end: float
+) -> list[tuple[float, float]]:
"""返回 [start, end) 内的全部无消息区间。"""
if end <= start:
return []
@@ -69,9 +87,7 @@ def free_gaps(ts: Sequence[float], start: float, end: float) -> list[tuple[float
return [(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
-def longest_free(
- ts: Sequence[float], start: float, end: float
-) -> tuple[float, float]:
+def longest_free(ts: Sequence[float], start: float, end: float) -> tuple[float, float]:
"""[start, end) 内最长的无消息区间。"""
gaps = free_gaps(ts, start, end)
if not gaps:
@@ -113,15 +129,34 @@ def _normalize(values: Sequence[float]) -> list[float]:
return [v / total for v in values]
-def _runs(mask: Sequence[bool], limit: int) -> list[tuple[int, int]]:
- """环形找连续 True 段,返回 (起点, 长度),长度不超过 limit。"""
+def _runs(
+ mask: Sequence[bool], limit: int, circular: bool = True
+) -> list[tuple[int, int]]:
+ """找连续 True 段,返回 (起点, 长度),长度不超过 limit。
+
+ circular=True 用于单日 12 槽(跨午夜是本日内的相邻),
+ 拼接两天的 24 槽必须传 False——否则首尾会被当成相邻,选出一周之外的窗。
+ """
n = len(mask)
if n == 0:
return []
if all(mask):
- return [(0, limit)]
+ return [(0, min(n, limit))] if not circular else [(0, limit)]
if not any(mask):
return []
+ if not circular:
+ out: list[tuple[int, int]] = []
+ i = 0
+ while i < n:
+ if not mask[i]:
+ i += 1
+ continue
+ j = i
+ while j < n and mask[j]:
+ j += 1
+ out.append((i, min(j - i, limit)))
+ i = j
+ return out
start = mask.index(False)
seq = list(mask[start:]) + list(mask[:start])
out: list[tuple[int, int]] = []
@@ -180,6 +215,9 @@ class PlannerState:
seen_days: list[str] = field(default_factory=list)
recent: list[float] = field(default_factory=list)
last_update: float = 0.0
+ #: 热度图对应的时区名与目标用户,任一变化都要重建,否则星期几/钟点对不上
+ tz_name: str = ""
+ uid: str = ""
def to_dict(self) -> dict[str, Any]:
return {
@@ -188,6 +226,8 @@ def to_dict(self) -> dict[str, Any]:
"seen_days": self.seen_days[-_MAX_SEEN_DAYS:],
"recent": self.recent[-_MAX_RECENT:],
"last_update": self.last_update,
+ "tz_name": self.tz_name,
+ "uid": self.uid,
}
@@ -199,12 +239,9 @@ class IdlePlanner:
def __init__(self, cfg: Any) -> None:
self._cfg = cfg
- self.tz: tzinfo
tz_name = getattr(cfg, "idle_timezone", "Asia/Shanghai")
- try:
- self.tz = ZoneInfo(tz_name)
- except Exception:
- self.tz = timezone(timedelta(hours=8))
+ self.tz, self.tz_name, self.tz_fallback = resolve_timezone(tz_name)
+ self.uid = str(getattr(cfg, "target_user_id", "") or "")
self.state = PlannerState()
self.noise_gap = getattr(cfg, "idle_noise_gap_minutes", 15) * 60
@@ -231,6 +268,19 @@ def load(self, payload: dict[str, Any]) -> None:
raw = payload.get("idle_planner") if isinstance(payload, dict) else None
if not isinstance(raw, dict):
return
+ saved_tz = str(raw.get("tz_name", "") or "")
+ saved_uid = str(raw.get("uid", "") or "")
+ # 时区或目标用户换了,旧星期几/钟点的计数就不能再用
+ if saved_tz and saved_tz != self.tz_name:
+ _LOG.info(
+ f"[Subconscious] Planner history dropped: tz {saved_tz} -> {self.tz_name}"
+ )
+ return
+ if saved_uid and self.uid and saved_uid != self.uid:
+ _LOG.info(
+ f"[Subconscious] Planner history dropped: uid {saved_uid} -> {self.uid}"
+ )
+ return
try:
fine = [float(v) for v in raw.get("fine", [])]
coarse = [float(v) for v in raw.get("coarse", [])]
@@ -245,6 +295,8 @@ def load(self, payload: dict[str, Any]) -> None:
if isinstance(recent, list):
self.state.recent = [float(v) for v in recent][-_MAX_RECENT:]
self.state.last_update = float(raw.get("last_update", 0.0))
+ self.state.tz_name = self.tz_name
+ self.state.uid = self.uid
except (TypeError, ValueError):
self.state = PlannerState()
@@ -278,6 +330,8 @@ def observe(self, ts: float | None = None) -> None:
self.state.seen_days.append(day_key)
if len(self.state.seen_days) > _MAX_SEEN_DAYS:
self.state.seen_days = self.state.seen_days[-_MAX_SEEN_DAYS:]
+ self.state.tz_name = self.tz_name
+ self.state.uid = self.uid
self.state.recent.append(now_ts)
if len(self.state.recent) > _MAX_RECENT:
self.state.recent = self.state.recent[-_MAX_RECENT:]
@@ -307,21 +361,36 @@ def note_window_taken(self, start_ts: float) -> None:
# ------------------------------------------------------------ 分层分值
def active_days_of(self, weekdays: Sequence[int]) -> int:
+ """统计近期活跃天数:超过粗层半衰期 4 倍的老日期不再计入。
+
+ 不设上限的话,用户换作息后旧日期会永久撑着 fine/mid 层,
+ 稀薄但"看起来够量"的样本反而比全局层更不可信。
+ """
+ horizon = max(1, int(self.tau_coarse * 4))
+ cutoff = datetime.now(self.tz) - timedelta(days=horizon)
cnt = 0
- keys = set(self.state.seen_days)
- for key in keys:
+ for key in set(self.state.seen_days):
try:
dt = datetime.strptime(key, "%Y-%m-%d")
except ValueError:
continue
+ if dt.replace(tzinfo=self.tz) < cutoff:
+ continue
if dt.weekday() in weekdays:
cnt += 1
return cnt
def _shrink(self, cells: Sequence[float], n_days: int) -> list[float]:
- alpha = n_days / (n_days + self.shrink_k) if (n_days + self.shrink_k) > 0 else 0.0
- base = self.state.coarse
- return [alpha * cells[i] + (1 - alpha) * base[i] for i in range(SLOTS_PER_DAY)]
+ """把细层分布按可信度向全局分布收缩。
+
+ 两层先各自归一化再混合:细层一天一次采样、全局层跨所有天累加,
+ 直接相加会让全局层凭绝对计数压过 alpha 想表达的信任度。
+ """
+ total = n_days + self.shrink_k
+ alpha = n_days / total if total > 0 else 0.0
+ base = _normalize(self.state.coarse)
+ cur = _normalize(cells)
+ return [alpha * cur[i] + (1 - alpha) * base[i] for i in range(SLOTS_PER_DAY)]
def tier_table(self, tier: str, wd: int) -> tuple[list[float], list[float], int]:
"""返回 (槽位权重, 槽位原始计数, 有效样本天数)。"""
@@ -352,9 +421,21 @@ def _tiers_for(self, wd: int) -> list[str]:
# ------------------------------------------------------------ 冷段
- def _cold_runs(
- self, tier: str, wd: int
- ) -> list[tuple[int, int, int]]:
+ def _tier_for_neighbor(self, tier: str, wd: int) -> str:
+ """跨午夜扫描时,给相邻星期几单独挑一档可用分层。
+
+ 直接沿用当天的 tier,会在相邻那天样本不足时拿 0 计数参与判定,
+ 那些槽位会被误判成冷槽。
+ """
+ if tier == "fine" and self.active_days_of([wd]) < self.min_weeks_fine:
+ return self._tier_for_neighbor("mid", wd)
+ if tier == "mid":
+ days = [0, 1, 2, 3, 4] if wd <= 4 else [5, 6]
+ if self.active_days_of(days) < self.min_weeks_mid:
+ return "coarse"
+ return tier
+
+ def _cold_runs(self, tier: str, wd: int) -> list[tuple[int, int, int]]:
"""返回 [(环形起点槽, 长度, 归属星期几)],长度上限一天。"""
weights, counts, _ = self.tier_table(tier, wd)
pcts = _normalize(weights)
@@ -363,19 +444,28 @@ def _cold_runs(
for s in range(SLOTS_PER_DAY)
]
# 跨午夜:接上下一星期几的表,否则 22:00-08:00 的安静段会被切断
- w2, c2, _ = self.tier_table(tier, (wd + 1) % DAYS)
+ wd2 = (wd + 1) % DAYS
+ tier2 = self._tier_for_neighbor(tier, wd2)
+ w2, c2, _ = self.tier_table(tier2, wd2)
p2 = _normalize(w2)
mask2 = [
p2[s] <= self.cold_pct_max and c2[s] <= self.cold_abs_max
for s in range(SLOTS_PER_DAY)
]
+ # 拼接表首尾在真实时间上相隔整整一天,不能按环形再接回去
ring = mask + mask2
- return [(start, length, wd) for start, length in _runs(ring, SLOTS_PER_DAY)]
+ return [
+ (start, length, wd) for start, length in _runs(ring, SLOTS_PER_DAY, False)
+ ]
def _lightest_run(
- self, tier: str, wd: int, length_slots: int
+ self, tier: str, wd: int, length_slots: int, now: datetime
) -> tuple[int, int] | None:
- """环形滑动窗口找总权重最低的连续 length_slots 槽段。"""
+ """环形滑动窗口找总权重最低的连续 length_slots 槽段。
+
+ 权重相同时取最近一次将要到来的那段,避免全天打平时
+ 因为起点槽已过而把窗口推到下周。
+ """
weights, _, _ = self.tier_table(tier, wd)
pcts = _normalize(weights)
if sum(pcts) <= 1e-9:
@@ -383,11 +473,14 @@ def _lightest_run(
n = SLOTS_PER_DAY
ring = pcts + pcts
best: tuple[int, int] | None = None
- best_sum = float("inf")
+ best_key: tuple[float, float] | None = None
for start in range(n):
total = sum(ring[start : start + length_slots])
- if total < best_sum - 1e-12:
- best_sum = total
+ seg_start, _ = self._seg_bounds(start, length_slots, wd, now)
+ delay = max(0.0, (seg_start - now.astimezone(self.tz)).total_seconds())
+ key = (total, delay)
+ if best_key is None or key < best_key:
+ best_key = key
best = (start, length_slots)
return best
@@ -402,6 +495,19 @@ def _next_occurrence(self, wd: int, now: datetime) -> datetime:
cand += timedelta(days=DAYS)
return cand
+ def _seg_bounds(
+ self, start_slot: int, length_slots: int, wd: int, now: datetime
+ ) -> tuple[datetime, datetime]:
+ """冷段对应的绝对时间区间;已过去的按下一个同类星期几算。"""
+ day0 = self._next_occurrence(wd, now)
+ seg_start = day0 + timedelta(minutes=start_slot * SLOT_MINUTES)
+ seg_end = seg_start + timedelta(minutes=length_slots * SLOT_MINUTES)
+ now_local = now.astimezone(self.tz)
+ if seg_end <= now_local:
+ seg_start += timedelta(days=DAYS)
+ seg_end += timedelta(days=DAYS)
+ return seg_start, seg_end
+
def _refine(
self,
start_slot: int,
@@ -412,23 +518,23 @@ def _refine(
penalty: int,
relaxed: int,
forced: bool,
- ) -> Plan:
- """把冷段换成具体时间窗:段内找最长空闲,二分校核,扣 buffer。"""
- day0 = self._next_occurrence(wd, now)
- seg_start = day0 + timedelta(minutes=start_slot * SLOT_MINUTES)
- seg_end = seg_start + timedelta(minutes=length_slots * SLOT_MINUTES)
+ ) -> Plan | None:
+ """把冷段换成具体时间窗:段内找最长空闲,二分校核,扣 buffer。
+
+ 段内连一次运行都放不下时返回 None,交给调用方换下一段,
+ 而不是报出一个实际用不了的时间窗。
+ """
+ seg_start, seg_end = self._seg_bounds(start_slot, length_slots, wd, now)
now_local = now.astimezone(self.tz)
- if seg_end <= now_local:
- seg_start += timedelta(days=DAYS)
- seg_end += timedelta(days=DAYS)
if seg_start < now_local:
seg_start = now_local + timedelta(minutes=1)
+ if seg_end <= seg_start:
+ return None
# 只信原始时间戳:连发消息先合并,再取段内最长无消息区间
ts = merge_noise(self.state.recent, self.noise_gap)
s_ts, e_ts = seg_start.timestamp(), seg_end.timestamp()
f_start, f_end = longest_free(ts, s_ts, e_ts)
- # 二分只为确认这段空闲的上界(段内可能有更细的分布)
capacity = bisect_free_minutes(ts, s_ts, e_ts)
gap_minutes = int((f_end - f_start) // 60)
# 二分给出理论上界,实际可用不得超出真实无消息区间
@@ -445,22 +551,23 @@ def _refine(
start_ts = min(max(f_start, target.timestamp()), latest_start)
start_ts = max(start_ts, f_start, now.timestamp() + 60)
+ note = ""
if f_end - start_ts < need * 60:
- # 段内剩余空间放不下一次运行,标记但不放弃(保底语义)
- length_minutes = max(1, int((f_end - start_ts) // 60))
- note = "insufficient_free_space"
+ # 段内剩余空间放不下一次运行,退回段首再算一次
start_ts = f_start
- length_minutes = max(1, int((f_end - f_start) // 60))
- else:
- # 结束时间严格受空闲段与冷段双重约束
- length_minutes = int((f_end - start_ts) // 60)
- if free_minutes < length_minutes: # 保守取真实空闲长度
- length_minutes = free_minutes
- note = ""
+ note = "insufficient_free_space"
+ if f_end - start_ts < 60:
+ return None
start = datetime.fromtimestamp(start_ts, self.tz)
- end = min(start + timedelta(minutes=length_minutes),
- datetime.fromtimestamp(f_end, self.tz))
+ # 尾部 buffer 不纳入回报窗口,否则 end 会把安全余量也算成可用时间
+ usable_end = datetime.fromtimestamp(f_end, self.tz) - timedelta(
+ minutes=self.buffer_minutes
+ )
+ end = min(start + timedelta(minutes=free_minutes), usable_end)
+ length_minutes = int((end - start).total_seconds() // 60)
+ if length_minutes < 1:
+ return None
return Plan(
start=start,
end=end,
@@ -476,9 +583,7 @@ def _refine(
def _fallback(self, now: datetime, penalty: int, note: str) -> Plan:
local = now.astimezone(self.tz)
- target = local.replace(
- hour=self.target_hour, minute=0, second=0, microsecond=0
- )
+ target = local.replace(hour=self.target_hour, minute=0, second=0, microsecond=0)
if target <= local + timedelta(minutes=1):
target += timedelta(days=1)
return Plan(
@@ -491,11 +596,49 @@ def _fallback(self, now: datetime, penalty: int, note: str) -> Plan:
note=note,
)
+ def has_history(self) -> bool:
+ """热度图是否有可用样本。"""
+ return sum(self.state.coarse) > 1e-6
+
+ def fallback_plan(
+ self, penalty: int = 1, now: datetime | None = None, note: str = "no_history"
+ ) -> Plan:
+ """直接给出保底窗口,不做冷段扫描。"""
+ now = now or datetime.now(timezone.utc)
+ return self._fallback(now.astimezone(self.tz), penalty, note)
+
def plan(self, penalty: int = 1, now: datetime | None = None) -> Plan:
result = self._plan(penalty, now)
self._last_plan = result
return result
+ def _want_slots(self, penalty: int) -> int:
+ """按惩罚值算本次要求的冷段槽数,指数增长但先封顶再取整。"""
+ exp = max(0, penalty - 1)
+ try:
+ want = self.base_slots * (self.multiplier**exp)
+ except OverflowError:
+ # 惩罚值离谱时直接给上限,不要让一次计算失败把调度整条打断
+ return self.max_slots
+ if want != want: # NaN 保护
+ return self.base_slots
+ return max(1, min(round(want), self.max_slots))
+
+ def _pick_run(
+ self, runs: Sequence[tuple[int, int, int]], now: datetime
+ ) -> tuple[int, int, int] | None:
+ """在满足门槛的冷段里挑一段:优先最近的、尚未来临的、较长的。"""
+ best: tuple[int, int, int] | None = None
+ best_key: tuple[int, float, int] | None = None
+ for start_slot, length_slots, owner in runs:
+ seg_start, _ = self._seg_bounds(start_slot, length_slots, owner, now)
+ delay = max(0.0, (seg_start - now.astimezone(self.tz)).total_seconds())
+ key = (0 if delay > 0 else 1, delay, -length_slots)
+ if best_key is None or key < best_key:
+ best_key = key
+ best = (start_slot, length_slots, owner)
+ return best
+
def _plan(self, penalty: int = 1, now: datetime | None = None) -> Plan:
now = now or datetime.now(timezone.utc)
local = now.astimezone(self.tz)
@@ -504,62 +647,66 @@ def _plan(self, penalty: int = 1, now: datetime | None = None) -> Plan:
if sum(self.state.coarse) <= 1e-6:
return self._fallback(local, penalty, "no_history")
- want = self.base_slots * (self.multiplier ** max(0, penalty - 1))
- want = max(1, min(round(want), self.max_slots))
+ want = self._want_slots(penalty)
tiers = self._tiers_for(wd)
for step in range(self.max_relax + 1):
need_slots = max(1, want >> step)
for tier in tiers:
- runs = [
- r for r in self._cold_runs(tier, wd) if r[1] >= need_slots
- ]
- if not runs:
+ runs = [r for r in self._cold_runs(tier, wd) if r[1] >= need_slots]
+ picked = self._pick_run(runs, now)
+ if picked is None:
continue
- start_slot, length_slots, owner = max(
- runs, key=lambda r: (r[1], -r[0])
- )
- return self._refine(
- start_slot,
- length_slots,
- owner,
+ plan = self._refine(
+ picked[0],
+ picked[1],
+ picked[2],
now,
tier,
penalty,
step,
forced=False,
)
+ if plan is not None:
+ return plan
# 全部放宽仍不足:强制取最长冷段
- best: tuple[int, int, int, str] | None = None
for tier in tiers:
- for run in self._cold_runs(tier, wd):
- if best is None or run[1] > best[1]:
- best = (run[0], run[1], run[2], tier)
- if best is not None:
- return self._refine(
- best[0], best[1], best[2], now, best[3], penalty,
- self.max_relax, forced=True,
+ runs = sorted(self._cold_runs(tier, wd), key=lambda r: -r[1])
+ picked = self._pick_run(runs, now)
+ if picked is None:
+ continue
+ plan = self._refine(
+ picked[0],
+ picked[1],
+ picked[2],
+ now,
+ tier,
+ penalty,
+ self.max_relax,
+ forced=True,
)
- # 完全没有冷槽(用户几乎全天活跃):退到热度最低的连续段
+ if plan is not None:
+ return plan
+
+ # 完全没有可用冷段(用户几乎全天活跃):退到热度最低的连续段
for tier in tiers:
- floor_run = self._lightest_run(tier, wd, 1)
- if floor_run is not None:
- return self._refine(
- floor_run[0], floor_run[1], wd, now, tier, penalty,
- self.max_relax, forced=True,
- )
+ floor_run = self._lightest_run(tier, wd, 1, now)
+ if floor_run is None:
+ continue
+ plan = self._refine(
+ floor_run[0],
+ floor_run[1],
+ wd,
+ now,
+ tier,
+ penalty,
+ self.max_relax,
+ forced=True,
+ )
+ if plan is not None:
+ return plan
return self._fallback(local, penalty, "no_cold_segment")
- return self._refine(
- best[0],
- best[1],
- best[2],
- now,
- best[3],
- penalty,
- self.max_relax,
- forced=True,
- )
def describe(plan: Plan) -> str:
diff --git a/amrita_plugin_memory/rethinking/tools.py b/amrita_plugin_memory/rethinking/tools.py
index dc7a5e4..e026504 100644
--- a/amrita_plugin_memory/rethinking/tools.py
+++ b/amrita_plugin_memory/rethinking/tools.py
@@ -29,6 +29,7 @@
from ..vector import AsyncUserMemory, MemoryMetadata, get_db_conn
from . import _state
from .consts import DEFAULT_SEND_PROMPT, ensure_prompt_file, load_character_prompt
+from .schedule import resolve_timezone
from .schemas import (
DELETE_MEMORY_SCHEMA,
DUPLICATE_HELPER_SCHEMA,
@@ -64,6 +65,9 @@
_DUPLICATE_DEFAULT_LIMIT = 40
_DUPLICATE_MAX_LIMIT = 200
+#: get_context 里向量库操作的超时上限(秒),超时只标 stale 不阻塞循环
+_CONTEXT_DB_TIMEOUT = 8.0
+
_SUBCONSCIOUS_TOOLS = _state.get_tools_manager()
@@ -249,6 +253,15 @@ async def subconscious_list_memory(data: dict[str, Any]) -> str:
@on_tools(ITER_STOP_SCHEMA, strict=True, bound_to=_SUBCONSCIOUS_TOOLS)
async def subconscious_iter_stop(data: dict[str, Any]) -> str:
summary = str(data.get("summary", ""))
+ if not _state.is_context_fetched():
+ # 没取环境就收工:周期算无效,不写摘要。惩罚保持不清零,
+ # 下一次调度仍带着这轮攒的量,避免"空转一轮还被当成完成"
+ logger.warning("[Subconscious] iter_stop without get_context, cycle invalid")
+ _state.mark_cycle_invalid()
+ return _tools_err(
+ "本轮未调用 subconscious_get_context,周期无效。"
+ "请先取当前环境再决定是否整理记忆"
+ )
_state.set_last_iter_summary(summary)
logger.info(f"[Subconscious] iter_stop: summary={summary[:80]}")
return json.dumps(
@@ -594,13 +607,10 @@ async def subconscious_get_context(data: dict[str, Any]) -> str:
runner = _state.get_runner()
cfg = runner._config if runner is not None else None
- tz_name = getattr(cfg, "idle_timezone", "Asia/Shanghai") if cfg else "Asia/Shanghai"
- try:
- from zoneinfo import ZoneInfo
-
- tz = ZoneInfo(tz_name)
- except Exception:
- tz = timezone.utc
+ tz_cfg = getattr(cfg, "idle_timezone", "Asia/Shanghai") if cfg else "Asia/Shanghai"
+ # 与调度侧共用同一份解析,避免规划窗口与回报钟点分处两个时区
+ tz, tz_name, tz_fell_back = resolve_timezone(tz_cfg)
+ if tz_fell_back:
stale.append("timezone")
now = datetime.now(timezone.utc)
@@ -622,12 +632,17 @@ async def subconscious_get_context(data: dict[str, Any]) -> str:
memory_hint = ""
try:
ope = _make_operator()
- await ope.init()
+ await asyncio.wait_for(ope.init(), timeout=_CONTEXT_DB_TIMEOUT)
pid = _get_partition_id()
- total = await ope.count_user_notes(pid)
+ total = await asyncio.wait_for(
+ ope.count_user_notes(pid), timeout=_CONTEXT_DB_TIMEOUT
+ )
threshold = getattr(cfg, "memory_warn_threshold", 100) if cfg else 100
if total > threshold:
- result = await ope.get_all_notes(pid, include=["metadatas"])
+ result = await asyncio.wait_for(
+ ope.get_all_notes(pid, include=["metadatas"]),
+ timeout=_CONTEXT_DB_TIMEOUT,
+ )
low_count = sum(
1
for m in (result.get("metadatas") or [])
@@ -648,11 +663,13 @@ async def subconscious_get_context(data: dict[str, Any]) -> str:
except Exception:
stale.append("idle_plan")
+ _state.mark_context_fetched()
return json.dumps(
{
"status": "success",
"now": local.strftime("%Y-%m-%d %H:%M:%S"),
"timezone": tz_name,
+ "timezone_fallback": tz_fell_back,
"weekday": local.strftime("%A"),
"last_run_at": last_run_iso,
"minutes_since_last_run": gap_minutes,
From 8fb54bc67f2f48b5fb47dc8cc3ef4d027e151554 Mon Sep 17 00:00:00 2001
From: AmriaLin <325560502+AmriaLin@users.noreply.github.com>
Date: Tue, 20 Oct 2026 07:44:55 +0000
Subject: [PATCH 6/6] =?UTF-8?q?style(schedule):=20=E5=8E=BB=E6=8E=89?=
=?UTF-8?q?=E5=88=86=E8=8A=82=E6=A8=AA=E7=BA=BF=E6=B3=A8=E9=87=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
按仓库约定不写 # ---- 小节名 这类分隔线,改由空行与函数拆分表达结构。
模块 docstring 里便于独立单测措辞同步调整(本仓库不建 tests 目录)。
---
amrita_plugin_memory/rethinking/schedule.py | 21 +--------------------
1 file changed, 1 insertion(+), 20 deletions(-)
diff --git a/amrita_plugin_memory/rethinking/schedule.py b/amrita_plugin_memory/rethinking/schedule.py
index 14f18ac..b1b7006 100644
--- a/amrita_plugin_memory/rethinking/schedule.py
+++ b/amrita_plugin_memory/rethinking/schedule.py
@@ -7,7 +7,7 @@
- 规划:环形找连续冷槽 -> 段内用原始时间戳做分钟级二分 -> 扣 buffer 得可用窗口。
- 保底:冷段不足门槛时逐次减半放宽,最后强制取最长冷段。
-本模块不依赖 nonebot,纯数据 + 纯函数,便于独立单测。
+本模块不依赖 nonebot,纯数据 + 纯函数,可脱离运行时单独验证。
"""
from __future__ import annotations
@@ -61,9 +61,6 @@ def slot_of(ts: float, tz: tzinfo) -> tuple[int, int]:
return wd, slot
-# ---------------------------------------------------------------- 纯函数工具
-
-
def merge_noise(ts: Sequence[float], gap_seconds: float) -> list[float]:
"""把一波连发的消息压成单点:间距小于 gap 的后来者直接并掉。"""
out: list[float] = []
@@ -173,9 +170,6 @@ def _runs(
return out
-# ---------------------------------------------------------------- 规划结果
-
-
@dataclass
class Plan:
"""一次调度规划的结果。"""
@@ -231,9 +225,6 @@ def to_dict(self) -> dict[str, Any]:
}
-# ---------------------------------------------------------------- 规划器
-
-
class IdlePlanner:
"""基于热度图的空闲窗口规划器。"""
@@ -262,8 +253,6 @@ def __init__(self, cfg: Any) -> None:
self.fallback_minutes = getattr(cfg, "idle_fallback_minutes", 120)
self._last_plan: Plan | None = None
- # ------------------------------------------------------------ 持久化
-
def load(self, payload: dict[str, Any]) -> None:
raw = payload.get("idle_planner") if isinstance(payload, dict) else None
if not isinstance(raw, dict):
@@ -303,8 +292,6 @@ def load(self, payload: dict[str, Any]) -> None:
def dump(self) -> dict[str, Any]:
return {"idle_planner": self.state.to_dict()}
- # ------------------------------------------------------------ 观测
-
def _decay(self, now_ts: float) -> None:
if self.state.last_update <= 0:
self.state.last_update = now_ts
@@ -358,8 +345,6 @@ def note_window_taken(self, start_ts: float) -> None:
"""窗口实际被占用后,把该时段计入一次观测(避免反复选同一段)。"""
self.observe(start_ts + 60)
- # ------------------------------------------------------------ 分层分值
-
def active_days_of(self, weekdays: Sequence[int]) -> int:
"""统计近期活跃天数:超过粗层半衰期 4 倍的老日期不再计入。
@@ -419,8 +404,6 @@ def _tiers_for(self, wd: int) -> list[str]:
tiers.append("coarse")
return tiers
- # ------------------------------------------------------------ 冷段
-
def _tier_for_neighbor(self, tier: str, wd: int) -> str:
"""跨午夜扫描时,给相邻星期几单独挑一档可用分层。
@@ -579,8 +562,6 @@ def _refine(
note=note,
)
- # ------------------------------------------------------------ 主流程
-
def _fallback(self, now: datetime, penalty: int, note: str) -> Plan:
local = now.astimezone(self.tz)
target = local.replace(hour=self.target_hour, minute=0, second=0, microsecond=0)