Skip to content

feat(dmn): 空闲窗口调度与 prompt 静态化 (0.8.0) - #6

Merged
JohnRichard4096 merged 6 commits into
mainfrom
feat/idle-window-dmn
Oct 21, 2026
Merged

JohnRichard4096 merged 6 commits into
mainfrom
feat/idle-window-dmn

Conversation

@AmriaLin

@AmriaLin AmriaLin commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

概要

把 DMN 的调度从「用户最后一条消息 + 指数延迟」改为「历史空闲窗口里选一个」,同时把 system prompt 里所有会变化的快照剥离出去。两件事同批改,因为惩罚计数器(penalty)的语义在两处共用,分开会让语义中途变形。

版本:0.7.1 -> 0.8.0

为什么改

旧方案的问题是时间基准错了。它算的是「用户消息时刻 + base × multiplier^(n-1)」,用户白天聊得越多,下一次整理就被推得越远。用户连续两天活跃,窗口就滑到 24 小时后,而那正好是他下次活跃的时段——DMN 在最该运行的窗口被饿死。而且它是相对延迟,没有锚定时钟,用户改作息窗口就跟着漂。

另一条线是 prompt。total_runs 被写进 system,模型会拿它当进度条做判断,但它不含本轮信息,也不可验证,是自举悖论。记忆库总量、待审建议数也都是循环开头算的快照,循环内不会更新,模型却在拿它做决策。

调度层

新增 rethinking/schedule.py,纯函数 + 规划器,不依赖 nonebot,可独立单测。

  • 热度图:7 天 × 12 槽(2h),用户每发一条消息对应槽 +1,全局衰减 τ=14 天
  • 分层:fine(单片星期几,τ=28 天)/ mid(工作日-周末)/ coarse(全局),按各层有效样本天数逐级降级
  • 收缩:细层向全局做 n/(n+k) 加权,k=4。单星期几的样本积累速度是全局的 1/7,不收缩会在稀薄样本上失真
  • 冷槽:双条件判定(占比 ≤ 5% 且原始计数 ≤ 1)。只按占比会在消息稀疏时分母趋零,所有槽位都变热点
  • 冷段:环形扫描,接上下一星期几的表,避免 22:00 到次日 08:00 被切断
  • 细化:段内回到原始时间戳,先合并连发噪声,再二分求可用空闲上界,扣 buffer 与预估耗时
  • penalty 新语义:不再是「推迟多少分钟」,而是「要求多长的冷段」。base 3 槽 × 1.5^(n-1),封顶 6 槽,不足则减半放宽,最多三次
  • 保底:放宽到底仍不足则强制取最长冷段;完全没有冷槽(用户几乎全活跃)则退到热度最低的连续段。任何路径都不会返回空

冷启动(无历史)直接走固定 idle_target_hour,不走规划。

prompt 层

  • system 里删掉 total_runs、精确时间、上轮摘要、待审建议计数、记忆库膨胀告警
  • 日期降到日粒度(YYYY-MM-DD):每天才变一次,不影响前缀缓存,又能给记忆新旧提供锚
  • 同日内渲染出的 system 逐字节一致,已验证连续两次 hash 相同,可命中 provider 前缀缓存
  • 新增 subconscious_get_context,一次返回精确时间、距上轮间隔、上轮摘要、待审建议、记忆库规模与当前窗口;单项查询失败只标 stale,不中断
  • 规则中加硬约束:每轮第一件事必须调它,未调用视为本轮无效

用流程契约替代信息注入,比指望模型自己刷新更可靠。

兼容与迁移

  • use_idle_window_planner 默认 true,关掉完全回退旧路径,旧字段 rethink_* 原样保留
  • 旧 penalty 语义是「被推迟的次数」,与新语义不可换算,升级时检测到没有 idle_planner 键就清零一次并打日志
  • SubconsciousState.payload 只追加新键,旧版本读不到就忽略,不写迁移脚本

验证

  • tests/test_schedule.py 13 条单测:噪声合并、二分与暴力解一致、环形跨零点、冷启动回退、惩罚不溢出窗口、全天活跃不死锁、状态往返、衰减生效
  • ruff check 全通过,compileall 通过
  • pyproject 启用 pytest(原为注释状态)

尚未做真机验证:本次 checkout 里没有实际的 prompt 模板文件,get_context 的返回结构是否被模型正确消费还需在真实会话里确认。

评审关注点

  1. 热度图参数(τ、收缩 k、冷槽阈值)都是经验值,初期可能需要在真实数据上微调
  2. 单一 24 小时模型对周末作息差异明显的用户会偏,当前靠 mid 层缓解;如果不够,下一步可以给周末单独一套衰减
  3. 强制取最低热度段的路径会撞用户消息,这是刻意的取舍:宁可被打断一次,也不能永久停摆

Summary by Sourcery

Replace delay-based DMN scheduling with adaptive idle-window planning and move changing runtime snapshots out of the system prompt.

New Features:

  • Add adaptive idle-window scheduling based on user activity history, with cold-start and fallback behavior.
  • Add a consolidated subconscious context tool and a permission-scoped read-only profile tool.

Bug Fixes:

  • Prevent stale system-prompt snapshots and invalid cycles from driving DMN decisions or clearing scheduling penalties.
  • Preserve scheduling state and penalty updates across concurrent saves, interruptions, and version upgrades.

Enhancements:

  • Make the DMN system prompt stable within each day by moving dynamic runtime information into on-demand context retrieval.
  • Add configurable activity heatmaps, scheduling tiers, time zones, penalties, relaxation, buffering, and noise handling.
  • Share profile parsing and timezone resolution across runtime components while keeping profile access restricted to the DMN target user.

Build:

  • Bump the project version from 0.7.1 to 0.8.0 and enable the pytest development dependency.

Documentation:

  • Document the profile-reading tool, its access rules, and repository development conventions.

Chores:

  • Add repository guidance for module boundaries, tooling, persistence, permissions, and contribution practices.

调度层
- 新增 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
@sourcery-ai

sourcery-ai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

本次 PR 将 DMN 从基于最后一条消息的指数延迟改为可持久化的历史活跃度空闲窗口规划,同时把易变运行信息从 system prompt 移入强制首调用的 subconscious_get_context,以改善调度时间基准、避免 prompt 快照过期并提升前缀缓存命中率;同时保留旧调度回退、处理 penalty 迁移并补充独立调度测试。

Sequence diagram for activity-based DMN rescheduling

sequenceDiagram
    participant User
    participant Hook as PreCompletionHook
    participant Runner as SubconsciousRunner
    participant Planner as IdlePlanner
    participant State as PersistentState
    participant Scheduler

    User->>Hook: New message
    Hook->>Runner: cancel_and_reschedule(chat_ts)
    Runner->>Planner: observe(chat_ts)
    Runner->>State: increment_penalty()
    Runner->>Planner: plan(penalty)
    Planner->>Planner: Select cold segment and refine free window
    Planner-->>Runner: Plan(start, end)
    Runner->>Scheduler: _schedule_once(delay_seconds)
    Runner->>State: _save_state()
Loading

Sequence diagram for dynamic DMN context retrieval

sequenceDiagram
    participant Runner as SubconsciousRunner
    participant Agent as DMN agent
    participant Context as subconscious_get_context
    participant Memory as Memory store
    participant State as Runtime state

    Runner->>Agent: Start cycle with static system prompt
    Agent->>Context: subconscious_get_context()
    Context->>State: Read last run and suggestions
    Context->>Memory: Query memory statistics
    Memory-->>Context: Counts or failure
    Context-->>Agent: Current time, interval, summary, plan, stale
    Agent->>Agent: Decide memory operations using fresh context
    Agent->>Runner: subconscious_iter_stop(summary)
Loading

Flow diagram for idle-window planning and fallback

flowchart TD
    Start([plan]) --> History{Activity history available?}
    History -- No --> ColdStart[Fixed idle_target_hour window]
    History -- Yes --> Tier[Select fine, mid, or coarse tier]
    Tier --> Cold[Find cold slots and contiguous segments]
    Cold --> Required{Segment meets penalty requirement?}
    Required -- Yes --> Refine[Merge noise and bisect free capacity]
    Required -- No --> Relax[Halve requirement up to max_relax_steps]
    Relax --> Required
    Required -- Exhausted --> Longest[Force longest cold segment]
    Longest --> Refine
    Refine --> Plan([Schedule DMN window])
Loading

File-Level Changes

Change Details Files
将 DMN 调度改为基于用户历史活跃度选择空闲窗口,并保留可配置的旧固定延迟回退路径。
  • 新增纯函数 IdlePlanner,维护 7×12 热度图、时间衰减、fine/mid/coarse 分层及样本收缩。
  • 通过双条件冷槽、环形连续冷段、消息噪声合并和段内空闲二分计算可用窗口。
  • 将 penalty 改为冷段长度要求,支持封顶、逐步放宽、强制最长冷段及全天活跃保底。
  • 在消息触发时记录热度、节流重排,并持久化规划器状态;旧状态升级时清零旧语义 penalty。
  • 增加时区、衰减、阈值、缓冲、惩罚和回退等调度配置。
amrita_plugin_memory/config.py
amrita_plugin_memory/rethinking/hooks.py
amrita_plugin_memory/rethinking/runner.py
amrita_plugin_memory/rethinking/schedule.py
tests/test_schedule.py
将动态运行快照从 system prompt 移至按需工具上下文,以保持同日 prompt 前缀稳定。
  • 从主 prompt 移除运行次数、精确时间、上轮摘要、建议计数和记忆库膨胀快照,仅保留日粒度日期及静态指南。
  • 新增 subconscious_get_context,聚合当前时间、上轮运行信息、知识建议、记忆统计和当前窗口,并以 stale 标记单项失败。
  • 通过 prompt 规则和运行入口要求每轮首先调用上下文工具;iter_stop 摘要和运行时间写入状态供工具读取。
amrita_plugin_memory/rethinking/consts.py
amrita_plugin_memory/rethinking/_state.py
amrita_plugin_memory/rethinking/runner.py
amrita_plugin_memory/rethinking/schemas.py
amrita_plugin_memory/rethinking/tools.py
完成 0.8.0 版本与测试工具链更新。
  • 版本从 0.7.1 升至 0.8.0。
  • 启用 pytest 开发依赖并新增调度器测试,覆盖噪声合并、空闲区间、环形跨午夜、冷启动、惩罚放宽、保底、状态往返和衰减。
pyproject.toml
tests/test_schedule.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 22 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="amrita_plugin_memory/rethinking/schedule.py" line_range="371-372" />
<code_context>
+            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(
</code_context>
<issue_to_address>
**False cold windows get scheduled**

When cold slots at opposite ends of the concatenated two-day mask are separated in real time, `_runs` treats the concatenated masks as circular and joins nonconsecutive slots. `_refine` maps that false run to consecutive calendar time, so the selected window includes slots that were not classified as cold.

Make run detection join only chronologically adjacent slots, including only genuine midnight transitions between the two days.
</issue_to_address>

### Comment 2
<location path="amrita_plugin_memory/rethinking/schedule.py" line_range="506-507" />
<code_context>
+        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)
</code_context>
<issue_to_address>
**Large penalties stop scheduling**

When the penalty grows enough for the configured multiplier exponentiation to overflow, `IdlePlanner._plan` computes `multiplier ** (penalty - 1)` before applying `max_slots`, which raises `OverflowError`. `cancel_and_reschedule` has already removed the existing job, so no replacement is scheduled and the DMN stops running.

Cap the required slot count without first computing an unbounded exponent, and ensure a planning failure cannot leave the DMN without a scheduled job.
</issue_to_address>

### Comment 3
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="146-150" />
<code_context>
-        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):
</code_context>
<issue_to_address>
**Recent activity does not defer the job**

When a message arrives within the throttle interval after a replan scheduled a job due soon, the throttled branch of `cancel_and_reschedule` saves the new observation and penalty, then returns without changing the pending one-shot job. That job can run during or immediately after the user's newer activity.

Defer or reschedule the pending job on throttled messages without repeating the full planning work.
</issue_to_address>

### Comment 4
<location path="amrita_plugin_memory/config.py" line_range="49" />
<code_context>
+        default=True,
+        description="是否启用空闲窗口调度(热度图 + 冷段二分);关闭后回退到旧的固定延迟方案",
+    )
+    idle_timezone: str = Field(
+        default="Asia/Shanghai", description="热度图与窗口计算使用的时区"
+    )
</code_context>
<issue_to_address>
**Context and schedule use different timezones**

When `idle_timezone` is not a valid IANA timezone, `IdlePlanner` falls back to UTC+08:00 while `subconscious_get_context` falls back to UTC and can still return the invalid configured name. The planned window and reported local time disagree, so the caller receives a misleading schedule and clock.

Resolve and validate the timezone consistently for both planning and context, and report the timezone actually used.

Also at `amrita_plugin_memory/rethinking/schedule.py:206`, `amrita_plugin_memory/rethinking/tools.py:597-598`.
</issue_to_address>

### Comment 5
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="461-462" />
<code_context>
+            "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:
</code_context>
<issue_to_address>
**Older saves erase planner history**

When an older plugin instance saves the shared state after a newer instance has stored planner data, the older `_save_state` writes a replacement payload without `idle_planner`. The next new instance loads the row as legacy state and loses the accumulated planner history.

Preserve planner fields when older-version payloads update shared state, or store planner history separately from the replaceable legacy payload.
</issue_to_address>

### Comment 6
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="153-154" />
<code_context>
+            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(
</code_context>
<issue_to_address>
**Active curation survives new chat**

When a user message arrives while a DMN cycle is already running, `cancel_and_reschedule` removes a scheduled job but does not stop an in-progress `_run_cycle`; the old cycle continues using its existing `ChatObject` while the user's new message is being processed, so its later memory changes apply after the conversation has moved on.

Cancel or invalidate the active cycle on new chat and prevent its stale results from being applied.
</issue_to_address>

### Comment 7
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="133" />
<code_context>
         """
+        # 热度图观测:每条消息都算,加深该时间窗口的热点
         with contextlib.suppress(Exception):
-            scheduler.remove_job(self._job_id)
+            self._planner.observe(chat_ts)
         penalty = _state.increment_penalty()
</code_context>
<issue_to_address>
**First message bypasses cold start**

When the planner has no prior history and receives its first user message through the normal rescheduling hook, `cancel_and_reschedule` records the triggering message before calling `plan`, so the first ordinary trigger makes `state.coarse` nonzero and bypasses `_fallback`. With only that one sample, every unobserved slot can qualify as cold, and the first run is scheduled from a sparse-data plan instead of the configured fallback window.

Determine whether history existed before recording the triggering message, and use the fallback until sufficient prior samples are available.

Also at `amrita_plugin_memory/rethinking/schedule.py:503-504`.
</issue_to_address>

### Comment 8
<location path="amrita_plugin_memory/rethinking/schedule.py" line_range="421-422" />
<code_context>
+        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)
</code_context>
<issue_to_address>
**Past quiet slots delay planning a week**

When the longest qualifying cold run has already ended today, or all fallback slot weights tie and slot 0 has passed, `_plan` selects the longest qualifying run without considering whether its time has already passed; in the no-cold-slots fallback, `_lightest_run` breaks equal-weight ties in favor of slot 0. `_refine` then moves an expired run forward by seven days, so a currently active user can have the fallback schedule pushed to next week instead of a later quiet slot.

Make run selection time-aware: prefer a still-upcoming valid interval and, in the lowest-heat fallback, break ties using the nearest upcoming slot rather than the earliest slot index.

Also at `amrita_plugin_memory/rethinking/schedule.py:387-390`, `amrita_plugin_memory/rethinking/schedule.py:519-520`.
</issue_to_address>

### Comment 9
<location path="amrita_plugin_memory/rethinking/tools.py" line_range="619-621" />
<code_context>
-        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"])
</code_context>
<issue_to_address>
**Context lookup can hang**

When the configured vector database accepts a connection but a request stalls, `subconscious_get_context` awaits Chroma initialization and count retrieval without a timeout. A stalled remote vector database leaves the required first tool call pending indefinitely, blocking the DMN cycle instead of returning context marked stale.

Apply a bounded timeout to the context tool’s database operations and mark the affected fields stale when it expires.
</issue_to_address>

### Comment 10
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="280" />
<code_context>
                 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())

         # 先持久化,任一失败则不重置惩罚(保留重试机会)
</code_context>
<issue_to_address>
**Concurrent chat penalty is lost**

When a user message is handled while `_post_process` is awaiting its state save, `_post_process` resets the shared penalty after the await, overwriting the increment made by the chat hook and its newly scheduled plan. Later scheduling therefore uses a lower penalty than the user's activity warrants.

Prevent `_post_process` from resetting penalty increments made after its save began, for example by synchronizing updates or comparing the penalty before resetting it.
</issue_to_address>

### Comment 11
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="462" />
<code_context>
+            "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:
</code_context>
<issue_to_address>
**Overlapping saves overwrite newer state**

When two callers invoke `_save_state` concurrently and the older snapshot's database update commits after the newer one, concurrent `_save_state` calls serialize snapshots separately and update the same payload without version checking; if an older snapshot commits last, it replaces newer heat-map observations, penalty, or summary state with stale values.

Serialize state writes with a lock or use a versioned/merge update so older snapshots cannot replace newer state.

Also at `amrita_plugin_memory/rethinking/hooks.py:30`.
</issue_to_address>

### Comment 12
<location path="amrita_plugin_memory/rethinking/schedule.py" line_range="365-366" />
<code_context>
+            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
</code_context>
<issue_to_address>
**Unobserved weekday treated as cold**

When the current weekday has enough samples for the fine or mid tier but the following weekday does not, `_cold_runs` applies the current weekday’s selected tier to the following weekday without checking whether that weekday meets the tier’s sample threshold. For an unsampled neighbor, its raw fine or mid counts are zero, so low-share slots can pass the cold test and be selected despite having no reliable weekday-specific observations.

Choose the tier independently for each weekday in the cross-midnight scan, falling back to a sufficiently sampled tier for the neighbor.
</issue_to_address>

### Comment 13
<location path="amrita_plugin_memory/rethinking/schedule.py" line_range="308-318" />
<code_context>
+
+    # ------------------------------------------------------------ 分层分值
+
+    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]:
</code_context>
<issue_to_address>
**Stale dates keep fine tiers active**

When the user is inactive for a long period or changes routine while old dates remain in `seen_days`, `active_days_of` counts retained dates regardless of age, and `_decay` does not expire them. `_tiers_for` therefore keeps selecting fine or mid tiers based on stale patterns instead of falling back as history fades.

Expire or age out old dates when counting active days so fine and mid tiers require recent support.

Also at `amrita_plugin_memory/rethinking/schedule.py:259-266`.
</issue_to_address>

### Comment 14
<location path="amrita_plugin_memory/rethinking/schedule.py" line_range="443-461" />
<code_context>
+        latest_start = max(f_start, f_end - need * 60)
</code_context>
<issue_to_address>
**Reported window consumes safety buffer**

When a plan is refined from a free gap, `_refine` reserves `buffer_minutes` when choosing the start, but reports the interval through `f_end`. `Plan.end` therefore includes the configured safety buffer and overstates usable time.

Subtract the end buffer from the reported plan end and usable duration.
</issue_to_address>

### Comment 15
<location path="amrita_plugin_memory/rethinking/schedule.py" line_range="186-191" />
<code_context>
+
+    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,
+        }
+
+
</code_context>
<issue_to_address>
**Saved heatmap uses wrong timezone**

When the configured timezone or target-user identity changes after planner history is saved, `IdlePlanner.load` restores `fine` and `coarse` buckets without checking which timezone or user they represent. Planning then interprets old weekday and hour counts under the new configuration and selects windows from mismatched activity.

Persist and validate the timezone and target-user identity, clearing or rebuilding the heatmap when either changes.

Also at `amrita_plugin_memory/rethinking/schedule.py:237`.
</issue_to_address>

### Comment 16
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="175-176" />
<code_context>
+        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
</code_context>
<issue_to_address>
**State-save failures go unnoticed**

When the state database is unavailable or a state write fails during rescheduling, `cancel_and_reschedule` suppresses `_save_state` errors without logging them, so a failed write silently loses the new penalty and heatmap across a restart; operators see no indication that persisted scheduling state is stale.

Log state-save failures while keeping rescheduling independent of persistence success.
</issue_to_address>

### Comment 17
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="409" />
<code_context>
+            self._planner.load(payload)
+            # 旧 penalty 语义是"被推迟的次数",与新语义(要求的冷段长度)不可换算,
+            # 升级后清零一次,避免带着旧值把门槛顶到上限
+            if "idle_planner" not in payload and _state.get_penalty_count() > 0:
+                logger.info(
+                    "[Subconscious] Migrate: reset legacy penalty "
</code_context>
<issue_to_address>
**Legacy penalty survives planner switch**

When the legacy fallback is used to accumulate a nonzero penalty and the planner is enabled later, `_save_state` writes `idle_planner` even when the legacy scheduler is enabled, so `_load_state` treats that payload as migrated and skips clearing the legacy delay-based penalty. Switching to the planner then interprets that count as a cold-slot requirement and schedules with the wrong penalty.

Persist and check an explicit penalty-semantics version, or reset the penalty when switching from legacy scheduling to the idle planner.

Also at `amrita_plugin_memory/rethinking/runner.py:461`.
</issue_to_address>

### Comment 18
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="409-414" />
<code_context>
+            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}")
</code_context>
<issue_to_address>
**Legacy mode loses saved penalty**

When an upgrade loads an old payload with a nonzero penalty while `use_idle_window_planner` is false, `_load_state` resets the persisted penalty whenever the old payload lacks `idle_planner`, even when idle planning is disabled. The first legacy-mode reschedule after upgrading therefore uses the base delay instead of the previously accumulated exponential delay.

Reset the legacy penalty only when the idle-window planner is enabled.
</issue_to_address>

### Comment 19
<location path="amrita_plugin_memory/rethinking/consts.py" line_range="31-32" />
<code_context>
 1. 你**必须**使用提供的工具来检查和操作用户的记忆库。不要凭空想象或推测记忆内容。
-2. 每次推理循环中,你应该:
+2. 每轮**第一件事**必须调用 subconscious_get_context 获取当前环境
+   (精确时间、距上轮间隔、上次摘要、待审查建议、记忆库规模)。
+   不调用则视为本轮无效,不要凭上一轮的印象行动。
+3. 之后你应该:
    a. 先调用 subconscious_list_memory 或 subconscious_read_memory 了解记忆库当前状态
</code_context>
<issue_to_address>
**Skipped context still counts**

When the model skips `subconscious_get_context` but calls `subconscious_iter_stop`, the first-call rule exists only in prompt text: `subconscious_iter_stop` stores any summary and acknowledges the cycle without checking whether `subconscious_get_context` ran. A cycle that skips the required context is still processed as valid and can reset the penalty.

Track whether context was fetched for the current cycle and reject or mark `iter_stop` invalid when it was not.

Also at `amrita_plugin_memory/rethinking/tools.py:246-247`.
</issue_to_address>

### Comment 20
<location path="amrita_plugin_memory/rethinking/runner.py" line_range="405" />
<code_context>
             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 语义是"被推迟的次数",与新语义(要求的冷段长度)不可换算,
</code_context>
<issue_to_address>
**Upgrade loses last-run time**

When an installation upgrades with a state row written by 0.7.1, which has `last_run_time` but not `last_run_ts`, `_load_state` reads only `last_run_ts`, so an existing 0.7.1 payload’s `last_run_time` is discarded and `subconscious_get_context` reports no prior run or elapsed interval until a later cycle completes.

When `last_run_ts` is absent, parse the legacy `last_run_time` value and use it to initialize the last-run timestamp.
</issue_to_address>

### Comment 21
<location path="amrita_plugin_memory/rethinking/schedule.py" line_range="321-323" />
<code_context>
+        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]:
</code_context>
<issue_to_address>
**Shrink weights use mismatched scales**

When the coarse layer has substantially more total observations than the selected fine or mid layer, `_shrink` mixes per-weekday or per-day-group counts with `coarse` counts pooled across all weekdays, then normalizes the mixture. Because the inputs represent different amounts of exposure, the coarse vector can dominate even when `alpha` says the fine tier should be trusted, making weekday-specific activity patterns largely ineffective.

Normalize the fine/mid and coarse vectors to comparable distributions before applying the shrinkage weights.
</issue_to_address>

### Comment 22
<location path="tests/test_schedule.py" line_range="121-123" />
<code_context>
+    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():
</code_context>
<issue_to_address>
**Ignored penalty still passes**

When the planner ignores its penalty argument, `test_penalty_raises_requirement_but_stays_in_window` supplies penalties 1, 3, and 8 at line 120, but lines 121-122 only check that plans are not forced and start at hour 3. A planner that ignores penalty and returns the same plan for every input passes, so the test does not verify that the requirement rises.

Use a fixture where different penalties lead to distinguishable required or selected cold-run lengths, and assert that difference.
</issue_to_address>

Sourcery assessment

Approval pending. 22 findings to address first.

Blocking findings: amrita_plugin_memory/rethinking/schedule.py:372, amrita_plugin_memory/rethinking/schedule.py:507, amrita_plugin_memory/rethinking/runner.py:150, amrita_plugin_memory/config.py:49, amrita_plugin_memory/rethinking/runner.py:462, and 17 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread amrita_plugin_memory/rethinking/schedule.py Outdated
Comment thread amrita_plugin_memory/rethinking/schedule.py Outdated
Comment thread amrita_plugin_memory/rethinking/runner.py
Comment thread amrita_plugin_memory/config.py
Comment thread amrita_plugin_memory/rethinking/runner.py Outdated
Comment thread amrita_plugin_memory/rethinking/runner.py Outdated
Comment thread amrita_plugin_memory/rethinking/consts.py
Comment thread amrita_plugin_memory/rethinking/runner.py Outdated
Comment thread amrita_plugin_memory/rethinking/schedule.py Outdated
Comment thread tests/test_schedule.py Outdated
- 抽出 profile_store:画像路径 PROFILE_PATH 与纯函数 read_profile,运行器与会话侧共用
- permissions.can_read_profile:比对 subconscious.target_user_id,仅本人可读
- 新增表层工具 read_profile(custom_run 校验),写入仍只留在循环内部
- README 补充只读口径说明
- 新增 AGENTS.md:模块边界、静态检查命令、工具注册三条路径、权限口径、数据布局
- schedule.py:slot_of 的 tz 形参放宽为 tzinfo,IdlePlanner.tz 显式标注,消除 pyright 类型错误
@AmriaLin
AmriaLin force-pushed the feat/idle-window-dmn branch from 33d4f3f to 8486079 Compare October 20, 2026 06:39
- 删掉 tests/test_schedule.py,与不补单测的约定对齐
- pyproject.toml 去掉 pytest dev 依赖
- AGENTS.md 明确本仓库不建 tests/ 目录
调度正确性
- 两天拼接的冷槽不再按环形相连,避免把隔天两组冷槽误并成一段
- 惩罚指数先封顶再取整,溢出时退到上限而不是抛异常
- 冷段选择改为时间感知:优先尚未到来的窗口,全等权重时按最近时间取
- _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 不阻塞循环
Comment thread amrita_plugin_memory/rethinking/schedule.py Outdated
按仓库约定不写 # ---- 小节名 这类分隔线,改由空行与函数拆分表达结构。
模块 docstring 里便于独立单测措辞同步调整(本仓库不建 tests 目录)。
@JohnRichard4096
JohnRichard4096 merged commit d68490b into main Oct 21, 2026
3 checks passed
@JohnRichard4096
JohnRichard4096 deleted the feat/idle-window-dmn branch October 21, 2026 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants