feat(proactive): separate proactive refresh from auto dream (#488)

* refractor(proactive): upgrade proactive feature with disentangled job and steps

* refactor(proactive): apply audit fixes

- rename read-side job 'proactive' -> 'proactive_read' (less confusing vs the refresh pipeline)
- drop dedicated agent_wrapper.proactive; extraction reuses the default wrapper
- simplify schema: remove unused ProactiveExtractOutput/TopicUpdate, drop resource_paths
- extract no longer scans resource/ directly (daily notes already carry resource content)
- update tests and docs accordingly

* feat(proactive): strict extract-output gate and prompt total budget

- parse_extract_reply now requires a contract section (follow_ups/extends/updates
  as a list); non-empty replies with misspelled section names trigger the
  existing one-shot retry instead of silently checkpointing changed files
- pack_paths gains max_total_chars; extract packs newest daily material first,
  keeps the first file on overflow, and records omitted files in a trailer
  (default budget 300000 chars, configurable via max_total_chars)
- tests: schema gate unit, schema-error retry e2e, budget unit + e2e

* feat(proactive): add scenario-card plan step and generative agenda step

* feat(proactive): digest-personal profile personalization and leaner LLM contract

- extract/plan/agenda now draw a user profile block from <digest_dir>/personal/*.md
  (frontmatter description + body excerpt, per-file budget, profile.md fallback)
- all daily access honours the configured daily_dir (prompt paths parameterized,
  config-driven fallbacks) so workspaces using e.g. memory/ work unchanged
- schema trim: drop dead fields errors/material_paths, carry_forward_all -> count
- shrink LLM output contract: new topics emit title/reason/confidence/paths only;
  keywords removed end-to-end, evidence derived from paths[0] (updates keep it)

* fix(proactive): skip checkpoint when extract reply stays unusable after retry

Two consecutive unparseable replies now short-circuit the round without
checkpointing, so the same material is retried next round instead of being
silently consumed (closes the residual audit #1 gap: the structural gate
detected schema-wrong output but a double failure still checkpointed).

* fix(proactive): replace running bool with reference-counted job activity tracker for the idle gate

* refactor(proactive): remove job activity tracking and idle gate, restore job tree to upstream

* fix(proactive): address second audit round (readonly reader, mtime checkpoint, wider fallbacks, profile containment, horizon content, expiry boundary)

* refactor(dream): strip interests.yaml ownership from dream, proactive is now the sole writer

* refactor(dream): separate proactive topic generation

* ci: update renamed auto dream smoke test

* fix(proactive): complete refresh migration and docs

---------

Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
This commit is contained in:
imrewce 2026-09-07 17:23:37 +08:00 committed by GitHub
parent f04eedb3ab
commit 354837f9af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 5474 additions and 1280 deletions

View file

@ -45,7 +45,7 @@ jobs:
- name: Run Windows path tests
run: |
python -m pytest `
tests/unit/test_auto_dream.py::test_scan_day_files_includes_nested_md_and_excludes_interests `
tests/unit/test_auto_dream.py::test_scan_day_files_includes_only_markdown_day_files `
tests/unit/test_auto_dream.py::test_dream_extract_matches_posix_catalog_paths `
tests/unit/test_read_with_neighbors.py::test_read_with_neighbors_uses_posix_nested_path `
-v

View file

@ -99,7 +99,7 @@ cat > .env <<'EOF'
# EMBEDDING_API_KEY=sk-xxx
# EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# Required for auto_memory, auto_resource, and auto_dream.
# Required for auto_memory, auto_resource, auto_dream, and proactive refresh.
LLM_API_KEY=sk-xxx
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EOF
@ -270,8 +270,8 @@ everything under `metadata/` is rebuildable.
| [`auto_memory`](docs/en/auto_memory.md) | Agent hook or `reme auto_memory` | Distills useful conversation facts while preserving a filtered conversation source record. | `session/dialog/*.jsonl`, `daily/<date>/<generated-name>.md` |
| [`auto_resource`](docs/en/auto_resource.md) | Resource watcher or `reme auto_resource` | Turns files under `resource/` into source-linked, content-named daily cards. | `daily/<date>/<resource-card>.md` |
| [`auto_index`](docs/en/memory_search.md) | Background watcher or `reme reindex` | The watcher ingests Markdown from `daily/` and `digest/`; `reindex` only rebuilds BM25 and embeddings from already-ingested chunks. | Searchable chunks, BM25, wikilink graph, and optional vectors |
| [`auto_dream`](docs/en/auto_dream.md) | `dream_cron` or `reme auto_dream` | By default, extracts up to five reusable units from changed files in the latest two-day window, then creates, corroborates, refines, or corrects digest nodes. | `digest/**`, `daily/<date>/interests.yaml` |
| [`proactive`](docs/en/proactive.md) | `reme proactive` before an agent decides to act | Reads topics generated by `auto_dream`; the host agent decides whether and how to mention them. | Structured topics from `daily/<date>/interests.yaml` |
| [`auto_dream`](docs/en/auto_dream.md) | `dream_cron` or `reme auto_dream` | By default, extracts up to five reusable units from changed files in the latest two-day window, then creates, corroborates, refines, or corrects digest nodes. | `digest/**` |
| [`proactive_read`](docs/en/proactive.md) | `reme proactive_read` before an agent decides to act | Reads topics generated by the independent proactive refresh flow; the host agent decides whether and how to mention them. | Structured topics from `daily/<date>/interests.yaml` |
<table>
<tr>
@ -297,7 +297,7 @@ BM25 through reciprocal rank fusion (RRF).
> [!IMPORTANT]
>
> `proactive` only reads and exposes interest topics produced by Auto Dream. It does not independently browse the web,
> `proactive_read` only reads and exposes interest topics produced by proactive refresh. It does not independently browse the web,
> send notifications, or rewrite the knowledge base; the host agent decides whether and how to act on a topic.
## 📊 Benchmarks

View file

@ -98,7 +98,7 @@ cat > .env <<'EOF'
# EMBEDDING_API_KEY=sk-xxx
# EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# 必须auto_memory、auto_resource 和 auto_dream 需要 LLM。
# 必须auto_memory、auto_resource、auto_dream 和 proactive refresh 需要 LLM。
LLM_API_KEY=sk-xxx
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EOF
@ -266,8 +266,8 @@ ReMe 遵循 capture → index → consolidate → recall 的循环。workspace
| [`auto_memory`](docs/zh/auto_memory.md) | Agent hook 或 `reme auto_memory` | 提炼有长期价值的对话事实,同时保留过滤后的对话来源记录。 | `session/dialog/*.jsonl``daily/<date>/<generated-name>.md` |
| [`auto_resource`](docs/zh/auto_resource.md) | 资源监听或 `reme auto_resource` | 将 `resource/` 下的文件转为带来源链接、按内容命名的 daily 卡片。 | `daily/<date>/<resource-card>.md` |
| [`auto_index`](docs/zh/memory_search.md) | 后台监听或 `reme reindex` | watcher 摄取 `daily/``digest/` 中的 Markdown`reindex` 只基于已摄取的 chunks 重建 BM25 和 Embedding。 | 可检索的 chunks、BM25、wikilink 图谱和可选向量 |
| [`auto_dream`](docs/zh/auto_dream.md) | `dream_cron``reme auto_dream` | 默认从最近两天内变化的文件中最多提取 5 个可复用 unit再创建、印证、补充或修正 digest 节点。 | `digest/**``daily/<date>/interests.yaml` |
| [`proactive`](docs/zh/proactive.md) | Agent 决定主动行动前调用 `reme proactive` | 读取 `auto_dream` 生成的 topics是否以及如何提醒用户由宿主 Agent 决定。 | 来自 `daily/<date>/interests.yaml` 的结构化 topics |
| [`auto_dream`](docs/zh/auto_dream.md) | `dream_cron``reme auto_dream` | 默认从最近两天内变化的文件中最多提取 5 个可复用 unit再创建、印证、补充或修正 digest 节点。 | `digest/**` |
| [`proactive_read`](docs/zh/proactive.md) | Agent 决定主动行动前调用 `reme proactive_read` | 读取独立 proactive refresh 流程生成的 topics是否以及如何提醒用户由宿主 Agent 决定。 | 来自 `daily/<date>/interests.yaml` 的结构化 topics |
<table>
<tr>
@ -292,7 +292,7 @@ ReMe 遵循 capture → index → consolidate → recall 的循环。workspace
> [!IMPORTANT]
>
> `proactive` 只读取并暴露 Auto Dream 生成的兴趣主题,不会自行联网、发送通知或改写知识库;是否以及如何使用主题,由宿主 Agent
> `proactive_read` 只读取并暴露 proactive refresh 生成的兴趣主题,不会自行联网、发送通知或改写知识库;是否以及如何使用主题,由宿主 Agent
> 决定。
## 📊 评测结果

View file

@ -2,16 +2,12 @@
`auto_dream` is ReMe's long-term memory distillation flow from daily to digest. By default it scans the target date and
the previous day, processes only files changed since the previous dream, extracts a small set of high-value memory units
across that window, integrates them into `digest/`, and writes the target day's `interests.yaml` for proactive use.
<p align="center">
<img src="../figure/auto-dream-and-proactive.svg" alt="ReMe Auto Dream and Proactive flow from daily to digest to proactive" width="92%">
</p>
across that window, and integrates them into `digest/`.
Its daily inputs usually come from [Auto Memory](./auto_memory.md) and [Auto Resource](./auto_resource.md). For the file
semantics of `digest/`, Sources sections, and wikilinks, see [Memory as File](./memory_as_file.md). For the linking
strategy used during Integrate, see [Auto Link](./auto_link.md). To read `interests.yaml`,
use [Proactive](./proactive.md).
strategy used during Integrate, see [Auto Link](./auto_link.md). Proactive discovery is a separate flow; see
[Proactive](./proactive.md).
## Configuration
@ -33,22 +29,12 @@ auto_dream:
max_units:
type: integer
default: 5
topic_count:
type: integer
default: 3
topic_diversity_days:
type: integer
default: 7
steps:
- backend: dream_extract_step
file_catalog: dream
topic_session_id: interests
scan_days: 2
max_units: 5
- backend: dream_integrate_step
- backend: dream_topics_step
topic_count: 3
topic_diversity_days: 7
- backend: dream_finish_step
file_catalog: dream
```
@ -61,8 +47,6 @@ Parameters:
| `hint` | Additional guidance from the caller for the Extract and Integrate stages. |
| `scan_days` | Recent-date window ending at `date`; defaults to 2 and has a minimum of 1. |
| `max_units` | Maximum reusable units extracted in one run; defaults to 5. |
| `topic_count` | Maximum number of topics written to `interests.yaml`. Defaults to 3. |
| `topic_diversity_days` | Number of past days of `interests.yaml` files considered when avoiding duplicate topics. Defaults to 7. |
## Inputs and Outputs
@ -76,8 +60,7 @@ daily/2026-06-20.md
daily/2026-06-20/**/*.md
```
Every `daily/<date>/interests.yaml` in the scan window is excluded from extraction so previous proactive output cannot
feed back into the next run. Final topics are written only for the target date.
Only Markdown day indexes and notes are scanned. Proactive state and `interests.yaml` are not Auto Dream inputs.
The main outputs are:
@ -86,10 +69,9 @@ The main outputs are:
| `digest/procedure/*.md` | Methods, workflows, runbooks, and executable experience. |
| `digest/personal/*.md` | User-, team-, and project-related preferences, facts, and long-term context. |
| `digest/wiki/*.md` | General knowledge, concepts, observations, and decision precedents. |
| `daily/<date>/interests.yaml` | Topics worth proactive attention from the host agent that day. |
| `metadata/file_catalog/dream*` | Dream-specific catalog used to detect changes in daily inputs. |
## Four Stages
## Three Stages
### 1. Extract
@ -97,18 +79,15 @@ The main outputs are:
1. Refresh each `daily/<date>.md` in the scan window.
2. Scan those day indexes and `daily/<date>/**/*.md`, comparing mtimes with `file_catalog: dream`.
3. Send all changed files together to the LLM and globally extract two structured result types: `units` and `topics`.
3. Send all changed files together to the LLM and globally extract structured memory `units`.
`units` are long-term memory units ready to be distilled into digest. Each has `name`, `bucket`, `summary`, and `paths`.
A run returns at most `max_units`; extraction merges cross-file evidence for the same abstraction and drops passing
mentions, per-file summaries, and weak candidates without reusable value. `bucket` may only be `procedure`, `personal`,
or `wiki`; unknown values are routed to `wiki`.
`topics` are proactive-interest candidates for the day. They contain `title`, `reason`, `evidence`, `keywords`, and
`paths` and are filtered again in the Topics stage.
If there are no changed files, Extract succeeds with no units; Integrate then has no unit work, Topics preserves any
existing target-day topics, and Finish still performs its normal catalog summary. If files changed but no LLM is
If there are no changed files, Extract succeeds with no units; Integrate then has no unit work, and Finish still
performs its normal catalog summary. If files changed but no LLM is
configured, Extract fails because extraction requires an LLM.
### 2. Integrate
@ -140,47 +119,17 @@ There are four integration actions:
Successfully integrated units are recorded in `integrate_results`. Failed units enter `failed_units`, and their source
paths enter `failed_paths`. The Finish stage does not checkpoint failed paths, ensuring that they can be retried later.
### 3. Topics
`dream_topics_step` turns topic candidates from Extract into the final `daily/<date>/interests.yaml` for the day.
It reads:
```text
daily/<date>/interests.yaml
daily/<each of the previous topic_diversity_days dates>/interests.yaml
```
Existing topics from the same day are preserved, while similar topics from the previous `topic_diversity_days` days are
deduplicated. At most three topics are written by default. With an LLM configured, the LLM selects topics that are more
specific, actionable, and non-repetitive. Without an LLM, the step falls back to local normalization and deduplication.
Example output format. See [Proactive](./proactive.md) for the interface that reads this file:
```yaml
date: 2026-06-20
topic_count: 3
diversity_days: 7
topics:
- title: Quality regression in the memory retrieval pipeline
reason: The user has recently made repeated changes to search, node_search, and dream integration.
evidence: daily/2026-06-20/session.md
keywords:
- memory search
- auto dream
paths:
- daily/2026-06-20/session.md
```
### 4. Finish
### 3. Finish
`dream_finish_step` completes the run:
1. Write successfully processed changed paths to `file_catalog: dream`.
2. Also write the target `daily/<date>/interests.yaml` and every refreshed day-index page in the scan window to the
catalog.
2. Also write every refreshed day-index page in the scan window to the catalog.
3. Persist the dream catalog if there were upserts or deletions.
4. Return a summary containing counts for scanned, changed, integrated, topics, checkpoints, and related values.
4. Return a summary containing counts for scanned, changed, integrated, checkpoints, and related values.
Auto Dream neither reads nor writes proactive state or `interests.yaml`. Those files are owned by the proactive refresh
pipeline; see [Proactive](./proactive.md).
Failed paths are not checkpointed. The next `auto_dream` run therefore continues to treat them as changed inputs until
integration succeeds.
@ -216,7 +165,6 @@ jobs:
- backend: dream_extract_step
file_catalog: dream
- backend: dream_integrate_step
- backend: dream_topics_step
- backend: dream_finish_step
file_catalog: dream
```
@ -232,7 +180,6 @@ the workspace-relative wikilink semantics described in
[Memory as File](./memory_as_file.md).
`auto_dream` does not invent an overview from nothing. Only content that actually appears in daily input and is
extracted as a unit or topic can enter digest or `interests.yaml`.
extracted as a memory unit can enter digest.
The complete flow depends on an LLM for Extract and Integrate. Topics can perform local deduplication without an LLM,
but that does not mean the full dream flow can run offline.
The complete flow depends on an LLM for Extract and Integrate.

View file

@ -18,7 +18,6 @@ auto_dream:
steps:
- dream_extract_step
- dream_integrate_step # where auto_link actually happens
- dream_topics_step
- dream_finish_step
```

View file

@ -86,7 +86,8 @@ components:
Built-in registrations include `openai`, `anthropic`, `dashscope`, `deepseek`, `gemini`, `moonshot`, `ollama`, and `xai`. Their detailed model fields follow the corresponding AgentScope wrappers.
File operations, BM25 search, and wikilink traversal do not require an LLM. Evolution workflows such as `auto_memory`, `auto_resource`, and `auto_dream` do.
File operations, BM25 search, wikilink traversal, and `proactive_read` do not require an LLM. Evolution workflows such
as `auto_memory`, `auto_resource`, `auto_dream`, and proactive refresh do.
## Embeddings

View file

@ -7,7 +7,8 @@ description: Quick answers for ReMe installation, services, models, retrieval, f
## Do basic file operations require a model API key?
No. `write`, `read`, `list`, `stat`, BM25 search, and wikilink traversal work without model credentials. `auto_memory`, `auto_resource`, and `auto_dream` require an LLM.
No. `write`, `read`, `list`, `stat`, BM25 search, wikilink traversal, and `proactive_read` work without model
credentials. `auto_memory`, `auto_resource`, `auto_dream`, and proactive refresh require an LLM.
## Why is search still BM25-only after setting an embedding key?

View file

@ -421,7 +421,6 @@ jobs:
steps:
- backend: dream_extract_step
- backend: dream_integrate_step
- backend: dream_topics_step
- backend: dream_finish_step
```
@ -433,9 +432,9 @@ The current implementation uses `croniter` to calculate the next trigger time. T
```mermaid
flowchart LR
Jobs["default.yaml jobs"] --> BG["background<br/>index_update_loop<br/>resource_watch_loop<br/>digest_watch_loop"]
Jobs --> Cron["cron<br/>dream_cron<br/>optimize_index_cron"]
Jobs --> Cron["cron<br/>dream_cron<br/>proactive_refresh_cron<br/>optimize_index_cron"]
Jobs --> Stream["stream<br/>chat"]
Jobs --> Base["base<br/>version / help / health_check / status / app_config<br/>search / node_search / traverse / graph_snapshot / reindex<br/>read / load / read_image / write / save / edit / delete / move / list / stat / frontmatter_*<br/>daily_list / daily_reindex / daily_write<br/>auto_memory / auto_memory_cc / auto_resource / auto_dream / proactive"]
Jobs --> Base["base<br/>version / help / health_check / status / app_config<br/>search / node_search / traverse / graph_snapshot / reindex<br/>read / load / read_image / write / save / edit / delete / move / list / stat / frontmatter_*<br/>daily_list / daily_reindex / daily_write<br/>auto_memory / auto_memory_cc / auto_resource / auto_dream / proactive_refresh / proactive_read"]
```
## 7. Step Model
@ -803,7 +802,6 @@ jobs:
- backend: dream_extract_step
file_catalog: dream
- backend: dream_integrate_step
- backend: dream_topics_step
- backend: dream_finish_step
file_catalog: dream
```

View file

@ -31,7 +31,8 @@ An empty search result must remain empty; do not present model inference as reca
## MCP
The default HTTP service exposes streamable HTTP MCP at `http://127.0.0.1:2333/mcp`. Common tools include `search`, `read`, `traverse`, `list`, `auto_memory`, and `proactive`.
The default HTTP service exposes streamable HTTP MCP at `http://127.0.0.1:2333/mcp`. Common tools include `search`,
`read`, `traverse`, `list`, `auto_memory`, and `proactive_read`.
Use `service.jobs` to expose a read-only subset or keep write tools in a separate configuration.

View file

@ -96,7 +96,7 @@ The corresponding automatic flows are [Auto Memory](./auto_memory.md), [Auto Res
│ ├── YYYY-MM-DD.md # index page for the day
│ └── YYYY-MM-DD/
│ ├── <generated_name>.md # topic-named conversation or resource card
│ └── interests.yaml # proactive interest topics generated by auto_dream
│ └── interests.yaml # proactive interest topics generated by proactive refresh
└── digest/ # deeply processed layer; reusable personal facts, procedures, and knowledge nodes
├── personal/
│ └── <memory>.md # user profile, preferences, and durable personal facts

View file

@ -1,7 +1,7 @@
# Proactive
`proactive` is ReMe's interface for reading proactive memory. It does not reanalyze daily notes or call an LLM. It only
reads the current day's interest topics written by `auto_dream`:
`proactive_read` is ReMe's interface for reading proactive memory. It does not reanalyze daily notes or call an LLM. It
reads interest topics written by the independent proactive refresh flow:
```text
daily/<date>/interests.yaml
@ -10,56 +10,131 @@ daily/<date>/interests.yaml
A host agent can use it to learn "what is worth proactive attention today," then decide whether to remind the user, ask
a follow-up question, recommend a next step, or produce a proactive insight.
`interests.yaml` is generated by the Topics stage of [Auto Dream](./auto_dream.md). `proactive` only reads and exposes
the result.
`interests.yaml` is generated by the proactive refresh pipeline, scheduled by `proactive_refresh_cron` by default.
`proactive_read` only reads and exposes the result; Auto Dream is an independent daily-to-digest flow and does not read
or write proactive state.
## Configuration
The default configuration is in `reme/config/default.yaml`:
The default configuration is in `reme/config/default.yaml`. It defines the same refresh steps twice: a local-only
one-shot job for maintenance and debugging, and the scheduled job that runs every day at 18:00 in the application
timezone:
```yaml
proactive:
proactive_refresh:
backend: base
enable_serve: false
steps: &proactive_refresh_steps
- backend: proactive_extract_step
file_catalog: proactive
scan_days: 2
carry_forward_days: 14
max_carry_forward_topics: 20
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
max_topics: 10
- backend: proactive_plan_step
- backend: proactive_agenda_step
- backend: proactive_finish_step
file_catalog: proactive
proactive_refresh_cron:
backend: cron
cron: "0 18 * * *"
steps: *proactive_refresh_steps
```
The anchor above is only a compact illustration; `default.yaml` spells out both step lists explicitly. The read job is:
```yaml
proactive_read:
backend: base
description: "Proactive: read daily/<date>/interests.yaml and expose the latest user-interest topics."
parameters:
date:
type: string
default: ""
include_content:
type: boolean
default: true
type: object
properties:
date:
type: string
default: ""
include_content:
type: boolean
default: true
horizon_days:
type: integer
default: 1
min_confidence:
type: number
default: 0.4
steps:
- backend: proactive_step
min_confidence: 0.4
```
Parameters:
| Parameter | Purpose |
|-------------------|-------------------------------------------------------------------------------------------|
| `date` | Date to read in `YYYY-MM-DD` format. When empty, use today in the application's timezone. |
| `include_content` | Whether to return the raw YAML in the answer and metadata. Defaults to `true`. |
| Parameter | Purpose |
|-------------------|------------------------------------------------------------------------------------------------------|
| `date` | Date to read in `YYYY-MM-DD` format. When empty, use today in the application's timezone. |
| `include_content` | Whether to return the raw YAML in the answer and metadata. Defaults to `true`. |
| `horizon_days` | Read one day's exposure file, or use the truth source for a wider evidence horizon. Defaults to `1`. |
| `min_confidence` | Minimum topic confidence to return. Defaults to `0.4`; legacy topics use `0.5`. |
### Refresh cost, files, and opt-out
When no daily Markdown note changed, refresh exits before calling an LLM and does not create a new exposure file. With
changed material, extraction normally makes one LLM call and may retry once after an unusable reply. If push candidates
remain, planning makes one additional call; agenda generation makes one more when there are multiple candidates. A
refresh therefore makes at most four LLM calls with the default chain.
The refresh pipeline maintains the rebuildable `daily/_proactive.yaml` truth source, writes
`daily/<date>/interests.yaml`, and advances the independent `proactive` file catalog. Auto Dream does not read or write
any of those proactive artifacts.
To disable automatic refresh, use an explicit application config that omits the `proactive_refresh_cron` job. Keep the
local-only `proactive_refresh` job if you still want on-demand maintenance. Because it has `enable_serve: false`, it is
not exposed through HTTP or MCP.
## Input Contract
A typical file looks like this:
A current proactive-refresh file looks like this:
```yaml
version: 2
date: 2026-06-20
topic_count: 3
diversity_days: 7
generated_at: 2026-06-20T18:00:00+08:00
push: true
topics:
- title: Quality regression in the memory retrieval pipeline
- id: baa88ad49cb2
title: Quality regression in the memory retrieval pipeline
reason: The user has recently made repeated changes to search, node_search, and dream integration.
kind: follow_up
confidence: 0.86
first_seen: 2026-06-20
last_evidence_at: 2026-06-20
evidence: daily/2026-06-20/session.md
keywords:
- memory search
- auto dream
paths:
- daily/2026-06-20/session.md
agenda:
- topic_id: baa88ad49cb2
title: Quality regression in the memory retrieval pipeline
scenario_type: resume_task
opener: Review the latest retrieval regression before the next release.
next_action: Compare the failing query against the previous index snapshot.
preconditions: []
delivery: in_conversation
linked_memory: []
order_reason: Recent evidence and a concrete next action.
suppressed: []
```
Only the `topics` list is parsed into structured results. Every topic requires at least `title` and `reason`;
`evidence`, `keywords`, and `paths` are supporting fields.
Current v2 topics include stable identity, kind, confidence, evidence dates, and source paths. The reader also accepts
legacy v1 files containing `title`, `reason`, `evidence`, `keywords`, and `paths`; missing v2 confidence falls back to
`0.5`.
## Return Value
@ -76,6 +151,14 @@ metadata:
| `skipped` | `true` when the file does not exist. |
| `error` | Read or parse error. |
| `summary` | Short summary. |
| `agenda` | Today's proactive agenda (optional, v2 files only). |
When today's `interests.yaml` was produced by the proactive refresh chain with an agenda,
the answer also carries an `agenda` field: the ordered agenda items, each with `topic_id`,
`title`, `scenario_type`, `opener` (a natural conversation opener), `next_action` (the
minimal executable step), `preconditions`, `delivery`, `linked_memory` and `order_reason`.
Agenda items whose topic is resolved or below `min_confidence` are filtered out on read;
the field is absent when the file has no agenda.
When the file exists and parses successfully, the answer is structured data. For example:
@ -84,13 +167,30 @@ When the file exists and parses successfully, the answer is structured data. For
"summary": "Read 1 proactive topic(s) from daily/2026-06-20/interests.yaml",
"topics": [
{
"id": "baa88ad49cb2",
"title": "Quality regression in the memory retrieval pipeline",
"reason": "The user has recently made repeated changes to search, node_search, and dream integration.",
"kind": "follow_up",
"confidence": 0.86,
"first_seen": "2026-06-20",
"last_evidence_at": "2026-06-20",
"evidence": "daily/2026-06-20/session.md",
"keywords": ["memory search", "auto dream"],
"paths": ["daily/2026-06-20/session.md"]
}
],
"agenda": [
{
"topic_id": "baa88ad49cb2",
"title": "Quality regression in the memory retrieval pipeline",
"scenario_type": "resume_task",
"opener": "Review the latest retrieval regression before the next release.",
"next_action": "Compare the failing query against the previous index snapshot.",
"preconditions": [],
"delivery": "in_conversation",
"linked_memory": [],
"order_reason": "Recent evidence and a concrete next action."
}
],
"content": "date: 2026-06-20\n..."
}
```
@ -104,44 +204,49 @@ A missing file is not an error. The call succeeds with a skipped result:
Skipped: interests file not found at daily/2026-06-20/interests.yaml
```
This lets a host agent treat "there is no dream result for today yet" as a normal empty state.
This lets a host agent treat "there is no proactive refresh result for today yet" as a normal empty state.
## Running Proactive
CLI:
Run one refresh immediately through the normal application lifecycle:
```bash
reme proactive date=2026-06-20
reme start job=proactive_refresh date=2026-06-20
```
This command may call the configured LLM and may update `_proactive.yaml`, `interests.yaml`, and the proactive catalog.
It does not run Auto Dream.
Read the generated topics:
```bash
reme proactive_read date=2026-06-20
```
Omit the raw YAML content:
```bash
reme proactive date=2026-06-20 include_content=false
reme proactive_read date=2026-06-20 include_content=false
```
## Relationship to auto_dream
`proactive` is the downstream read step for `auto_dream`:
Proactive refresh and Auto Dream consume daily notes independently:
```text
daily notes
-> auto_dream
-> daily/<date>/interests.yaml
-> proactive
-> host agent
daily notes -> auto_dream -> digest
daily notes -> proactive_refresh_cron -> daily/<date>/interests.yaml -> proactive_read -> host agent
```
The responsibilities are divided as follows. For the complete Extract, Integrate, Topics, and Finish flow, see
[Auto Dream](./auto_dream.md):
The proactive responsibilities are divided as follows:
| Module | Responsibility |
|----------------------|--------------------------------------------------------|
| `dream_extract_step` | Extract topic candidates from changed daily inputs. |
| `dream_topics_step` | Deduplicate, select, and write `interests.yaml`. |
| `proactive_step` | Read `interests.yaml` and expose it to the host agent. |
| Module | Responsibility |
|--------------------------|--------------------------------------------------------|
| `proactive_refresh` | Run the refresh pipeline once from the local CLI. |
| `proactive_refresh_cron` | Run the same writer pipeline every day at 18:00. |
| `proactive_step` | Read `interests.yaml` and expose it to the host agent. |
`proactive` does not modify files, update a catalog, or decide whether the user should be interrupted. It only provides
`proactive_read` does not modify files, update a catalog, or decide whether the user should be interrupted. It only provides
the day's topic material. The caller's product policy determines whether, when, and in what tone to push it to the user.
## Failure Modes

View file

@ -34,7 +34,7 @@ The static build step requires Node.js 22.13 or newer and makes Studio available
Installing the `core` extra is recommended. The current code imports the AgentScope wrapper, and self-evolving memory
also depends on it.
To use agent workflows such as `auto_memory`, `auto_resource`, and `auto_dream`, configure an LLM:
To use agent workflows such as `auto_memory`, `auto_resource`, `auto_dream`, and proactive refresh, configure an LLM:
```bash
cat > .env <<'EOF'
@ -203,7 +203,7 @@ Distill daily notes into long-term digest memory:
```bash
reme auto_dream date=2026-06-20
reme proactive date=2026-06-20
reme proactive_read date=2026-06-20
```
These flows require a working LLM. Without an LLM configuration, start with basic capabilities such as `write`, `read`,

View file

@ -13,9 +13,11 @@ Conversations / external resources
|
+--> auto_dream
| distill daily/ into digest/{personal,procedure,wiki}/
| and write daily/<date>/interests.yaml
|
+--> search / node_search / read / traverse / proactive
+--> proactive_refresh_cron
| write daily/<date>/interests.yaml
|
+--> search / node_search / read / traverse / proactive_read
let agents retrieve, associate, read, and inspect interest topics
```
@ -58,7 +60,7 @@ daily/
├── glencore-output-update.md
├── drc-cobalt-policy.md
├── high-nickel-cathode-trend.md
└── interests.yaml # generated after auto_dream
└── interests.yaml # generated by proactive refresh
```
The corresponding flow is:
@ -79,17 +81,15 @@ Run:
reme auto_dream date=2026-05-18
```
`auto_dream` is a four-step pipeline:
`auto_dream` is a three-step pipeline:
```text
dream_extract_step
scan the daily window from 2026-05-17 through 2026-05-18 by default
output at most 5 units plus topics from changed files
output at most 5 memory units from changed files
dream_integrate_step
recall existing digest nodes with node_search for each unit
decide CREATE / CORROBORATE / REFINE / CORRECT
dream_topics_step
write daily/2026-05-18/interests.yaml
dream_finish_step
checkpoint successfully processed daily inputs
```
@ -220,7 +220,7 @@ the CATL interview record on 2026-05-19.
### Proactive: Read the day's interest topics
`auto_dream` writes:
The independent proactive refresh flow writes:
```text
daily/2026-05-18/interests.yaml
@ -229,24 +229,41 @@ daily/2026-05-18/interests.yaml
Example:
```yaml
version: 2
date: 2026-05-18
topic_count: 3
diversity_days: 7
generated_at: 2026-05-18T18:00:00+08:00
push: true
topics:
- title: Impact of DRC mining-rights policy on cobalt supply
- id: 9c2aa7bd21bf
title: Impact of DRC mining-rights policy on cobalt supply
reason: The user repeatedly mentioned KFM and cobalt-price risk today
keywords: [cobalt, DRC, CMOC, KFM]
kind: follow_up
confidence: 0.7
first_seen: 2026-05-18
last_evidence_at: 2026-05-18
evidence: daily/2026-05-18/cobalt-supply-risk.md
paths:
- daily/2026-05-18/cobalt-supply-risk.md
agenda:
- topic_id: 9c2aa7bd21bf
title: Impact of DRC mining-rights policy on cobalt supply
scenario_type: resume_task
opener: Review the KFM policy update before the next cobalt-supply decision.
next_action: Compare the latest policy note with the existing supply-risk assessment.
preconditions: []
delivery: in_conversation
linked_memory: [daily/2026-05-18/cobalt-supply-risk.md]
order_reason: Recent evidence and a concrete next step.
suppressed: []
```
Call:
```bash
reme proactive date=2026-05-18
reme proactive_read date=2026-05-18
```
The `proactive` Job returns the topics from `interests.yaml` and, optionally, the raw YAML content.
The `proactive_read` Job returns the topics from `interests.yaml` and, optionally, the raw YAML content.
### Value of this scenario

View file

@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="640" viewBox="0 0 1200 640" role="img"
aria-labelledby="title desc">
<title id="title">ReMe auto dream and proactive flow</title>
<desc id="desc">A left-to-right flow from a recent changed-daily window to digest integration, interest topic
writing, catalog checkpointing, and proactive reads.
<desc id="desc">Independent Auto Dream and proactive flows consume daily notes: Auto Dream writes digest memory,
while proactive refresh writes interest topics for proactive reads.
</desc>
<defs>
<style>.bg { fill: #fffdf8; } .title { font: 700 30px "Comic Sans MS", "Bradley Hand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #1f2430; } .subtitle { font: 14px "Comic Sans MS", "Bradley Hand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #556276; } .step-num { font: 700 12px "Comic Sans MS", "Bradley Hand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #ffffff; } .step-title { font: 700 18px "Comic Sans MS", "Bradley Hand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #1f2430; } .step-subtitle { font: 13px "Comic Sans MS", "Bradley Hand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #556276; } .chip-title { font: 700 13px "Comic Sans MS", "Bradley Hand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #1f2430; } .chip-text { font: 12px "Comic Sans MS", "Bradley Hand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #5e6a7c; } .note { font: 12px "Comic Sans MS", "Bradley Hand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #4f5c6f; } .panel { fill: #ffffff; stroke: #1f2430; stroke-width: 2.2; rx: 18; ry: 18; stroke-linecap: round; stroke-linejoin: round; } .chip { fill: #f8fbff; stroke: #1f2430; stroke-width: 1.6; rx: 11; ry: 11; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 6 5; } .badge { fill: #44546a; } .arrow { stroke: #7f8b9d; stroke-width: 1.45; fill: none; stroke-linecap: round; stroke-linejoin: round; marker-end: url(#arrow); } .soft-arrow { stroke: #a3adbd; stroke-width: 1.25; stroke-dasharray: 6 6; fill: none; stroke-linecap: round; stroke-linejoin: round; marker-end: url(#arrow-soft); } .line { stroke: #a3adbd; stroke-width: 1.25; stroke-linecap: round; }</style>
@ -17,7 +17,7 @@
<rect class="bg" x="0" y="0" width="1200" height="640"/>
<text class="title" x="600" y="54" text-anchor="middle">Auto Dream and Proactive</text>
<text class="subtitle" x="600" y="80" text-anchor="middle">Scan a recent daily window, integrate a compact set of reusable units, then expose proactive topics.</text>
<text class="subtitle" x="600" y="80" text-anchor="middle">Independent flows turn daily notes into digest memory and proactive topics.</text>
<rect class="panel" x="38" y="132" width="196" height="300"/>
<circle class="badge" cx="72" cy="170" r="15"/>
@ -32,7 +32,7 @@
<text class="chip-text" x="136" y="333" text-anchor="middle">changed daily</text>
<rect class="chip" x="66" y="360" width="140" height="44"/>
<text class="chip-title" x="136" y="379" text-anchor="middle">LLM extract</text>
<text class="chip-text" x="136" y="397" text-anchor="middle">≤ 5 units + topics</text>
<text class="chip-text" x="136" y="397" text-anchor="middle">≤ 5 memory units</text>
<rect class="panel" x="270" y="132" width="196" height="300"/>
<circle class="badge" cx="304" cy="170" r="15"/>
@ -52,37 +52,37 @@
<rect class="panel" x="502" y="132" width="196" height="300"/>
<circle class="badge" cx="536" cy="170" r="15"/>
<text class="step-num" x="536" y="174" text-anchor="middle">3</text>
<text class="step-title" x="564" y="176">Topics</text>
<text class="step-subtitle" x="530" y="206">dream_topics_step</text>
<text class="step-title" x="564" y="176">Finish</text>
<text class="step-subtitle" x="530" y="206">dream_finish_step</text>
<rect class="chip" x="530" y="232" width="140" height="44"/>
<text class="chip-title" x="600" y="251" text-anchor="middle">merge topics</text>
<text class="chip-text" x="600" y="269" text-anchor="middle">same day kept</text>
<text class="chip-title" x="600" y="251" text-anchor="middle">checkpoint</text>
<text class="chip-text" x="600" y="269" text-anchor="middle">skip failures</text>
<rect class="chip" x="530" y="296" width="140" height="44"/>
<text class="chip-title" x="600" y="315" text-anchor="middle">avoid repeats</text>
<text class="chip-text" x="600" y="333" text-anchor="middle">last 7 days</text>
<text class="chip-title" x="600" y="315" text-anchor="middle">persist catalog</text>
<text class="chip-text" x="600" y="333" text-anchor="middle">dream catalog</text>
<rect class="chip" x="530" y="360" width="140" height="44"/>
<text class="chip-title" x="600" y="379" text-anchor="middle">write YAML</text>
<text class="chip-text" x="600" y="397" text-anchor="middle">interests.yaml</text>
<text class="chip-title" x="600" y="379" text-anchor="middle">return summary</text>
<text class="chip-text" x="600" y="397" text-anchor="middle">counts + errors</text>
<rect class="panel" x="734" y="132" width="196" height="300"/>
<circle class="badge" cx="768" cy="170" r="15"/>
<text class="step-num" x="768" y="174" text-anchor="middle">4</text>
<text class="step-title" x="796" y="176">Finish</text>
<text class="step-subtitle" x="762" y="206">dream_finish_step</text>
<text class="step-num" x="768" y="174" text-anchor="middle">P</text>
<text class="step-title" x="796" y="176">Refresh</text>
<text class="step-subtitle" x="762" y="206">proactive_refresh_cron</text>
<rect class="chip" x="762" y="232" width="140" height="44"/>
<text class="chip-title" x="832" y="251" text-anchor="middle">checkpoint</text>
<text class="chip-text" x="832" y="269" text-anchor="middle">skip failures</text>
<text class="chip-title" x="832" y="251" text-anchor="middle">extract topics</text>
<text class="chip-text" x="832" y="269" text-anchor="middle">changed daily</text>
<rect class="chip" x="762" y="296" width="140" height="44"/>
<text class="chip-title" x="832" y="315" text-anchor="middle">persist catalog</text>
<text class="chip-text" x="832" y="333" text-anchor="middle">file_catalog</text>
<text class="chip-title" x="832" y="315" text-anchor="middle">plan agenda</text>
<text class="chip-text" x="832" y="333" text-anchor="middle">filter + order</text>
<rect class="chip" x="762" y="360" width="140" height="44"/>
<text class="chip-title" x="832" y="379" text-anchor="middle">return summary</text>
<text class="chip-text" x="832" y="397" text-anchor="middle">counts + errors</text>
<text class="chip-title" x="832" y="379" text-anchor="middle">write YAML</text>
<text class="chip-text" x="832" y="397" text-anchor="middle">interests.yaml</text>
<rect class="panel" x="966" y="132" width="196" height="300"/>
<circle class="badge" cx="1000" cy="170" r="15"/>
<text class="step-num" x="1000" y="174" text-anchor="middle">5</text>
<text class="step-title" x="1028" y="176">Proactive</text>
<text class="step-num" x="1000" y="174" text-anchor="middle">R</text>
<text class="step-title" x="1028" y="176">Read</text>
<text class="step-subtitle" x="994" y="206">proactive_step</text>
<rect class="chip" x="994" y="232" width="140" height="44"/>
<text class="chip-title" x="1064" y="251" text-anchor="middle">read YAML</text>
@ -96,7 +96,6 @@
<path class="arrow" d="M234 282 H270"/>
<path class="arrow" d="M466 282 H502"/>
<path class="arrow" d="M698 282 H734"/>
<path class="arrow" d="M930 282 H966"/>
<rect class="panel" x="80" y="502" width="1040" height="82"/>
@ -112,6 +111,5 @@
<text class="chip-title" x="972" y="532">Boundary</text>
<text class="chip-text" x="972" y="554" style="font-size:11px">Read-only; caller decides</text>
<path class="soft-arrow" d="M1064 432 C1064 476 600 472 600 432"/>
<text class="note" x="834" y="474" text-anchor="middle">proactive reads interests.yaml after auto_dream writes it</text>
<text class="note" x="834" y="474" text-anchor="middle">Auto Dream and proactive refresh are independent consumers of daily notes</text>
</svg>

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -1,16 +1,11 @@
# Auto Dream
`auto_dream` 是 ReMe 的 daily 到 digest 的长期记忆沉淀流程。它默认扫描目标日期及前一天的 daily 输入,只处理相对上次 dream
发生变化的文件,从整个扫描窗口中抽取少量高价值 memory units整合进 `digest/`,再生成目标日期可供主动提醒使用的
`interests.yaml`
<p align="center">
<img src="../figure/auto-dream-and-proactive.svg" alt="ReMe Auto Dream and Proactive 从 daily 到 digest 再到 proactive 的流程" width="92%">
</p>
发生变化的文件,从整个扫描窗口中抽取少量高价值 memory units并整合进 `digest/`
它消费的 daily 输入通常来自 [Auto Memory](./auto_memory.md) 和 [Auto Resource](./auto_resource.md)。`digest/`、Sources 章节
和 wikilink 的文件语义见 [Memory as File](./memory_as_file.md)Integrate 阶段的链接策略详见 [Auto Link](./auto_link.md)。
`interests.yaml` 的读取接口见 [Proactive](./proactive.md)。
主动发现是独立流程,见 [Proactive](./proactive.md)。
## 配置入口
@ -32,22 +27,12 @@ auto_dream:
max_units:
type: integer
default: 5
topic_count:
type: integer
default: 3
topic_diversity_days:
type: integer
default: 7
steps:
- backend: dream_extract_step
file_catalog: dream
topic_session_id: interests
scan_days: 2
max_units: 5
- backend: dream_integrate_step
- backend: dream_topics_step
topic_count: 3
topic_diversity_days: 7
- backend: dream_finish_step
file_catalog: dream
```
@ -60,8 +45,6 @@ auto_dream:
| `hint` | 调用方给抽取和整合阶段的额外指导。 |
| `scan_days` | 以 `date` 结尾的最近日期窗口;默认扫描 2 天,最小为 1。 |
| `max_units` | 一次最多抽取多少个可复用 unit默认 5。 |
| `topic_count` | 最终写入 `interests.yaml` 的 topic 上限,默认 3。 |
| `topic_diversity_days` | 选择 topic 时参考过去多少天的 `interests.yaml` 避免重复,默认 7。 |
## 输入和输出
@ -74,7 +57,7 @@ daily/2026-06-20.md
daily/2026-06-20/**/*.md
```
扫描窗口内的 `daily/<date>/interests.yaml` 都不作为抽取输入,避免上一轮主动主题反过来污染下一轮抽取。最终 topic 只写入目标日期
Auto Dream 只扫描 Markdown 日期索引和笔记,不读取 proactive 状态或 `interests.yaml`
主要输出有三类:
@ -83,10 +66,9 @@ daily/2026-06-20/**/*.md
| `digest/procedure/*.md` | 方法、流程、runbook、可执行经验。 |
| `digest/personal/*.md` | 用户、团队、项目相关的偏好、事实、长期上下文。 |
| `digest/wiki/*.md` | 通用知识、概念、观察、决策先例。 |
| `daily/<date>/interests.yaml` | 当天值得上层 Agent 主动关注的兴趣主题。 |
| `metadata/file_catalog/dream*` | dream 专用 catalog用于判断 daily 输入是否变化。 |
## 个阶段
## 个阶段
### 1. Extract
@ -94,16 +76,14 @@ daily/2026-06-20/**/*.md
1. 刷新扫描窗口内每天的索引页 `daily/<date>.md`
2. 扫描这些日期的索引页和 `daily/<date>/**/*.md`,与 `file_catalog: dream` 中记录的 mtime 对比。
3. 只把 changed files 一起交给 LLM全局抽取两类结构化结果:`units``topics`。
3. 只把 changed files 一起交给 LLM全局抽取结构化 memory `units`。
`units` 是准备沉淀进 digest 的长期记忆单元,包含 `name``bucket``summary``paths`。一次最多返回 `max_units`
个,抽取器会优先合并指向同一抽象的跨文件证据,并丢弃短暂提及、逐文件摘要和缺少复用价值的弱候选。`bucket` 只允许
`procedure``personal``wiki`;未知值会路由到 `wiki`
`topics` 是当天主动兴趣候选,包含 `title``reason``evidence``keywords``paths`,后续由 Topics 阶段再筛选。
如果没有 changed filesExtract 会成功返回空 unitsIntegrate 随后没有 unit 可处理Topics 保留目标日期已有的 topicsFinish
仍会正常汇总 catalog。如果有变化但没有配置 LLMExtract 会失败,因为抽取依赖 LLM。
如果没有 changed filesExtract 会成功返回空 unitsIntegrate 随后没有 unit 可处理Finish 仍会正常汇总 catalog。
如果有变化但没有配置 LLMExtract 会失败,因为抽取依赖 LLM。
### 2. Integrate
@ -131,45 +111,17 @@ digest 节点。新增与更新都必须保留来源,并把相关 digest 链
Integrate 成功的 unit 会记录到 `integrate_results`;失败的 unit 会进入 `failed_units`,其来源路径会进入 `failed_paths`
Finish 阶段不会 checkpoint 失败路径,保证下次还能重试。
### 3. Topics
`dream_topics_step` 将 Extract 阶段产生的 topic candidates 变成当天最终的 `daily/<date>/interests.yaml`
它会读取:
```text
daily/<date>/interests.yaml
daily/<过去 topic_diversity_days 天中的每一天>/interests.yaml
```
同一天已有 topics 会被保留,最近 `topic_diversity_days` 天出现过的相似主题会被去重。默认最多写 3 个 topic。配置了 LLM 时会让
LLM 选择更具体、可行动、非重复的主题;没有 LLM 时会退化成本地规范化去重。
写入格式示例。读取这个文件的接口见 [Proactive](./proactive.md)
```yaml
date: 2026-06-20
topic_count: 3
diversity_days: 7
topics:
- title: 记忆检索链路的质量回归
reason: 用户近期持续修改 search、node_search 和 dream 集成链路。
evidence: daily/2026-06-20/session.md
keywords:
- memory search
- auto dream
paths:
- daily/2026-06-20/session.md
```
### 4. Finish
### 3. Finish
`dream_finish_step` 负责收尾:
1. 将成功处理的 changed paths 写入 `file_catalog: dream`
2. 将目标日期的 `daily/<date>/interests.yaml`扫描窗口内每个已刷新的 day-index 页也写入 catalog。
2. 将扫描窗口内每个已刷新的 day-index 页也写入 catalog。
3. 如果有 upsert 或 delete持久化 dream catalog。
4. 返回包含 scanned、changed、integrated、topics、checkpoint 等计数的摘要。
4. 返回包含 scanned、changed、integrated、checkpoint 等计数的摘要。
Auto Dream 不读取或写入 proactive 状态和 `interests.yaml`。这些文件由 proactive refresh writer 链路负责,
见 [Proactive](./proactive.md)。
失败路径不会被 checkpoint。这样下一次 `auto_dream` 仍会把它们视作 changed input直到整合成功。
@ -204,7 +156,6 @@ jobs:
- backend: dream_extract_step
file_catalog: dream
- backend: dream_integrate_step
- backend: dream_topics_step
- backend: dream_finish_step
file_catalog: dream
```
@ -217,7 +168,6 @@ jobs:
`该决策记录在 [[daily/<date>/decision.md]] 中。`链接写法遵循
[Memory as File](./memory_as_file.md) 中的 workspace-relative wikilink 语义。
`auto_dream` 不凭空生成总览。只有 daily 输入中确实出现、并被抽取为 unit 或 topic 的内容,才会进入 digest 或
`interests.yaml`
`auto_dream` 不凭空生成总览。只有 daily 输入中确实出现、并被抽取为 memory unit 的内容,才会进入 digest。
完整流程依赖 LLM 完成 Extract 和 Integrate。Topics 可以在没有 LLM 时做本地去重,但这不等于完整 dream 能离线运行。
完整流程依赖 LLM 完成 Extract 和 Integrate。

View file

@ -15,7 +15,6 @@ auto_dream:
steps:
- dream_extract_step
- dream_integrate_step # auto_link 的实际发生位置
- dream_topics_step
- dream_finish_step
```

View file

@ -93,7 +93,8 @@ components:
可注册的内置 backend 包括 `openai``anthropic``dashscope``deepseek``gemini``moonshot``ollama``xai`。实际字段由对应 AgentScope model wrapper 决定。
基础文件操作、BM25 检索、wikilink 遍历不需要 LLM。`auto_memory``auto_resource``auto_dream` 等演化流程需要可用 LLM。
基础文件操作、BM25 检索、wikilink 遍历和 `proactive_read` 不需要 LLM。`auto_memory``auto_resource`
`auto_dream` 和 proactive refresh 等演化流程需要可用 LLM。
## Embedding 配置

View file

@ -7,7 +7,8 @@ description: ReMe 安装、服务、模型、检索、文件和插件问题的
## 基础文件操作需要模型 API Key 吗?
不需要。`write``read``list``stat`、BM25 搜索和 wikilink 遍历可以在没有模型凭据时运行。`auto_memory``auto_resource``auto_dream` 需要 LLM。
不需要。`write``read``list``stat`、BM25 搜索、wikilink 遍历和 `proactive_read` 可以在没有模型凭据时
运行。`auto_memory``auto_resource``auto_dream` 和 proactive refresh 需要 LLM。
## 为什么配置了 Embedding Key 仍然只有 BM25

View file

@ -405,7 +405,6 @@ jobs:
steps:
- backend: dream_extract_step
- backend: dream_integrate_step
- backend: dream_topics_step
- backend: dream_finish_step
```
@ -416,9 +415,9 @@ jobs:
```mermaid
flowchart LR
Jobs["default.yaml jobs"] --> BG["background<br/>index_update_loop<br/>resource_watch_loop<br/>digest_watch_loop"]
Jobs --> Cron["cron<br/>dream_cron<br/>optimize_index_cron"]
Jobs --> Cron["cron<br/>dream_cron<br/>proactive_refresh_cron<br/>optimize_index_cron"]
Jobs --> Stream["stream<br/>chat"]
Jobs --> Base["base<br/>version / help / health_check / status / app_config<br/>search / node_search / traverse / graph_snapshot / reindex<br/>read / load / read_image / write / save / edit / delete / move / list / stat / frontmatter_*<br/>daily_list / daily_reindex / daily_write<br/>auto_memory / auto_memory_cc / auto_resource / auto_dream / proactive"]
Jobs --> Base["base<br/>version / help / health_check / status / app_config<br/>search / node_search / traverse / graph_snapshot / reindex<br/>read / load / read_image / write / save / edit / delete / move / list / stat / frontmatter_*<br/>daily_list / daily_reindex / daily_write<br/>auto_memory / auto_memory_cc / auto_resource / auto_dream / proactive_refresh / proactive_read"]
```
## 7. Step 模型
@ -784,7 +783,6 @@ jobs:
- backend: dream_extract_step
file_catalog: dream
- backend: dream_integrate_step
- backend: dream_topics_step
- backend: dream_finish_step
file_catalog: dream
```

View file

@ -40,7 +40,7 @@ ReMe 把记忆能力放在独立服务和用户拥有的 workspace 中。Agent
- `traverse`
- `list`
- `auto_memory`
- `proactive`
- `proactive_read`
根据宿主风险模型,可以用 `service.jobs` 只暴露只读工具,或将写入工具放在单独配置中。

View file

@ -84,7 +84,7 @@ ReMe 用目录表达记忆组织和记忆分层。原始材料先进入 `resourc
│ ├── YYYY-MM-DD.md # 当天索引页
│ └── YYYY-MM-DD/
│ ├── <generated_name>.md # 按主题命名的对话或资源卡片
│ └── interests.yaml # auto_dream 产出的主动兴趣主题
│ └── interests.yaml # proactive refresh 产出的主动兴趣主题
└── digest/ # 深加工层;可长期复用的个人事实、流程经验、知识节点
├── personal/
│ └── <memory>.md # 用户画像、偏好、长期个人事实

View file

@ -1,6 +1,7 @@
# Proactive
`proactive` 是 ReMe 的主动记忆读取接口。它不重新分析 daily也不调用 LLM只读取 `auto_dream` 写出的当天兴趣主题:
`proactive_read` 是 ReMe 的主动记忆读取接口。它不重新分析 daily也不调用 LLM只读取独立 proactive refresh
流程写出的兴趣主题:
```text
daily/<date>/interests.yaml
@ -8,54 +9,125 @@ daily/<date>/interests.yaml
上层 Agent 可以用它获取“今天值得主动关注什么”,再决定是否提醒、追问、推荐下一步或生成主动洞察。
`interests.yaml` 由 [Auto Dream](./auto_dream.md) 的 Topics 阶段生成;`proactive` 只负责读取和暴露结果。
`interests.yaml` 由 proactive refresh writer 链路生成,默认通过 `proactive_refresh_cron` 定时执行;
`proactive_read` 只负责读取和暴露结果。Auto Dream 是独立的 daily-to-digest 流程,不读取或写入 proactive 状态。
## 配置入口
默认配置在 `reme/config/default.yaml`
默认配置在 `reme/config/default.yaml`。同一组 refresh steps 有两个入口:用于维护和调试的本地 one-shot job
以及按应用时区每天 18:00 执行的定时任务:
```yaml
proactive:
proactive_refresh:
backend: base
enable_serve: false
steps: &proactive_refresh_steps
- backend: proactive_extract_step
file_catalog: proactive
scan_days: 2
carry_forward_days: 14
max_carry_forward_topics: 20
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
max_topics: 10
- backend: proactive_plan_step
- backend: proactive_agenda_step
- backend: proactive_finish_step
file_catalog: proactive
proactive_refresh_cron:
backend: cron
cron: "0 18 * * *"
steps: *proactive_refresh_steps
```
上面的 anchor 只是为了紧凑展示;`default.yaml` 中显式写出了两组 steps。读取 job 为:
```yaml
proactive_read:
backend: base
description: "Proactive: read daily/<date>/interests.yaml and expose the latest user-interest topics."
parameters:
date:
type: string
default: ""
include_content:
type: boolean
default: true
type: object
properties:
date:
type: string
default: ""
include_content:
type: boolean
default: true
horizon_days:
type: integer
default: 1
min_confidence:
type: number
default: 0.4
steps:
- backend: proactive_step
min_confidence: 0.4
```
参数含义:
| 参数 | 作用 |
|-------------------|-----------------------------------------------------------------|
| `date` | 要读取的日期,格式为 `YYYY-MM-DD`。为空时使用应用时区中的今天。 |
| `include_content` | 是否在 answer 和 metadata 中返回 YAML 原文,默认 `true`。 |
| 参数 | 作用 |
|-------------------|-----------------------------------------------------------------------------|
| `date` | 要读取的日期,格式为 `YYYY-MM-DD`。为空时使用应用时区中的今天。 |
| `include_content` | 是否在 answer 和 metadata 中返回 YAML 原文,默认 `true`。 |
| `horizon_days` | 读取单日曝光文件,或在更宽时间窗下读取 truth source默认 `1`。 |
| `min_confidence` | 返回 topic 的最低置信度,默认 `0.4`;旧版 topic 使用 `0.5`。 |
### Refresh 成本、文件与关闭方式
没有 daily Markdown 发生变化时refresh 会在调用 LLM 前结束也不会生成新的曝光文件。有变化素材时extract
通常调用一次 LLM回复不可用时最多重试一次。存在 push candidates 时plan 会再调用一次;候选数大于一个时,
agenda 会再调用一次。因此默认链路单轮最多调用四次 LLM。
Refresh 会维护可重建的 truth source `daily/_proactive.yaml`,写入 `daily/<date>/interests.yaml`,并推进独立的
`proactive` file catalog。Auto Dream 不读取或写入这些 proactive 产物。
如需关闭自动 refresh请使用不包含 `proactive_refresh_cron` job 的显式应用配置。若仍需按需维护,可保留
本地 one-shot 的 `proactive_refresh`;它设置了 `enable_serve: false`,不会暴露到 HTTP 或 MCP。
## 输入契约
典型格式如下:
当前 proactive refresh 生成的文件如下:
```yaml
version: 2
date: 2026-06-20
topic_count: 3
diversity_days: 7
generated_at: 2026-06-20T18:00:00+08:00
push: true
topics:
- title: 记忆检索链路的质量回归
- id: b2120f3573cb
title: 记忆检索链路的质量回归
reason: 用户近期持续修改 search、node_search 和 dream 集成链路。
kind: follow_up
confidence: 0.86
first_seen: 2026-06-20
last_evidence_at: 2026-06-20
evidence: daily/2026-06-20/session.md
keywords:
- memory search
- auto dream
paths:
- daily/2026-06-20/session.md
agenda:
- topic_id: b2120f3573cb
title: 记忆检索链路的质量回归
scenario_type: resume_task
opener: 下次发布前先回顾最近的检索回归。
next_action: 对比失败查询与上一个索引快照。
preconditions: []
delivery: in_conversation
linked_memory: []
order_reason: 证据较新且下一步明确。
suppressed: []
```
只有 `topics` 列表会被解析成结构化结果。每个 topic 至少需要 `title``reason``evidence``keywords``paths` 是辅助字段。
当前 v2 topic 包含稳定 ID、类型、置信度、证据日期和来源路径。读取器仍兼容包含 `title``reason``evidence`
`keywords``paths` 的 v1 文件;缺少 v2 置信度时按 `0.5` 处理。
## 返回结果
@ -71,6 +143,13 @@ topics:
| `skipped` | 文件不存在时为 `true`。 |
| `error` | 读取或解析异常。 |
| `summary` | 简短摘要。 |
| `agenda` | 当日主动议程(可选,仅 v2 文件携带时返回)。 |
当当天的 `interests.yaml` 由 proactive refresh 链路生成并携带议程时answer 会附带
`agenda` 字段:按顺序排列的当日议程条目,每条包含 `topic_id``title``scenario_type`
`opener`(自然口吻的开场白)、`next_action`(最小可执行动作)、`preconditions`
`delivery``linked_memory``order_reason`。读侧会过滤已解决或低于 `min_confidence`
的主题对应的议程条目;文件不含议程时该字段不出现。
文件存在且解析成功时answer 是结构化数据,例如:
@ -79,13 +158,30 @@ topics:
"summary": "Read 1 proactive topic(s) from daily/2026-06-20/interests.yaml",
"topics": [
{
"id": "b2120f3573cb",
"title": "记忆检索链路的质量回归",
"reason": "用户最近反复修改了 search、node_search 和 dream integration。",
"kind": "follow_up",
"confidence": 0.86,
"first_seen": "2026-06-20",
"last_evidence_at": "2026-06-20",
"evidence": "daily/2026-06-20/session.md",
"keywords": ["memory search", "auto dream"],
"paths": ["daily/2026-06-20/session.md"]
}
],
"agenda": [
{
"topic_id": "b2120f3573cb",
"title": "记忆检索链路的质量回归",
"scenario_type": "resume_task",
"opener": "下次发布前先回顾最近的检索回归。",
"next_action": "对比失败查询与上一个索引快照。",
"preconditions": [],
"delivery": "in_conversation",
"linked_memory": [],
"order_reason": "证据较新且下一步明确。"
}
],
"content": "date: 2026-06-20\n..."
}
```
@ -99,43 +195,49 @@ topics:
Skipped: interests file not found at daily/2026-06-20/interests.yaml
```
这让上层 Agent 可以把“今天还没有 dream 结果”当作正常空状态处理。
这让上层 Agent 可以把“今天还没有 proactive refresh 结果”当作正常空状态处理。
## 运行方式
CLI
通过正常应用生命周期立即执行一次 refresh
```bash
reme proactive date=2026-06-20
reme start job=proactive_refresh date=2026-06-20
```
该命令可能调用配置的 LLM并更新 `_proactive.yaml``interests.yaml` 与 proactive catalog它不会运行 Auto
Dream。
读取生成的 topics
```bash
reme proactive_read date=2026-06-20
```
不返回 YAML 原文:
```bash
reme proactive date=2026-06-20 include_content=false
reme proactive_read date=2026-06-20 include_content=false
```
## 与 auto_dream 的关系
`proactive``auto_dream` 的下游读取步骤
Proactive refresh 和 Auto Dream 各自独立消费 daily notes
```text
daily notes
-> auto_dream
-> daily/<date>/interests.yaml
-> proactive
-> upper-level agent
daily notes -> auto_dream -> digest
daily notes -> proactive_refresh_cron -> daily/<date>/interests.yaml -> proactive_read -> upper-level agent
```
职责边界如下。更完整的 Extract、Integrate、Topics、Finish 说明见 [Auto Dream](./auto_dream.md)
Proactive 职责边界如下:
| 模块 | 职责 |
|----------------------|----------------------------------------------|
| `dream_extract_step` | 从 changed daily 输入抽取 topic candidates。 |
| `dream_topics_step` | 去重、筛选并写入 `interests.yaml`。 |
| `proactive_step` | 读取 `interests.yaml`,暴露给上层 Agent。 |
| 模块 | 职责 |
|--------------------------|----------------------------------------------|
| `proactive_refresh` | 从本地 CLI 单次运行 refresh writer 链路。 |
| `proactive_refresh_cron` | 每天 18:00 运行同一套 writer 链路。 |
| `proactive_step` | 读取 `interests.yaml`,暴露给上层 Agent。 |
`proactive` 不修改任何文件,不更新 catalog也不负责判断是否应该主动打扰用户。它只提供当天主题材料是否推送、何时推送、用什么语气推送应由调用方根据产品策略决定。
`proactive_read` 不修改任何文件,不更新 catalog也不负责判断是否应该主动打扰用户。它只提供当天主题材料是否推送、何时推送、用什么语气推送应由调用方根据产品策略决定。
## 失败模式

View file

@ -33,7 +33,7 @@ cd ..
`core` extra 建议安装:当前代码会导入 AgentScope wrapper自进化记忆也依赖它。
如果要使用 `auto_memory``auto_resource``auto_dream` 这类 Agent 流程,再配置 LLM
如果要使用 `auto_memory``auto_resource``auto_dream` 和 proactive refresh 这类 Agent 流程,再配置 LLM
```bash
cat > .env <<'EOF'
@ -197,7 +197,7 @@ reme auto_resource changes='[{"path":"resource/2026-06-20/report.md","change":"a
```bash
reme auto_dream date=2026-06-20
reme proactive date=2026-06-20
reme proactive_read date=2026-06-20
```
这些流程需要可用 LLM未配置 LLM 时请先使用 `write/read/search` 这类基础能力。

View file

@ -12,9 +12,11 @@ ReMe 的共同模式是:
|
+--> auto_dream
| 从 daily/ 提炼 digest/{personal,procedure,wiki}/
| 同时写 daily/<date>/interests.yaml
|
+--> search / node_search / read / traverse / proactive
+--> proactive_refresh_cron
| 写入 daily/<date>/interests.yaml
|
+--> search / node_search / read / traverse / proactive_read
供 Agent 检索、联想、读取兴趣主题
```
@ -54,7 +56,7 @@ daily/
├── glencore-output-update.md
├── drc-cobalt-policy.md
├── high-nickel-cathode-trend.md
└── interests.yaml # auto_dream 后生成
└── interests.yaml # proactive refresh 生成
```
对应链路:
@ -73,20 +75,17 @@ daily/
reme auto_dream date=2026-05-18
```
`auto_dream`步管线:
`auto_dream`步管线:
```text
dream_extract_step
默认扫描 2026-05-17 至 2026-05-18 的 daily 窗口
从 changed 文件输出最多 5 个 units 和 topics
从 changed 文件输出最多 5 个 memory units
dream_integrate_step
每个 unit 用 node_search 召回已有 digest 节点
决定 CREATE / CORROBORATE / REFINE / CORRECT
dream_topics_step
写 daily/2026-05-18/interests.yaml
dream_finish_step
checkpoint 成功处理的 daily 输入
```
@ -212,7 +211,7 @@ reme traverse path=digest/wiki/钴.md depth=2 direction=both
### Proactive读取当天兴趣主题
`auto_dream` 会写:
独立的 proactive refresh 流程会写:
```text
daily/2026-05-18/interests.yaml
@ -221,24 +220,41 @@ daily/2026-05-18/interests.yaml
示例:
```yaml
version: 2
date: 2026-05-18
topic_count: 3
diversity_days: 7
generated_at: 2026-05-18T18:00:00+08:00
push: true
topics:
- title: 刚果(金)矿权政策对钴供给的影响
- id: f7c355661d51
title: 刚果(金)矿权政策对钴供给的影响
reason: 用户当天多次提到 KFM 矿和钴价风险
keywords: [钴, 刚果金, 洛阳钼业, KFM]
kind: follow_up
confidence: 0.7
first_seen: 2026-05-18
last_evidence_at: 2026-05-18
evidence: daily/2026-05-18/cobalt-supply-risk.md
paths:
- daily/2026-05-18/cobalt-supply-risk.md
agenda:
- topic_id: f7c355661d51
title: 刚果(金)矿权政策对钴供给的影响
scenario_type: resume_task
opener: 下次判断钴供给前,先看看 KFM 的最新政策变化。
next_action: 对比最新政策笔记与已有供给风险判断。
preconditions: []
delivery: in_conversation
linked_memory: [daily/2026-05-18/cobalt-supply-risk.md]
order_reason: 证据较新且下一步明确。
suppressed: []
```
调用:
```bash
reme proactive date=2026-05-18
reme proactive_read date=2026-05-18
```
`proactive` Job 返回 `interests.yaml` 中的 topics 和可选 YAML 原文。
`proactive_read` Job 返回 `interests.yaml` 中的 topics 和可选 YAML 原文。
### 场景价值

View file

@ -42,7 +42,7 @@ jobs:
# ── Auto dream (same as default.yaml auto_dream, base mode) ──
# auto_dream:
# backend: base
# description: "Auto-dream: scan today's day-index and daily notes, globally extract merged units/topics, integrate digest units, write interests.yaml, and persist the dream catalog."
# description: "Auto-dream: scan today's day-index and daily notes, globally extract merged units, integrate digest units, and persist the dream catalog."
# parameters:
# type: object
# properties:
@ -62,24 +62,12 @@ jobs:
# type: integer
# description: "maximum number of extracted memory units"
# default: 5
# topic_count:
# type: integer
# description: "maximum number of final daily interest topics"
# default: 3
# topic_diversity_days:
# type: integer
# description: "number of previous interests.yaml days to avoid repeating"
# default: 7
# steps:
# - backend: dream_extract_step
# file_catalog: dream
# topic_session_id: interests
# scan_days: 2
# max_units: 5
# - backend: dream_integrate_step
# - backend: dream_topics_step
# topic_count: 3
# topic_diversity_days: 7
# - backend: dream_finish_step
# file_catalog: dream

View file

@ -67,13 +67,9 @@ jobs:
steps:
- backend: dream_extract_step
file_catalog: dream
topic_session_id: interests
scan_days: 2
max_units: 5
- backend: dream_integrate_step
- backend: dream_topics_step
topic_count: 3
topic_diversity_days: 7
- backend: dream_finish_step
file_catalog: dream
@ -83,9 +79,61 @@ jobs:
steps:
- backend: optimize_index_step
proactive_refresh:
backend: base
enable_serve: false
description: "Refresh proactive topics once for local maintenance or debugging; not exposed by HTTP/MCP."
parameters:
type: object
properties:
date:
type: string
description: "YYYY-MM-DD to refresh; defaults to today in the application's timezone"
default: ""
steps:
- backend: proactive_extract_step
file_catalog: proactive
scan_days: 2
carry_forward_days: 14
max_carry_forward_topics: 20
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
max_topics: 10
- backend: proactive_plan_step
- backend: proactive_agenda_step
- backend: proactive_finish_step
file_catalog: proactive
proactive_refresh_cron:
backend: cron
cron: "0 18 * * *" # once a day at 18:00
steps:
- backend: proactive_extract_step
file_catalog: proactive
scan_days: 2
carry_forward_days: 14
max_carry_forward_topics: 20
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
max_topics: 10
- backend: proactive_plan_step
- backend: proactive_agenda_step
- backend: proactive_finish_step
file_catalog: proactive
auto_dream:
backend: base
description: "Auto-dream: scan today's day-index and daily notes, globally extract merged units/topics, integrate digest units, write interests.yaml, and persist the dream catalog."
description: "Auto-dream: scan today's day-index and daily notes, globally extract merged units, integrate digest units, and persist the dream catalog."
parameters:
type: object
properties:
@ -105,24 +153,12 @@ jobs:
type: integer
description: "maximum number of extracted memory units"
default: 5
topic_count:
type: integer
description: "maximum number of final daily interest topics"
default: 3
topic_diversity_days:
type: integer
description: "number of previous interests.yaml days to avoid repeating"
default: 7
steps:
- backend: dream_extract_step
file_catalog: dream
topic_session_id: interests
scan_days: 2
max_units: 5
- backend: dream_integrate_step
- backend: dream_topics_step
topic_count: 3
topic_diversity_days: 7
- backend: dream_finish_step
file_catalog: dream
@ -198,7 +234,7 @@ jobs:
- auto_image_resource_step
- auto_text_resource_step
proactive:
proactive_read:
backend: base
description: "Proactive: read daily/<date>/interests.yaml and expose the latest user-interest topics."
parameters:
@ -212,8 +248,17 @@ jobs:
type: boolean
description: "whether to include the raw YAML content in the response answer and metadata"
default: true
horizon_days:
type: integer
description: "merge interests.yaml across this many recent days (1 = single-day legacy behaviour)"
default: 1
min_confidence:
type: number
description: "minimum topic confidence to return (default 0.4 sits safely below the 0.5 fallback; v1 topics fall back to 0.5)"
default: 0.4
steps:
- backend: proactive_step
min_confidence: 0.4
version:
backend: base
@ -746,6 +791,7 @@ components:
# base_url: ${EMBEDDING_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}
# parameters: { }
#
# embedding_store:
# default:
# backend: local
@ -824,6 +870,8 @@ components:
backend: local
dream:
backend: local
proactive:
backend: local
file_chunker:
markdown:

View file

@ -4,11 +4,14 @@ from .application_config import ApplicationConfig, ComponentConfig, JobConfig
from .dream import (
DreamExtractOutput,
DreamState,
DreamTopic,
DreamUnit,
IntegrateOutcome,
)
from .proactive import (
ProactiveResult,
TopicSelectionOutput,
ProactiveState,
ProactiveStateFile,
ProactiveTopic,
)
from .emb_node import EmbNode
from .file_chunk import FileChunk
@ -27,7 +30,6 @@ __all__ = [
"ComponentConfig",
"DreamExtractOutput",
"DreamState",
"DreamTopic",
"DreamUnit",
"EmbNode",
"FileChunk",
@ -40,11 +42,13 @@ __all__ = [
"IntegrateOutcome",
"JobConfig",
"ProactiveResult",
"ProactiveState",
"ProactiveStateFile",
"ProactiveTopic",
"Request",
"Response",
"StreamChunk",
"TokenUsage",
"TopicSelectionOutput",
"TraverseGraph",
"TraverseGraphEdge",
"TraverseGraphNode",

View file

@ -6,6 +6,9 @@ from pydantic import BaseModel, Field
from ..enumeration import DreamBucketEnum
# ProactiveResult moved to reme.schema.proactive; re-exported for import compatibility.
from .proactive import ProactiveResult # noqa: F401 # pylint: disable=unused-import
class DreamUnit(BaseModel):
"""One cross-file memory unit emitted by global extract."""
@ -16,21 +19,10 @@ class DreamUnit(BaseModel):
paths: list[str] = Field(default_factory=list, description="Workspace-relative source paths.")
class DreamTopic(BaseModel):
"""One topic candidate emitted by global extract."""
title: str = Field(description="Specific user-interest topic title.")
reason: str = Field(description="Why this topic may interest the user.")
evidence: str = Field(description="Grounded evidence pointer.")
keywords: list[str] = Field(default_factory=list, description="Keywords for de-duplication.")
paths: list[str] = Field(default_factory=list, description="Workspace-relative source paths.")
class DreamExtractOutput(BaseModel):
"""Structured output for ``dream_extract_step``."""
units: list[DreamUnit] = Field(default_factory=list)
topics: list[DreamTopic] = Field(default_factory=list)
class IntegrateOutcome(BaseModel):
@ -41,24 +33,6 @@ class IntegrateOutcome(BaseModel):
note: str = Field(default="", description="Short summary of what landed.")
class TopicSelectionOutput(BaseModel):
"""Structured output for daily topic selection."""
topics: list[DreamTopic] = Field(default_factory=list)
class ProactiveResult(BaseModel):
"""Result of reading daily interest topics."""
date: str = ""
path: str = ""
topics: list[dict] = Field(default_factory=list)
content: str = ""
skipped: bool = False
error: str = ""
summary: str = ""
class DreamState(BaseModel):
"""Shared state passed across the dream steps."""
@ -78,7 +52,6 @@ class DreamState(BaseModel):
existing: dict[str, float] = Field(default_factory=dict)
indexed: dict[str, float] = Field(default_factory=dict)
units: list[dict] = Field(default_factory=list)
topics: list[dict] = Field(default_factory=list)
extract_summary: str = ""
integrate_results: list[dict] = Field(default_factory=list)
skipped_units: list[dict] = Field(default_factory=list)
@ -86,14 +59,10 @@ class DreamState(BaseModel):
nodes_updated: list[str] = Field(default_factory=list)
modified_paths: list[str] = Field(
default_factory=list,
description="Durable digest or interests files detected as created or changed during this run.",
description="Durable digest files detected as created or changed during this run.",
)
failed_units: list[dict] = Field(default_factory=list)
failed_paths: list[str] = Field(default_factory=list)
interests_path: str = ""
interests_paths: list[str] = Field(default_factory=list)
topics_written: int = 0
topic_error: str = ""
checkpoint_paths: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)

134
reme/schema/proactive.py Normal file
View file

@ -0,0 +1,134 @@
"""Proactive refresh schemas.
Defines the topic model (v2), the chain-shared context state, and the
``daily/_proactive.yaml`` truth-source file model. The LLM reply contract is
validated structurally by ``parse_extract_reply`` instead of a model here.
See ``PROACTIVE_SPEC.md`` sections F1/A2 for the full contracts.
"""
from pydantic import BaseModel, ConfigDict, Field, field_validator
TOPIC_KINDS = ("follow_up", "interest_extend")
def clamp_confidence(value) -> float:
"""Coerce confidence into [0, 1]; any conversion failure falls back to 0.5."""
try:
return min(1.0, max(0.0, float(value)))
except (TypeError, ValueError):
return 0.5
class ProactiveTopic(BaseModel):
"""One proactive topic; every field has a default so v1 files parse seamlessly.
Fallback rules (A2): invalid ``kind`` -> ``interest_extend``; unparseable
``confidence`` -> 0.5. Missing ``id``, ``first_seen`` and
``last_evidence_at`` are context-dependent and therefore resolved by the
loaders, not here.
"""
id: str = ""
title: str = ""
reason: str = ""
kind: str = "interest_extend"
confidence: float = 0.5
first_seen: str = ""
last_evidence_at: str = ""
evidence: str = ""
paths: list[str] = Field(default_factory=list)
@field_validator("kind", mode="before")
@classmethod
def _fallback_kind(cls, value):
text = str(value or "").strip()
return text if text in TOPIC_KINDS else "interest_extend"
@field_validator("confidence", mode="before")
@classmethod
def _fallback_confidence(cls, value):
return clamp_confidence(value)
@field_validator("paths", mode="before")
@classmethod
def _clean_str_list(cls, value):
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item).strip()]
class ProactiveState(BaseModel):
"""Chain-shared proactive context state (``context['proactive']``).
Extract fills change-detection/carry-forward/LLM output fields; topics fills the
filtering fields plus ``push_candidates`` (today's pushable topics); plan
expands candidates into ``scenario_cards``; agenda selects the ordered
``agenda`` and records ``suppressed`` candidates with reasons; finish
records the catalog checkpoint. ``plan_llm_calls`` counts plan+agenda LLM
calls separately from extract's ``llm_calls``.
``file_skip_reason`` is metadata/log only and never persisted to
interests.yaml (v5 simplification R7).
"""
date: str = ""
daily_dir: str = "daily"
workspace: str = ""
scan_days: int = 2
carry_forward_days: int = 14
changed_paths: list[str] = Field(default_factory=list)
changed_mtimes: dict[str, float] = Field(default_factory=dict)
carry_forward_count: int = 0
carry_forward_prompt: list[ProactiveTopic] = Field(default_factory=list)
llm_calls: int = 0
follow_ups: list[dict] = Field(default_factory=list)
extends: list[dict] = Field(default_factory=list)
updates: list[dict] = Field(default_factory=list)
early_exit: str = ""
updates_applied: int = 0
updates_resolved: int = 0
candidates_in: int = 0
candidates: list[dict] = Field(default_factory=list)
dropped_missing: int = 0
dropped_duplicate: int = 0
dropped_known: int = 0
topics_out: list[dict] = Field(default_factory=list)
push_candidates: list[dict] = Field(default_factory=list)
scenario_cards: list[dict] = Field(default_factory=list)
agenda: list[dict] = Field(default_factory=list)
suppressed: list[dict] = Field(default_factory=list)
plan_llm_calls: int = 0
push: bool = False
file_skip_reason: str = ""
interests_path: str = ""
interests_written: bool = False
checkpoint_paths: list[str] = Field(default_factory=list)
duration_ms: int = 0
class ProactiveStateFile(BaseModel):
"""On-disk truth-source ``daily/_proactive.yaml`` (F1.3, v5: 3 sections).
``resolved`` tombstones carry ``first_seen`` so a resurrected topic can
keep its original age anchor (F2.4 reopen channel).
"""
model_config = ConfigDict(extra="ignore")
version: int = 1
open_topics: list[ProactiveTopic] = Field(default_factory=list)
resolved: list[dict] = Field(default_factory=list)
class ProactiveResult(BaseModel):
"""Result of reading daily interest topics (F5)."""
date: str = ""
path: str = ""
topics: list[dict] = Field(default_factory=list)
content: str = ""
skipped: bool = False
error: str = ""
summary: str = ""
push: bool | None = None
generated_at: str = ""
agenda: list[dict] = Field(default_factory=list)

View file

@ -1,17 +1,26 @@
"""Evolve steps."""
from ._evolve import now
from ._evolve import now, passthrough_response
from .auto_image_resource import AutoImageResourceStep
from .auto_memory import AutoMemoryStep
from .auto_memory_cc import AutoMemoryCCStep
from .auto_resource import AutoResourceStep
from .auto_text_resource import AutoTextResourceStep
from .compressor import CompressorStep
from .dream import DreamExtractStep, DreamFinishStep, DreamIntegrateStep, DreamTopicsStep, ProactiveStep
from .dream import DreamExtractStep, DreamFinishStep, DreamIntegrateStep
from .proactive import (
ProactiveAgendaStep,
ProactiveExtractStep,
ProactiveFinishStep,
ProactivePlanStep,
ProactiveStep,
ProactiveTopicsStep,
)
__all__ = [
"now",
"AutoImageResourceStep",
"passthrough_response",
"AutoMemoryStep",
"AutoMemoryCCStep",
"AutoResourceStep",
@ -20,6 +29,10 @@ __all__ = [
"DreamExtractStep",
"DreamFinishStep",
"DreamIntegrateStep",
"DreamTopicsStep",
"ProactiveAgendaStep",
"ProactiveExtractStep",
"ProactiveFinishStep",
"ProactivePlanStep",
"ProactiveStep",
"ProactiveTopicsStep",
]

View file

@ -5,6 +5,8 @@ import zoneinfo
from agentscope.message import Msg
from ...schema import Response
def now(timezone: str | None = None) -> datetime.datetime:
"""Return current datetime in the given IANA timezone, falling back to local."""
@ -40,3 +42,22 @@ def agent_reply_result_text(reply_result: dict) -> str:
if text:
return text
return str(reply_result.get("result") or "").strip()
def passthrough_response(step, skip_key: str) -> Response:
"""Return a success response when a short-circuit flag is set (INV-7).
Short-circuited rounds never write interests.yaml or checkpoint catalogs;
the job still reports success so the skipped round is not counted as a failure.
"""
assert step.context is not None
response = step.context.response
response.success = True
flag = step.context.get(skip_key)
if isinstance(flag, dict):
reason = str(flag.get("reason") or "skipped")
else:
reason = str(flag or "skipped")
response.answer = f"Skipped: {reason}"
step.logger.info(f"[{step.name}] short-circuit via {skip_key!r} reason={reason}")
return response

View file

@ -3,13 +3,9 @@
from .extract import DreamExtractStep
from .finish import DreamFinishStep
from .integrate import DreamIntegrateStep
from .proactive import ProactiveStep
from .topics import DreamTopicsStep
__all__ = [
"DreamExtractStep",
"DreamFinishStep",
"DreamIntegrateStep",
"DreamTopicsStep",
"ProactiveStep",
]

View file

@ -26,11 +26,10 @@ _TOOLS = ("read",)
@R.register("dream_extract_step")
class DreamExtractStep(BaseStep):
"""Scan changed daily files and globally extract merged units/topics."""
"""Scan changed daily files and globally extract merged memory units."""
def __init__(self, topic_session_id: str = "interests", scan_days: int = 2, max_units: int = 5, **kwargs):
def __init__(self, scan_days: int = 2, max_units: int = 5, **kwargs):
super().__init__(**kwargs)
self.topic_session_id = topic_session_id
self.scan_days = scan_days
self.max_units = max_units
@ -57,22 +56,27 @@ class DreamExtractStep(BaseStep):
existing = self._existing(
workspace,
[
path
for scan_day in dates
for path in scan_day_files(workspace, scan_day, daily, f"{self.topic_session_id}.yaml")
],
[path for scan_day in dates for path in scan_day_files(workspace, scan_day, daily)],
)
interest_rels = {f"{daily}/{scan_day}/{self.topic_session_id}.yaml" for scan_day in dates}
day_mds = {f"{daily}/{scan_day}.md" for scan_day in dates}
day_prefixes = tuple(f"{daily}/{scan_day}/" for scan_day in dates)
nodes = await self.file_catalog.get_nodes()
indexed_all = {n.path: n.st_mtime for n in nodes if n.path in day_mds or n.path.startswith(day_prefixes)}
indexed = {path: mt for path, mt in indexed_all.items() if path not in interest_rels}
# Older Auto Dream versions checkpointed generated interests files.
# Remove every such watermark from the dream catalog, not only entries
# inside the current scan window. The exposure files themselves remain
# untouched and are owned by the proactive refresh pipeline.
legacy_interests = sorted(
{n.path for n in nodes if n.path.startswith(f"{daily}/") and n.path.endswith("/interests.yaml")},
)
indexed_all = {
n.path: n.st_mtime
for n in nodes
if n.path not in legacy_interests and (n.path in day_mds or n.path.startswith(day_prefixes))
}
indexed = {path: mt for path, mt in indexed_all.items() if path in existing}
changed = [rel for rel, mt in existing.items() if indexed.get(rel) != mt]
unchanged = [rel for rel, mt in existing.items() if indexed.get(rel) == mt]
protected = set(existing) | {rel for rel in interest_rels if (workspace / rel).is_file()}
deleted = sorted(indexed_all.keys() - protected)
deleted = sorted((indexed_all.keys() - set(existing)) | set(legacy_interests))
self.logger.info(
f"[{self.name}] scan summary existing={len(existing)} indexed={len(indexed)} "
f"changed={len(changed)} unchanged={len(unchanged)} deleted={len(deleted)}",
@ -147,21 +151,20 @@ class DreamExtractStep(BaseStep):
return self._finish(state, False, error)
units = meta.get("units") if "units" in meta else meta.get("memory_units")
if isinstance(units, list) and isinstance(meta.get("topics"), list):
if isinstance(units, list):
break
if attempt == 0:
self.logger.warning(f"[{self.name}] extract attempt 1 returned an unusable receipt; retrying once")
continue
# Keep the warning-only result checkpointable after one retry so a bad source cannot loop forever.
warning = "dream extract skipped unusable agent receipt after retry; expected units and topics lists"
warning = "dream extract skipped unusable agent receipt after retry; expected a units list"
state.warnings.append(warning)
self.logger.warning(f"[{self.name}] {warning}")
self.logger.info(f"[{self.name}] parse done keys={','.join(sorted(meta.keys())) if meta else '(none)'}")
self.clean_output(state, meta, max_units=max_units)
state.extract_summary = raw_result
answer = f"Extracted {len(state.units)} unit(s), {len(state.topics)} topic(s)"
answer = f"{answer} from {len(changed)} changed file(s) across {len(dates)} day(s)"
answer = f"Extracted {len(state.units)} unit(s) from {len(changed)} changed file(s) across {len(dates)} day(s)"
return self._finish(state, True, answer)
def _existing(self, workspace, files: list[str]) -> dict[str, float]:
@ -193,29 +196,6 @@ class DreamExtractStep(BaseStep):
self.logger.warning(f"[{self.name}] unit {name!r} emitted bucket {raw_bucket!r}; routing to wiki")
bucket = DreamBucketEnum.WIKI.value
state.units.append({"name": name, "bucket": bucket, "summary": summary, "paths": paths})
for raw in meta.get("topics") or []:
topic = self._clean_topic(raw, allowed)
if topic:
state.topics.append(topic)
@staticmethod
def _clean_topic(raw, allowed: set[str]) -> dict:
if not isinstance(raw, dict):
return {}
title = str(raw.get("title") or "").strip()
reason = str(raw.get("reason") or "").strip()
paths = clean_paths(raw.get("paths"), allowed)
if not title or not reason or not paths:
return {}
keywords = raw.get("keywords") or []
cleaned_keywords = [str(k).strip() for k in keywords if str(k).strip()] if isinstance(keywords, list) else []
return {
"title": title,
"reason": reason,
"evidence": str(raw.get("evidence") or "").strip(),
"keywords": cleaned_keywords,
"paths": paths,
}
def _finish(self, state: DreamState, success: bool, answer: str):
assert self.context is not None

View file

@ -1,7 +1,6 @@
extract_system_prompt: |
You are the dream global extraction agent. Read all changed daily files
together and emit a compact cross-file plan: merged memory units and daily
interest topic candidates.
together and emit a compact cross-file plan of merged memory units.
workspace_dir: {workspace_dir}
buckets: {buckets}
@ -14,7 +13,6 @@ extract_system_prompt: |
## What to Extract
- **Reusable memory units**: durable abstractions worth integrating into digest.
- **Daily interest candidates**: topics the user may care about seeing again.
- **Cross-file merges**: one unit may gather evidence from several paths.
- **No raw summaries**: do not summarize every file or every event.
@ -42,7 +40,7 @@ extract_system_prompt: |
- summary should name the abstraction, explain why it matters, and point at
the supporting evidence; do not quote or summarize a note.
- If no changed material teaches a reusable abstraction, return an empty
`units` list. Topic candidates may still be non-empty.
`units` list.
## Bucket Rules
@ -57,13 +55,6 @@ extract_system_prompt: |
- "Small PRs are easier to review" -> wiki.
- "Steps to split a large PR" -> procedure.
## Topic Rules
- Emit topics the user may care about, not generic labels.
- Each topic must include title, reason, evidence, keywords, and paths.
- paths must only contain values from changed_paths.
- Prefer concrete, recurring, or actionable interests over broad categories.
## Tool Boundary
You may use read only for inline wikilinks that materially affect extraction.
@ -76,16 +67,9 @@ extract_system_prompt: |
bucket: procedure|personal|wiki
summary: <grounded reusable abstraction>
paths: [<changed path>, ...]
topics:
- title: <specific user-interest topic>
reason: <why it matters>
evidence: <short evidence pointer>
keywords: [<keyword>, ...]
paths: [<changed path>, ...]
extract_system_prompt_zh: |
你是 dream 全局抽取 agent。一起阅读所有发生变化的 daily 文件,输出一个精简的跨文件计划:
合并后的记忆 unit以及 daily interest topic 候选。
你是 dream 全局抽取 agent。一起阅读所有发生变化的 daily 文件,输出一个由合并记忆 unit 组成的精简跨文件计划。
workspace_dir: {workspace_dir}
buckets: {buckets}
@ -97,7 +81,6 @@ extract_system_prompt_zh: |
## 抽取什么
- **可复用记忆 unit**:值得整合进 digest 的长期抽象。
- **Daily interest 候选**:用户之后可能还会关心、值得保留的主题。
- **跨文件合并**:一个 unit 可以合并来自多个 path 的证据。
- **不要原文摘要**:不要逐文件总结,也不要逐事件总结。
@ -116,7 +99,7 @@ extract_system_prompt_zh: |
- 不要输出 passing mention、已知概念复述、事件 umbrella unit、一次性时间戳、参会事实、
或没有复用价值的事实。
- summary 应命名抽象、解释它为什么重要,并指向支持证据;不要只是摘抄或总结笔记。
- 如果 changed material 没有教导任何可复用抽象,返回空 `units` listtopic 候选仍然可以非空
- 如果 changed material 没有教导任何可复用抽象,返回空 `units` list
## Bucket 规则
@ -129,13 +112,6 @@ extract_system_prompt_zh: |
- “小 PR 更容易 review” -> wiki。
- “如何拆分大 PR 的步骤” -> procedure。
## Topic 规则
- 输出用户可能真正关心的 topic而不是泛泛的标签。
- 每个 topic 必须包含 title、reason、evidence、keywords、paths。
- paths 只能使用 changed_paths 中出现的值。
- 优先选择具体、反复出现、可行动的兴趣,而不是宽泛类别。
## 工具边界
只有当 inline wikilink 会实质影响抽取时,才可以使用 read。
@ -148,12 +124,6 @@ extract_system_prompt_zh: |
bucket: procedure|personal|wiki
summary: <基于证据的可复用抽象>
paths: [<changed path>, ...]
topics:
- title: <具体的用户兴趣 topic>
reason: <为什么重要>
evidence: <简短证据指针>
keywords: [<关键词>, ...]
paths: [<changed path>, ...]
extract_user_message: |
date: {date}
@ -169,7 +139,7 @@ extract_user_message: |
{material_blob}
Extract merged memory units and daily interest topic candidates.
Extract merged memory units.
extract_user_message_zh: |
日期:{date}
@ -185,4 +155,4 @@ extract_user_message_zh: |
{material_blob}
抽取合并后的记忆 units 和 daily interest topic 候选
抽取合并后的记忆 units

View file

@ -26,12 +26,11 @@ class DreamFinishStep(BaseStep):
failed_paths = set(state.failed_paths)
checkpoint = [p for p in state.changed_paths if p not in failed_paths]
day_index_paths = [f"{state.daily_dir}/{day}.md" for day in (state.dates or [state.date]) if day]
interest_paths = state.interests_paths or ([state.interests_path] if state.interests_path else [])
supplemental = [p for p in [*interest_paths, *day_index_paths] if p and p not in failed_paths]
supplemental = [p for p in day_index_paths if p and p not in failed_paths]
upsert_paths = list(dict.fromkeys([*checkpoint, *supplemental]))
self.logger.info(
f"[{self.name}] start changed={len(state.changed_paths)} failed_paths={len(state.failed_paths)} "
f"checkpoint={len(checkpoint)} interest_paths={len(interest_paths)} day_indexes={len(day_index_paths)} "
f"checkpoint={len(checkpoint)} day_indexes={len(day_index_paths)} "
f"deleted={len(state.deleted_paths)} persist={self.persist}",
)
upserts = self._nodes(workspace, upsert_paths)
@ -72,7 +71,6 @@ class DreamFinishStep(BaseStep):
def render_summary(state: DreamState) -> str:
"""Render a concise user-facing summary."""
interest_paths = state.interests_paths or ([state.interests_path] if state.interests_path else [])
dates = ", ".join(state.dates or [state.date])
lines = [
("AutoDream completed with warnings" if state.warnings else "AutoDream completed"),
@ -83,12 +81,11 @@ def render_summary(state: DreamState) -> str:
f"- Files: {state.files_scanned} scanned, {state.files_changed} changed, "
f"{state.files_unchanged} unchanged, {state.files_deleted} deleted"
),
f"- Extracted: {len(state.units)} unit(s), {len(state.topics)} topic candidate(s)",
f"- Extracted: {len(state.units)} unit(s)",
(
f"- Integrated: {len(state.integrate_results)} ok, {len(state.skipped_units)} skipped, "
f"{len(state.failed_units)} failed"
),
f"- Topics: {state.topics_written} written" + (f" to {', '.join(interest_paths)}" if interest_paths else ""),
f"- Catalog: checkpointed {len(state.checkpoint_paths)} changed path(s)",
]
if state.nodes_created:

View file

@ -1,60 +0,0 @@
"""Read daily interests.yaml for proactive use."""
from ...base_step import BaseStep
from ....components import R
from ....schema import ProactiveResult
from .utils import load_yaml_topics, today, workspace_dir
@R.register("proactive_step")
class ProactiveStep(BaseStep):
"""Read ``daily/<date>/interests.yaml``."""
def __init__(self, include_content: bool = True, **kwargs):
super().__init__(**kwargs)
self.include_content = include_content
async def execute(self):
assert self.context is not None
day = today(self, str(self.context.get("date", "") or ""))
include_content = bool(self.context.get("include_content", self.include_content))
daily = self.config_value("daily_dir")
rel_path, abs_path = f"{daily}/{day}/interests.yaml", workspace_dir(self) / daily / day / "interests.yaml"
result = ProactiveResult(date=day, path=rel_path)
self.logger.info(f"[{self.name}] start date={day} path={rel_path} include_content={include_content}")
if not abs_path.is_file():
result.skipped, result.summary = True, f"Skipped: interests file not found at {rel_path}"
self.logger.info(f"[{self.name}] skip missing path={rel_path}")
return self._finish(True, result, include_content=include_content)
try:
self.logger.info(f"[{self.name}] read start path={rel_path}")
result.content = abs_path.read_text(encoding="utf-8") if include_content else ""
result.topics = load_yaml_topics(abs_path)
self.logger.info(
f"[{self.name}] read done path={rel_path} topics={len(result.topics)} chars={len(result.content)}",
)
except Exception as e: # noqa: BLE001
result.error, result.summary = f"{type(e).__name__}: {e}", ""
self.logger.error(f"[{self.name}] read failed path={rel_path}: {result.error}")
return self._finish(False, result, include_content=include_content)
result.summary = f"Read {len(result.topics)} proactive topic(s) from {rel_path}"
return self._finish(True, result, include_content=include_content)
def _finish(self, success: bool, result: ProactiveResult, *, include_content: bool):
assert self.context is not None
self.context.response.success = success
if not success:
self.context.response.answer = f"Error: {result.error}"
elif result.skipped:
self.context.response.answer = result.summary
else:
self.context.response.answer = {
"summary": result.summary,
"topics": result.topics,
**({"content": result.content} if include_content else {}),
}
self.context.response.metadata.update(result.model_dump())
self.logger.info(f"[{self.name}] finish success={success} answer={self.context.response.answer!r}")
return self.context.response

View file

@ -1,229 +0,0 @@
"""Daily interests.yaml step."""
import json
from pathlib import Path
from ...base_step import BaseStep
from ...file_io import refresh_day_index
from .._evolve import agent_reply_result_text
from ....components import R
from .utils import (
load_yaml_topics,
llm_available,
normalize_topic,
parse_structured_reply,
previous_dates,
state_from_context,
store_state,
workspace_dir,
write_yaml,
)
@R.register("dream_topics_step")
class DreamTopicsStep(BaseStep):
"""Write ``daily/<date>/interests.yaml`` with same-day and recent de-dup."""
def __init__(self, topic_count: int = 3, topic_diversity_days: int = 7, **kwargs):
super().__init__(**kwargs)
self.topic_count = topic_count
self.topic_diversity_days = topic_diversity_days
async def execute(self):
assert self.context is not None
state = state_from_context(self)
topic_count = int(self.context.get("topic_count", self.topic_count) or self.topic_count)
raw_days = self.context.get("topic_diversity_days", self.topic_diversity_days)
diversity_days = int(raw_days or self.topic_diversity_days)
workspace = Path(state.workspace).resolve() if state.workspace else workspace_dir(self)
target_day = state.date or ((state.dates or [""])[-1])
self.logger.info(
f"[{self.name}] start target_day={target_day!r} candidates={len(state.topics)} "
f"topic_count={topic_count} diversity_days={diversity_days}",
)
if not state.topics:
existing_paths = []
if target_day and self._abs_path(workspace, state.daily_dir, target_day).is_file():
existing_paths = [self._rel_path(state.daily_dir, target_day)]
state.interests_paths = existing_paths
state.interests_path = existing_paths[-1] if existing_paths else ""
state.topics_written = (
len(load_yaml_topics(self._abs_path(workspace, state.daily_dir, target_day))) if target_day else 0
)
answer = (
f"Kept existing interest topic(s) at {', '.join(existing_paths)}"
if existing_paths
else "Skipped interests.yaml write: no new topic candidates"
)
self.logger.info(f"[{self.name}] skip no candidates existing_paths={len(existing_paths)}")
return self._finish(state, True, answer)
try:
if not target_day:
state.interests_paths = []
state.interests_path = ""
state.topics_written = 0
self.logger.info(f"[{self.name}] skip no target date")
return self._finish(state, True, "Skipped interests.yaml write: no target date")
rel_path = self._rel_path(state.daily_dir, target_day)
abs_path = self._abs_path(workspace, state.daily_dir, target_day)
same_day = load_yaml_topics(abs_path, strict=True)
recent = [
topic
for previous_day in previous_dates(target_day, diversity_days)
for topic in load_yaml_topics(self._abs_path(workspace, state.daily_dir, previous_day))
]
self.logger.info(
f"[{self.name}] loaded context same_day={len(same_day)} recent={len(recent)} target={rel_path}",
)
topics, _used_llm = await self._select_topics(
target_day,
state.topics,
same_day,
recent,
topic_count,
diversity_days,
state,
)
self.logger.info(f"[{self.name}] selected topics={len(topics)} used_llm={_used_llm}")
payload = {
"date": target_day,
"topic_count": topic_count,
"diversity_days": diversity_days,
"topics": topics,
}
before_content = abs_path.read_bytes() if abs_path.is_file() else None
self.logger.info(f"[{self.name}] write yaml start path={rel_path}")
write_yaml(abs_path, payload)
self.logger.info(f"[{self.name}] write yaml done path={rel_path}")
if before_content != abs_path.read_bytes() and rel_path not in state.modified_paths:
state.modified_paths.append(rel_path)
self.logger.info(f"[{self.name}] refresh index start date={target_day} daily_dir={state.daily_dir}")
await refresh_day_index(self.file_store, target_day, state.daily_dir)
self.logger.info(f"[{self.name}] refresh index done date={target_day}")
state.interests_paths = [rel_path]
state.interests_path = rel_path
state.topics_written = len(topics)
answer = f"Wrote {len(topics)} interest topic(s) to {rel_path}"
return self._finish(state, True, answer)
except Exception as e: # noqa: BLE001
state.topic_error = f"{type(e).__name__}: {e}"
state.errors.append(state.topic_error)
self.logger.error(f"[{self.name}] failed: {state.topic_error}")
return self._finish(state, False, f"Error: {state.topic_error}")
async def _select_topics(
self,
day: str,
candidates: list[dict],
same_day: list[dict],
recent: list[dict],
count: int,
days: int,
state,
):
if not candidates:
return self._dedupe([], same_day, recent, count), False
if not llm_available(self):
self.logger.info(f"[{self.name}] select topics without llm candidates={len(candidates)}")
return self._dedupe(candidates, same_day, recent, count), False
self.logger.info(
f"[{self.name}] topics agent start candidates={len(candidates)} "
f"same_day={len(same_day)} recent={len(recent)}",
)
message = self.prompt_format(
"topics_user_message",
date=day,
topic_count=count,
diversity_days=days,
candidates_json=json.dumps(candidates, ensure_ascii=False, indent=2),
same_day_json=json.dumps(same_day, ensure_ascii=False, indent=2),
recent_topics_json=json.dumps(recent, ensure_ascii=False, indent=2),
)
try:
result = await self.agent_wrapper.reply(
message,
system_prompt=self.prompt_format("topics_system_prompt"),
)
self.logger.info(f"[{self.name}] topics agent done has_result={bool(result.get('result'))}")
raw_result = agent_reply_result_text(result)
meta = parse_structured_reply(raw_result)
except Exception as e: # noqa: BLE001
warning = f"topic selection agent unavailable; used deterministic fallback ({type(e).__name__})"
state.warnings.append(warning)
self.logger.warning(f"[{self.name}] {warning}: {e}")
return self._dedupe(candidates, same_day, recent, count), False
allowed_paths = {
str(path).strip() for candidate in candidates for path in candidate.get("paths") or [] if str(path).strip()
}
selected = [self._clean_topic(t, allowed_paths) for t in meta.get("topics") or []]
if not any(selected):
if not isinstance(meta.get("topics"), list):
warning = "topic selection skipped unusable agent receipt; used deterministic fallback"
state.warnings.append(warning)
self.logger.warning(f"[{self.name}] {warning}")
self.logger.info(f"[{self.name}] topics agent produced no usable topics; fallback to candidates")
selected = candidates
return self._dedupe(selected, same_day, recent, count), True
@staticmethod
def _rel_path(daily_dir: str, day: str) -> str:
return f"{daily_dir}/{day}/interests.yaml"
@staticmethod
def _abs_path(workspace: Path, daily_dir: str, day: str) -> Path:
return workspace / daily_dir / day / "interests.yaml"
@staticmethod
def _clean_topic(raw, allowed_paths: set[str] | None = None) -> dict:
if not isinstance(raw, dict):
return {}
title, reason = (
str(raw.get("title") or "").strip(),
str(raw.get("reason") or "").strip(),
)
if not title or not reason:
return {}
keywords, paths = raw.get("keywords") or [], raw.get("paths") or []
cleaned_keywords = [str(k).strip() for k in keywords if str(k).strip()] if isinstance(keywords, list) else []
cleaned_paths = (
[
str(p).strip()
for p in paths
if str(p).strip() and (allowed_paths is None or str(p).strip() in allowed_paths)
]
if isinstance(paths, list)
else []
)
return {
"title": title,
"reason": reason,
"evidence": str(raw.get("evidence") or "").strip(),
"keywords": cleaned_keywords,
"paths": cleaned_paths,
}
@staticmethod
def _dedupe(topics: list[dict], same_day: list[dict], recent: list[dict], count: int) -> list[dict]:
recent_norm = {normalize_topic(t.get("title", "")) for t in recent}
seen = {normalize_topic(t.get("title", "")) for t in same_day}
out = list(same_day)
for topic in [t for t in topics if t]:
title_norm = normalize_topic(topic.get("title", ""))
if title_norm and title_norm not in seen and title_norm not in recent_norm:
seen.add(title_norm)
out.append(topic)
if len(out) >= count:
break
return out[:count]
def _finish(self, state, success: bool, answer: str):
assert self.context is not None
state.summary = answer
store_state(self, state)
self.context.response.success = success
self.context.response.answer = answer
self.logger.info(f"[{self.name}] finish success={success} answer={answer!r}")
return self.context.response

View file

@ -1,107 +0,0 @@
topics_system_prompt: |
You select final daily user-interest topics from dream candidates for
daily/<date>/interests.yaml.
## Goals
- Preserve existing same-day topics unless they are clear duplicates.
- Avoid duplicates within today.
- Avoid topics already covered in recent interests.yaml files.
- Prefer concrete, recurring, or actionable interests over file summaries.
- Return no more than topic_count topics.
## Topic Quality
A good topic is something the user may want surfaced again: a live area of
attention, a recurring concern, a research direction, a project thread, or a
practical follow-up. Avoid generic labels, daily log summaries, and topics
whose only value is restating a file name.
## Output Rules
- Keep titles concise and specific.
- Keep reason grounded in candidate evidence.
- Keep keywords short and retrieval-friendly.
- Keep paths limited to the paths supplied by the candidates.
- Quote scalar strings that contain punctuation such as `:` or use block
scalars (`>`). Write paths as block lists, not flow lists, so
`daily/<date>/...` stays parseable.
Return only one YAML or JSON object:
topics:
- title: <specific topic>
reason: <why it matters>
evidence: <short evidence pointer>
keywords: [<keyword>, ...]
paths: [<source path>, ...]
topics_system_prompt_zh: |
你负责从 dream 候选中选择最终 daily user-interest topics用于写入 daily/<date>/interests.yaml。
## 目标
- 保留同一天已有的 topics除非它们明显重复。
- 避免当天内部重复。
- 避免重复最近 interests.yaml 已经覆盖过的 topics。
- 优先选择具体、反复出现、可行动的兴趣,而不是文件摘要。
- 返回数量不能超过 topic_count。
## Topic 质量
好的 topic 应该是用户未来可能希望再次看到的内容:持续关注的方向、反复出现的问题、研究方向、
项目线索或实际 follow-up。避免泛泛标签、daily log 摘要,以及只是在复述文件名的 topic。
## 输出规则
- title 要简短、具体。
- reason 要基于候选证据。
- keywords 要短,方便检索。
- paths 只能使用候选中提供的 paths。
- 如果字符串包含 `:` 等标点,要加引号或使用 block scalar (`>`)。
paths 使用 block list不要用 flow list避免 `daily/<date>/...` 解析失败。
只返回一个 YAML 或 JSON object
topics:
- title: <具体 topic>
reason: <为什么重要>
evidence: <简短证据指针>
keywords: [<关键词>, ...]
paths: [<source path>, ...]
topics_user_message: |
date: {date}
topic_count: {topic_count}
diversity_days: {diversity_days}
# Candidate topics
{candidates_json}
# Existing same-day interests.yaml topics
{same_day_json}
# Recent interests.yaml topics to avoid repeating
{recent_topics_json}
Select the final topics for daily interests.yaml.
topics_user_message_zh: |
日期:{date}
topic_count: {topic_count}
diversity_days: {diversity_days}
# 候选 topics
{candidates_json}
# 同一天已有的 interests.yaml topics
{same_day_json}
# 需要避免重复的最近 interests.yaml topics
{recent_topics_json}
选择最终要写入 daily interests.yaml 的 topics。

View file

@ -3,7 +3,6 @@
import datetime as dt
import re
from pathlib import Path
from uuid import uuid4
import yaml
@ -67,8 +66,8 @@ def llm_available(step: BaseStep) -> bool:
return False
def scan_day_files(workspace: Path, day: str, daily: str, interests_name: str = "interests.yaml") -> list[str]:
"""Scan day files."""
def scan_day_files(workspace: Path, day: str, daily: str) -> list[str]:
"""Scan Markdown day-index and note files."""
out: list[str] = []
day_index = workspace / daily / f"{day}.md"
if day_index.is_file():
@ -76,24 +75,42 @@ def scan_day_files(workspace: Path, day: str, daily: str, interests_name: str =
daily_root = workspace / daily / day
if daily_root.is_dir():
out.extend(p.relative_to(workspace).as_posix() for p in sorted(daily_root.rglob("*.md")) if p.is_file())
return [p for p in out if p != f"{daily}/{day}/{interests_name}"]
return out
def pack_paths(workspace: Path, paths: list[str], *, limit_per_file: int = 60000) -> str:
"""Pack paths into a single string."""
def pack_paths(
workspace: Path,
paths: list[str],
*,
limit_per_file: int = 60000,
max_total_chars: int | None = None,
) -> str:
"""Pack paths into a single string.
With ``max_total_chars`` set, blocks are packed in the given order until
the accumulated size would exceed the budget; the first file is always
kept and a trailer records how many files were omitted.
"""
blocks: list[str] = []
for rel in paths:
total = 0
for index, rel in enumerate(paths):
target = workspace / rel
if not target.is_file():
blocks.append(f"### {rel}\n(file not found)\n")
continue
try:
text = target.read_text(encoding="utf-8")
except Exception as e: # noqa: BLE001
blocks.append(f"### {rel}\n(error reading: {type(e).__name__}: {e})\n")
continue
suffix = "\n\n[truncated]\n" if len(text) > limit_per_file else ""
blocks.append(f"### {rel}\n{text[:limit_per_file]}{suffix}\n")
block = f"### {rel}\n(file not found)\n"
else:
try:
text = target.read_text(encoding="utf-8")
except Exception as e: # noqa: BLE001
block = f"### {rel}\n(error reading: {type(e).__name__}: {e})\n"
else:
suffix = "\n\n[truncated]\n" if len(text) > limit_per_file else ""
block = f"### {rel}\n{text[:limit_per_file]}{suffix}\n"
if max_total_chars is not None and index > 0 and total + len(block) > max_total_chars:
omitted = len(paths) - index
blocks.append(f"(omitted {omitted} file(s) to stay within the {max_total_chars}-char total budget)")
break
blocks.append(block)
total += len(block)
return "\n".join(blocks)
@ -109,11 +126,6 @@ def clean_paths(raw_paths, allowed: set[str]) -> list[str]:
return out
def normalize_topic(text: str) -> str:
"""Normalize topic."""
return re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text.lower()).strip()
def previous_dates(day: str, n_days: int) -> list[str]:
"""Get previous dates."""
try:
@ -123,90 +135,6 @@ def previous_dates(day: str, n_days: int) -> list[str]:
return [(base - dt.timedelta(days=i)).isoformat() for i in range(1, max(n_days, 0) + 1)]
def load_yaml_topics(path: Path, *, strict: bool = False) -> list[dict]:
"""Load YAML topics."""
if not path.is_file():
return []
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception as exc:
if strict:
raise ValueError(f"Invalid interests YAML at {path}: {exc}") from exc
return []
if data is None:
if strict:
raise ValueError(f"Invalid interests YAML at {path}: expected an object")
return []
topics = data.get("topics") if isinstance(data, dict) else None
if not isinstance(topics, list):
if strict:
raise ValueError(f"Invalid interests YAML at {path}: topics must be a list")
return []
cleaned_topics = []
for index, topic in enumerate(topics):
if strict:
_validate_topic(topic, path, index)
if isinstance(topic, dict) and (cleaned := clean_topic(topic)):
cleaned_topics.append(cleaned)
return cleaned_topics
def _validate_topic(topic: object, path: Path, index: int) -> None:
"""Reject topic data that would otherwise be silently discarded or coerced."""
prefix = f"Invalid interests YAML at {path}: topics[{index}]"
if not isinstance(topic, dict):
raise ValueError(f"{prefix} must be an object")
allowed = {"title", "reason", "evidence", "keywords", "paths"}
if unknown := sorted(set(topic) - allowed):
raise ValueError(f"{prefix} has unknown field(s): {', '.join(str(key) for key in unknown)}")
for field in ("title", "reason"):
value = topic.get(field)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{prefix}.{field} must be a non-empty string")
if "evidence" in topic and not isinstance(topic["evidence"], str):
raise ValueError(f"{prefix}.evidence must be a string")
for field in ("keywords", "paths"):
if field not in topic:
continue
values = topic[field]
if not isinstance(values, list) or any(not isinstance(value, str) or not value.strip() for value in values):
raise ValueError(f"{prefix}.{field} must be a list of non-empty strings")
def clean_topic(raw: dict) -> dict:
"""Clean topic."""
title, reason = (
str(raw.get("title") or "").strip(),
str(raw.get("reason") or "").strip(),
)
if not title or not reason:
return {}
keywords = raw.get("keywords") or []
paths = raw.get("paths") or []
return {
"title": title,
"reason": reason,
"evidence": str(raw.get("evidence") or "").strip(),
"keywords": ([str(k).strip() for k in keywords if str(k).strip()] if isinstance(keywords, list) else []),
"paths": ([str(p).strip() for p in paths if str(p).strip()] if isinstance(paths, list) else []),
}
def write_yaml(path: Path, payload: dict) -> None:
"""Atomically write YAML without exposing a partially written user file."""
path.parent.mkdir(parents=True, exist_ok=True)
rendered = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
content = rendered if rendered.endswith("\n") else f"{rendered}\n"
temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
try:
temporary.write_text(content, encoding="utf-8")
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def parse_structured_reply(text: str) -> dict:
"""Parse a JSON/YAML object from an agent reply, including fenced blocks."""
candidates = [text.strip()]

View file

@ -0,0 +1,17 @@
"""Proactive refresh steps: independent of the nightly dream chain."""
from .agenda import ProactiveAgendaStep
from .extract import ProactiveExtractStep
from .finish import ProactiveFinishStep
from .plan import ProactivePlanStep
from .proactive import ProactiveStep
from .topics import ProactiveTopicsStep
__all__ = [
"ProactiveAgendaStep",
"ProactiveExtractStep",
"ProactiveFinishStep",
"ProactivePlanStep",
"ProactiveStep",
"ProactiveTopicsStep",
]

View file

@ -0,0 +1,277 @@
"""Proactive agenda step: list-wise generative agenda + auditable silence (F2.6).
Replaces "sort and truncate" selection with generation: the LLM sees every
scenario card plus today's conversation context and an optional user profile,
then decides BY ITSELF how many items deserve today's agenda (bounded only by
the ``max_agenda_items`` safety cap) and must explicitly account for every
candidate it does not schedule (suppressed with a reason - silence is
auditable). This lets it merge candidates that are different entrances to the
same matter, keep narrative coherence, and avoid clashing with the user's
current focus.
Deterministic guarantees: exactly one candidate -> auto-agenda with no LLM
call; LLM absent/timed out/unusable -> freshness/confidence order fallback;
after validation the agenda is never empty while candidates exist, so the push
semantics computed by the topics step never flip. The step also owns the final
interests.yaml render, extending the v2 shape with ``agenda``/``suppressed``.
"""
import asyncio
import json
import time
from ...base_step import BaseStep
from .._evolve import agent_reply_result_text, passthrough_response
from ....components import R
from ....schema import ProactiveState
from ..dream.utils import daily_dir, workspace_dir
from .utils import (
current_now,
interests_path_for,
load_personal_profile_block,
norm_path,
parse_agenda_reply,
render_interests,
resolve_agent_wrapper,
write_interests_if_changed,
)
FALLBACK_ORDER_REASON = "deterministic fallback: freshness/confidence order"
@R.register("proactive_agenda_step")
class ProactiveAgendaStep(BaseStep):
"""Generate today's proactive agenda and render the enriched interests.yaml.
Skips silently (leaving the topics step's push=false render in place) when
there are no push candidates. Otherwise writes the final v2 file with the
``agenda`` and ``suppressed`` keys.
"""
def __init__(
self,
max_agenda_items: int = 6,
llm_timeout_seconds: float = 120,
profile_rel_path: str = "profile.md",
profile_max_chars: int = 2500,
skip_key: str = "proactive_skip",
**kwargs,
):
super().__init__(**kwargs)
self.max_agenda_items = max(int(max_agenda_items), 1)
self.llm_timeout_seconds = float(llm_timeout_seconds)
self.profile_rel_path = str(profile_rel_path or "")
self.profile_max_chars = max(int(profile_max_chars), 0)
self.skip_key = skip_key
async def execute(self):
assert self.context is not None
if self.context.get(self.skip_key):
return passthrough_response(self, self.skip_key)
started = time.monotonic()
raw_state = self.context.get("proactive")
if not raw_state:
self.context.response.success = True
self.context.response.answer = "Skipped agenda: no proactive extract state in context"
return self.context.response
state = ProactiveState.model_validate(raw_state)
if state.early_exit:
self.context.response.success = True
self.context.response.answer = f"Skipped agenda: {state.early_exit}"
return self.context.response
candidates = [c for c in state.push_candidates if isinstance(c, dict) and c.get("id")]
if not candidates:
self.context.response.success = True
self.context.response.answer = "Agenda: no push candidates; interests.yaml left to topics render"
self.logger.info(f"[{self.name}] no push candidates; nothing to agenda")
return self.context.response
ws = workspace_dir(self)
cards_by_id = {str(c.get("topic_id") or ""): c for c in state.scenario_cards if isinstance(c, dict)}
carded = [c for c in candidates if str(c.get("id")) in cards_by_id]
over_budget = [c for c in candidates if str(c.get("id")) not in cards_by_id]
self.logger.info(
f"[{self.name}] start candidates={len(candidates)} carded={len(carded)} "
f"over_budget={len(over_budget)} max_agenda_items={self.max_agenda_items}",
)
if not carded:
agenda_ids, order_reasons, suppressed_reasons = [], {}, {}
elif len(carded) == 1:
agenda_ids = [str(carded[0].get("id"))]
order_reasons = {agenda_ids[0]: "single push candidate; auto-agenda without LLM"}
suppressed_reasons = {}
else:
agenda_ids, order_reasons, suppressed_reasons = await self._llm_agenda(state, ws, carded, cards_by_id)
suppressed: list[dict] = []
for candidate in over_budget:
suppressed.append(
{
"topic_id": str(candidate.get("id")),
"title": str(candidate.get("title") or ""),
"reason": "over plan budget: not expanded into a scenario card",
},
)
for candidate in carded:
cid = str(candidate.get("id"))
if cid in agenda_ids:
continue
suppressed.append(
{
"topic_id": cid,
"title": str(candidate.get("title") or ""),
"reason": suppressed_reasons.get(cid) or "not selected for today's agenda",
},
)
agenda: list[dict] = []
for cid in agenda_ids:
card = cards_by_id.get(cid)
candidate = next((c for c in carded if str(c.get("id")) == cid), None)
if card is None or candidate is None:
continue
agenda.append(
{
"topic_id": cid,
"title": str(candidate.get("title") or ""),
"scenario_type": str(card.get("scenario_type") or ""),
"opener": str(card.get("opener") or ""),
"next_action": str(card.get("next_action") or ""),
"preconditions": list(card.get("preconditions") or []),
"delivery": str(card.get("delivery") or "in_conversation"),
"linked_memory": list(card.get("linked_memory") or []),
"order_reason": order_reasons.get(cid) or "",
},
)
state.agenda = agenda
state.suppressed = suppressed
state.push = bool(agenda)
day = state.date
daily = state.daily_dir or daily_dir(self)
rendered = render_interests(
day,
state.topics_out,
state.push,
current_now(self),
agenda=agenda,
suppressed=suppressed,
)
interests_path = interests_path_for(ws, daily, day)
written = write_interests_if_changed(ws, interests_path, rendered)
rel_path = norm_path(interests_path.relative_to(ws).as_posix())
state.interests_path = rel_path
state.interests_written = bool(state.interests_written or written)
state.duration_ms = state.duration_ms + int((time.monotonic() - started) * 1000)
self._store(state)
answer = (
f"Agenda: {len(agenda)} item(s), suppressed={len(suppressed)}, push={state.push}, "
f"written={written} to {rel_path}"
)
self.context.response.success = True
self.context.response.answer = answer
self.logger.info(f"[{self.name}] finish {answer}")
return self.context.response
async def _llm_agenda(
self,
state: ProactiveState,
ws,
carded: list[dict],
cards_by_id: dict[str, dict],
) -> tuple[list[str], dict[str, str], dict[str, str]]:
"""One list-wise LLM call; returns (agenda ids, order reasons, suppressed reasons)."""
carded_ids = [str(c.get("id")) for c in carded]
wrapper = resolve_agent_wrapper(self)
if wrapper is None:
self.logger.warning(f"[{self.name}] no agent_wrapper available; deterministic fallback agenda")
return self._fallback_agenda(carded_ids)
cards_json = json.dumps(
[
{
"topic_id": str(c.get("id")),
"title": c.get("title"),
"kind": c.get("kind"),
"scenario_type": cards_by_id[str(c.get("id"))].get("scenario_type"),
"opener": cards_by_id[str(c.get("id"))].get("opener"),
"next_action": cards_by_id[str(c.get("id"))].get("next_action"),
"delivery": cards_by_id[str(c.get("id"))].get("delivery"),
}
for c in carded
],
ensure_ascii=False,
)
user_message = self.prompt_format(
"agenda_user_message",
max_agenda_items=self.max_agenda_items,
cards_json=cards_json,
changed_files_json=json.dumps(list(dict.fromkeys(state.changed_paths)), ensure_ascii=False),
profile_block=self._profile_block(ws),
)
system_prompt = self.prompt_format("agenda_system_prompt", max_agenda_items=self.max_agenda_items)
state.plan_llm_calls += 1
try:
result = await asyncio.wait_for(
wrapper.reply(user_message, system_prompt=system_prompt),
timeout=self.llm_timeout_seconds,
)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
return self._fallback_agenda(carded_ids)
except Exception as e: # noqa: BLE001 - network/provider errors degrade to fallback agenda
self.logger.warning(
f"[{self.name}] LLM reply failed ({type(e).__name__}: {e}); using fallback agenda",
)
return self._fallback_agenda(carded_ids)
raw = agent_reply_result_text(result)
agenda_raw, suppressed_raw = parse_agenda_reply(raw)
allowed = set(carded_ids)
agenda_ids: list[str] = []
for item in agenda_raw:
cid = str(item.get("topic_id") or "").strip()
if cid in allowed and cid not in agenda_ids:
agenda_ids.append(cid)
if len(agenda_ids) >= self.max_agenda_items:
break
order_reasons = {
str(item.get("topic_id") or "").strip(): str(item.get("order_reason") or "").strip()
for item in agenda_raw
if str(item.get("topic_id") or "").strip() in allowed
}
suppressed_reasons = {
str(item.get("topic_id") or "").strip(): str(item.get("reason") or "").strip()
for item in suppressed_raw
if str(item.get("topic_id") or "").strip() in allowed
}
if not agenda_ids:
self.logger.warning(f"[{self.name}] agenda reply unusable; raw={raw[:200]!r}")
return self._fallback_agenda(carded_ids)
return agenda_ids, order_reasons, suppressed_reasons
def _fallback_agenda(self, carded_ids: list[str]) -> tuple[list[str], dict[str, str], dict[str, str]]:
"""Freshness/confidence head when generation is unavailable (cards keep sort order)."""
agenda_ids = carded_ids[: self.max_agenda_items]
order_reasons = {cid: FALLBACK_ORDER_REASON for cid in agenda_ids}
suppressed_reasons = {cid: "capacity (deterministic fallback)" for cid in carded_ids if cid not in agenda_ids}
return agenda_ids, order_reasons, suppressed_reasons
def _profile_block(self, ws) -> str:
"""Digest-personal profile first, legacy single profile file as fallback."""
digest_dir = str(self.config_value("digest_dir"))
return load_personal_profile_block(
ws,
digest_dir,
self.profile_max_chars,
self.profile_rel_path,
)
def _store(self, state: ProactiveState) -> None:
assert self.context is not None
data = state.model_dump()
self.context["proactive"] = data
self.context.response.metadata["proactive"] = data

View file

@ -0,0 +1,94 @@
agenda_system_prompt: |
You are the editor of today's proactive agenda for a personal memory
assistant. You receive scenario cards (already expanded from the push
candidates), the files that changed today (a proxy for what the user is
focused on right now) and an optional user profile.
Decide which cards enter today's agenda, in which order, and which are
held back. The number of agenda items is YOUR call: judge it from content
richness and redundancy - anything from 1 up to at most {max_agenda_items}.
Fewer, stronger items beat many weak ones.
## Editorial rules
- Merge, don't stack: when two cards are really the same matter reached
from different entrances, keep the single better entrance on the agenda
and suppress the other with reason `merged into <kept topic_id>`.
- Narrative coherence: today's agenda should read as one coherent story,
not a grab bag; order items so one naturally leads to the next.
- Focus conflict: if a card clashes with what the user is clearly focused
on today (changed files / profile), suppress it and say so.
- Auditable silence: EVERY candidate topic_id must appear exactly once,
either in `agenda` or in `suppressed` (with a short reason). Never drop
a candidate without a word.
## Output format
Return only one YAML fenced block:
```yaml
agenda:
- topic_id: <id>
order_reason: <why here, why now>
suppressed:
- topic_id: <id>
reason: <short reason; use "merged into <id>" for merges>
```
agenda_system_prompt_zh: |
你是个人记忆助手当日主动议程的编辑。输入是场景卡(由推送候选扩展而来)、
今天发生变化的文件(代表用户当前关注点)以及可选的用户画像。
决定哪些卡片进入今日议程、按什么顺序,哪些暂缓。议程条数由你自行裁决:
依据内容丰富度和重复程度决定,最少 1 条,至多不超过 {max_agenda_items} 条。
少而强胜过多而弱。
## 编辑规则
- 合并不堆砌:若两张卡其实是同一件事的不同入口,只保留更好的那个入口
进入议程,另一个放入 suppressedreason 写 `merged into <保留的 topic_id>`。
- 叙事连贯:今日议程应读起来是一条连贯的线索,而不是一堆碎片;排序让
前一项能自然引出后一项。
- 关注点冲突:若某张卡与用户今天明显关注的事情(变化文件/画像)冲突,
压制它并在 reason 中说明。
- 沉默可审计:每个候选 topic_id 必须恰好出现一次,要么在 agenda要么在
suppressed附简短理由。禁止不声不响地丢弃任何候选。
## 输出格式
只返回一个 YAML fenced block
```yaml
agenda:
- topic_id: <id>
order_reason: <为什么放这里、为什么是现在>
suppressed:
- topic_id: <id>
reason: <简短理由;合并用 "merged into <id>">
```
agenda_user_message: |
max_agenda_items: {max_agenda_items}
scenario_cards (candidate set):
{cards_json}
files changed today (current focus hint):
{changed_files_json}
user_profile:
{profile_block}
Emit the agenda/suppressed block per the contract.
agenda_user_message_zh: |
议程条数上限:{max_agenda_items}
场景卡(候选集):
{cards_json}
今日变化文件(当前关注点提示):
{changed_files_json}
用户画像:
{profile_block}
按契约输出 agenda/suppressed block。

View file

@ -0,0 +1,266 @@
"""Proactive refresh extract step: material scan + follow_ups/extends/updates (F2.0-F2.3)."""
import asyncio
import json
import time
from ...base_step import BaseStep
from .._evolve import agent_reply_result_text, passthrough_response
from ....components import R
from ....schema import ProactiveState
from ..dream.utils import daily_dir, pack_paths, today, workspace_dir
from .utils import (
clean_candidate,
load_carry_forward,
load_personal_profile_block,
load_state,
parse_extract_reply,
resolve_agent_wrapper,
scan_material_daily,
)
@R.register("proactive_extract_step")
class ProactiveExtractStep(BaseStep):
"""Scan the proactive material set and extract follow-ups, extends and updates.
Owns the ``proactive`` catalog watermark (never touches the dream catalog)
and the daily LLM budget. Short-circuits via ``context[skip_key]`` on
busy/budget/timeout per F4.3; early-exits with zero LLM calls when no new
evidence exists (F2.0).
"""
def __init__(
self,
scan_days: int = 2,
carry_forward_days: int = 14,
max_carry_forward_topics: int = 20,
llm_timeout_seconds: float = 300,
max_chars_per_file: int = 60000,
max_total_chars: int = 300000,
profile_max_chars: int = 1500,
extends_enabled: bool = True,
skip_key: str = "proactive_skip",
**kwargs,
):
super().__init__(**kwargs)
self.scan_days = max(int(scan_days), 1)
self.carry_forward_days = max(int(carry_forward_days), 1)
self.max_carry_forward_topics = max(int(max_carry_forward_topics), 0)
self.llm_timeout_seconds = float(llm_timeout_seconds)
self.max_chars_per_file = max(int(max_chars_per_file), 1000)
self.max_total_chars = max(int(max_total_chars), 0)
self.profile_max_chars = max(int(profile_max_chars), 0)
self.extends_enabled = bool(extends_enabled)
self.skip_key = skip_key
async def execute(self):
assert self.context is not None
if self.context.get(self.skip_key):
return passthrough_response(self, self.skip_key)
started = time.monotonic()
day = today(self, str(self.context.get("date", "") or ""))
ws = workspace_dir(self)
daily = daily_dir(self)
if self.file_catalog is None:
raise RuntimeError("proactive_extract_step requires file_catalog")
state = ProactiveState(
date=day,
daily_dir=daily,
workspace=str(ws),
scan_days=self.scan_days,
carry_forward_days=self.carry_forward_days,
)
self.logger.info(f"[{self.name}] start date={day} scan_days={self.scan_days} extends={self.extends_enabled}")
# 1) Material set M (F2.0) - all cheap, before any LLM work.
m_daily = scan_material_daily(ws, day, daily, self.scan_days)
# 2) Truth source + carry-forward (F1.3/F1.4).
state_file, needs_bootstrap = load_state(ws, daily)
carry_all, carry_prompt = await load_carry_forward(
ws,
state_file,
day,
self.carry_forward_days,
self.max_carry_forward_topics,
daily,
needs_bootstrap,
)
state.carry_forward_count = len(carry_all)
state.carry_forward_prompt = carry_prompt
# 3) Change detection against the proactive catalog watermark.
# Only daily notes are material: fresh resource uploads already flow
# into daily notes via auto_resource, so no separate resource scan.
existing = {}
for rel in m_daily:
try:
existing[rel] = (ws / rel).stat().st_mtime
except OSError as e:
self.logger.error(f"[{self.name}] stat failed on {rel}: {e}")
nodes = await self.file_catalog.get_nodes()
indexed = {n.path: n.st_mtime for n in nodes}
state.changed_paths = [rel for rel, mt in existing.items() if indexed.get(rel) != mt]
# mtime snapshot of what this round actually read; finish only
# checkpoints paths whose mtime still matches, so files modified while
# the LLM calls run stay "changed" for the next round (audit item 3).
state.changed_mtimes = {rel: existing[rel] for rel in state.changed_paths}
self.logger.info(
f"[{self.name}] material daily={len(m_daily)} "
f"changed={len(state.changed_paths)} carry_forward={len(carry_all)}",
)
# 4) Zero-consumption early exit (A4 row 1).
if not state.changed_paths:
state.early_exit = "no_new_evidence"
return self._finish(state, started, "Skipped: no new evidence; 0 LLM calls")
# 5) LLM channel (F4.4): structurally one reply per round plus at most
# one parse-failure retry, so no persistent budget is needed (v5 R5).
wrapper = resolve_agent_wrapper(self)
if wrapper is None:
state.early_exit = "no_agent_wrapper"
self.logger.warning(f"[{self.name}] no agent_wrapper available; skipping round")
return self._finish(state, started, "Skipped: no agent_wrapper configured")
# 6) One LLM call with sectioned output (A3); retry once on parse failure.
# meta is None when this round must NOT checkpoint (timeout, or two
# unusable replies): the material stays "changed" for the next round
# instead of being silently consumed on output that can never yield
# topics (audit #1).
meta = await self._extract_with_retry(wrapper, state, ws, day, daily)
if meta is None:
self._store(state)
return passthrough_response(self, self.skip_key)
self._clean_output(state, meta, set(m_daily), day)
answer = (
f"Extracted {len(state.follow_ups)} follow_up(s), {len(state.extends)} extend(s), "
f"{len(state.updates)} update(s) from {len(state.changed_paths)} changed file(s)"
)
return self._finish(state, started, answer)
def _build_messages(self, state: ProactiveState, ws, day: str, daily: str, material_blob: str) -> tuple[str, str]:
carry_forward_json = json.dumps(
[
{
"id": t.id,
"title": t.title,
"kind": t.kind,
"confidence": t.confidence,
"reason": t.reason,
"last_evidence_at": t.last_evidence_at,
"evidence": t.evidence,
}
for t in state.carry_forward_prompt
],
ensure_ascii=False,
)
changed = list(dict.fromkeys(state.changed_paths))
user_message = self.prompt_format(
"extract_user_message",
date=day,
changed_paths_json=json.dumps(changed, ensure_ascii=False),
carry_forward_json=carry_forward_json,
material_blob=material_blob,
profile_block=self._profile_block(ws),
extends=self.extends_enabled,
)
system_prompt = self.prompt_format(
"extract_system_prompt",
workspace_dir=str(ws),
daily_dir=daily,
extends=self.extends_enabled,
)
return user_message, system_prompt
def _profile_block(self, ws) -> str:
"""Digest-personal profile sketch; background knowledge, never material."""
digest_dir = str(self.config_value("digest_dir"))
return load_personal_profile_block(ws, digest_dir, self.profile_max_chars)
async def _extract_with_retry(self, wrapper, state, ws, day: str, daily: str) -> dict | None:
"""Returns parsed meta; None means skip without checkpointing."""
# Newest daily material first: when the total budget bites, the
# freshest evidence survives and the oldest files are omitted.
ordered = list(dict.fromkeys(state.changed_paths))[::-1]
material_blob = pack_paths(
ws,
ordered,
limit_per_file=self.max_chars_per_file,
max_total_chars=self.max_total_chars or None,
)
user_message, system_prompt = self._build_messages(state, ws, day, daily, material_blob)
raw = ""
for attempt in (1, 2):
raw = await self._reply(wrapper, state, user_message, system_prompt)
if raw is None:
self.context[self.skip_key] = {"reason": "llm_timeout"}
return None
meta = parse_extract_reply(raw)
if meta:
return meta
self.logger.warning(f"[{self.name}] parse failed on attempt {attempt}; raw={raw[:200]!r}")
self.logger.error(
f"[{self.name}] reply unusable after 2 attempts; keeping material un-checkpointed; " f"raw={raw[:200]!r}",
)
self.context[self.skip_key] = {"reason": "extract_parse_failed"}
return None
async def _reply(self, wrapper, state, user_message, system_prompt):
"""One timeout-wrapped reply; returns raw text or None on timeout."""
state.llm_calls += 1
try:
result = await asyncio.wait_for(
wrapper.reply(user_message, system_prompt=system_prompt),
timeout=self.llm_timeout_seconds,
)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
return None
return agent_reply_result_text(result)
def _clean_output(self, state: ProactiveState, meta: dict, allowed: set[str], day: str) -> None:
for raw in meta.get("follow_ups") or []:
if candidate := clean_candidate(raw, allowed, "follow_up", day):
state.follow_ups.append(candidate)
else:
state.dropped_missing += 1
if self.extends_enabled:
for raw in meta.get("extends") or []:
if candidate := clean_candidate(raw, allowed, "interest_extend", day):
state.extends.append(candidate)
else:
state.dropped_missing += 1
for raw in meta.get("updates") or []:
if not isinstance(raw, dict):
continue
topic_id = str(raw.get("id") or "").strip()
if not topic_id:
continue
action = str(raw.get("action") or "keep").strip()
if action not in ("keep", "update", "resolve"):
action = "keep"
state.updates.append(
{
"id": topic_id,
"action": action,
"evidence": str(raw.get("evidence") or "").strip()[:120],
"reason": str(raw.get("reason") or "").strip(),
"confidence": raw.get("confidence"),
},
)
def _store(self, state: ProactiveState) -> None:
assert self.context is not None
data = state.model_dump()
self.context["proactive"] = data
self.context.response.metadata["proactive"] = data
def _finish(self, state: ProactiveState, started: float, answer: str):
state.duration_ms = int((time.monotonic() - started) * 1000)
self._store(state)
self.context.response.success = True
self.context.response.answer = answer
self.logger.info(f"[{self.name}] finish answer={answer!r}")
return self.context.response

View file

@ -0,0 +1,211 @@
extract_system_prompt: |
You are the proactive discovery agent for a personal memory workspace.
You read recently changed conversation notes and maintain a small set of
user-interest topics. You never browse the web,
never write files, and never collect material yourself.
workspace_dir: {workspace_dir}
## Branch A - follow_ups (open loops)
Find unresolved matters the user may still want to close:
- questions that were asked but never answered;
- tasks that were started, interrupted, or postponed;
- commitments or plans mentioned without any follow-through yet.
Emit each as a `follow_ups` entry.
[extends]## Branch B - extends (interest boundary)
[extends]
[extends]Infer topics the user has NOT focused on yet, but that are plausibly
[extends]relevant to their recent work, using the conversation notes. These
[extends]guide future knowledge-source expansion; you only describe them, you
[extends]never collect anything.
[extends]Emit each as an `extends` entry.
## updates (carried-forward topics)
For every topic in carry_forward_topics, decide exactly one action:
- `keep`: still open but no new evidence today;
- `update`: new evidence appeared today; refresh evidence and confidence
(emit the re-scored confidence value);
- `resolve`: the conversation shows the matter is settled or done.
You must echo the given `id` unchanged. Never invent ids, and never emit an
update for a topic that is not in carry_forward_topics.
If two carry_forward topics turn out to be the same matter in different
words, keep the one with stronger evidence and `resolve` the other one
(note ``merged into <kept id>`` in reason).
Only `action=update` when the fresh evidence lives in a file listed in this
round's changed_paths; otherwise emit `keep` (updates citing anything else
are rejected downstream).
## Confidence rubric (fixed; do not freestyle scores)
- 0.9: explicit unfinished action with a time or commitment;
- 0.7: the user stated intent explicitly but no concrete action yet;
- 0.5: inferential link from several weak signals;
- 0.3: weak association only;
- below 0.3: do not output the topic at all.
## Hard rules
- Empty lists are a normal, expected output. Never invent topics to fill
space, and never promote a one-off weak mention into a topic.
- Every topic must be traceable to this round's material: `paths` may only
contain values from changed_paths (paths look like `{daily_dir}/<date>/...`);
entries violating this are discarded.
- Do not restate topics already listed in carry_forward_topics; use
`updates` to change their status instead.
- Same-matter rule: if a finding is really a carry_forward topic in
different words, or new progress/angle on it, emit `updates` with
action=update for that id - never open it as a new topic.
Example: carry_forward_topics has "explainability evaluation of memory
search" and the material says the evaluation dataset is now ready ->
update that topic; do NOT open "run the benchmark" as a new topic. Open
a new entry only for matters no carry_forward topic covers.
- Quote YAML strings containing punctuation such as `:`; write paths as
block lists so `{daily_dir}/<date>/...` stays parseable.
## Output format
Return only one YAML fenced block with this exact shape:
```yaml
follow_ups:
- title: <specific unresolved matter>
reason: <why it is still open>
confidence: 0.7
paths: [<changed path>, ...]
[extends]extends:
[extends] - title: <related-but-unfocused topic>
[extends] reason: <why it matters, grounded in the material>
[extends] confidence: 0.5
[extends] paths: [<changed path>, ...]
updates:
- id: <echoed carry-forward id>
action: keep|update|resolve
evidence: <path or path#anchor>
reason: <short justification>
confidence: <re-scored per rubric; emit for update, omit otherwise>
```
extract_system_prompt_zh: |
你是个人记忆 workspace 的 proactive 发现 agent。你阅读近期变化的对话笔记
,维护一小组用户兴趣主题。你绝不联网、
绝不写文件、绝不自行收集资料。
workspace_dir: {workspace_dir}
## 分支 A - follow_ups未决事项 / open loop
找出用户可能仍想关闭的未决事项:
- 提出过但没有得到回答的问题;
- 开始过但被中断或搁置的任务;
- 提到过但没有后续动作的承诺或计划。
每条作为一个 `follow_ups` 条目输出。
[extends]## 分支 B - extends兴趣边界扩展
[extends]
[extends]推断用户尚未关注、但与其近期工作大概率相关的主题,依据是对话笔记
[extends]。它们用于指引未来的知识源扩展;你只描述主题,不收集任何资料。
[extends]每条作为一个 `extends` 条目输出。
## updatescarry-forward 主题处置)
对 carry_forward_topics 中的每个主题,恰好选择一个动作:
- `keep`:仍然 open但今天没有新证据
- `update`:今天出现新证据;刷新 evidence 与 confidence输出按 rubric
重新打分的 confidence 值);
- `resolve`:对话显示事项已解决或完成。
必须原样回显给定的 `id`。禁止伪造 id禁止对不在 carry_forward_topics
中的主题输出 update。
若 carry_forward_topics 中有两个主题实为同一件事的不同表述,保留证据较强
的一个,对另一个输出 `resolve`reason 注明 merged into <保留的 id>)。
仅当新证据出自本轮 changed_paths 列表中的文件时才输出 action=update
否则输出 keep引用其他文件的 update 会在下游被拒绝并降级为 keep
## confidence 评分规则(固化,禁止自由打分)
- 0.9:有明确未完成动作且带时间/承诺;
- 0.7:用户显式表达意图但无明确动作;
- 0.5:推断性关联(多条弱信号);
- 0.3:弱联想;
- 低于 0.3:不要输出该主题。
## 硬约束
- 空列表是正常且预期的输出。不得为凑数编造主题,不得把一次性弱提及
拔高为主题。
- 每个主题必须能追溯到本轮素材:`paths` 只能使用 changed_paths 中的值
(路径形如 `{daily_dir}/<date>/...`),越界条目会被丢弃。
- 不要复述 carry_forward_topics 中已 open 的主题;要改变其状态请用
`updates`。
- 同一件事规则:若新发现其实只是某个 carry_forward 主题的换种说法,
或是它的新进展/新角度,必须对该 id 输出 action=update 的 `updates`
条目禁止把它当作新主题另开条目。例carry_forward_topics 已有
"记忆检索的可解释性评估",素材提到评测数据集已就绪 -> 更新该主题,
不得新开"运行 benchmark"主题。仅当 carry_forward 主题均未覆盖时,
才可新开主题。
- 包含 `:` 等标点的 YAML 字符串请加引号paths 使用 block list
避免 `{daily_dir}/<date>/...` 解析失败。
## 输出格式
只返回一个 YAML fenced block结构必须严格如下
```yaml
follow_ups:
- title: <具体的未决事项>
reason: <为什么仍未关闭>
confidence: 0.7
paths: [<素材路径>, ...]
[extends]extends:
[extends] - title: <相关但未被关注的主题>
[extends] reason: <为什么重要,基于素材>
[extends] confidence: 0.5
[extends] paths: [<素材路径>, ...]
updates:
- id: <回显的 carry-forward id>
action: keep|update|resolve
evidence: <路径或 路径#锚点>
reason: <简短理由>
confidence: <按 rubric 重新打分action=update 时给出,其余可省略>
```
extract_user_message: |
date: {date}
changed_paths (the ONLY allowed values for `paths`):
{changed_paths_json}
carry_forward_topics (already open; only act on them via updates):
{carry_forward_json}
user_profile (background knowledge about the user's identity, preferences
and constraints; use it ONLY to judge interest relevance and confidence -
it is NOT material, never cite it in paths/evidence):
{profile_block}
# Material
{material_blob}
Extract follow_ups[extends] / extends / updates per the contract. Remember:
empty lists are fine; never invent topics.
extract_user_message_zh: |
日期:{date}
changed_paths`paths` 唯一允许的取值):
{changed_paths_json}
carry_forward_topics已 open 的主题;只能通过 updates 处置):
{carry_forward_json}
用户画像(用户身份、偏好与约束的背景知识;仅用于判断兴趣相关度与
confidence——它不是素材禁止引用进 paths/evidence
{profile_block}
# 本轮素材
{material_blob}
按契约抽取 follow_ups[extends] / extends / updates。记住空列表是正常
输出,绝不编造主题。

View file

@ -0,0 +1,79 @@
"""Proactive finish step: checkpoint only the proactive catalog (F3)."""
from ...base_step import BaseStep
from .._evolve import passthrough_response
from ....components import R
from ....schema import FileNode, ProactiveState
from ..dream.utils import workspace_dir
@R.register("proactive_finish_step")
class ProactiveFinishStep(BaseStep):
"""Upsert this round's changed material into the proactive catalog.
interests.yaml is deliberately NOT checkpointed (v5 R6): the catalog only
serves change-detection watermarking and interests.yaml is excluded from
the material set (INV-11), so no consumer would ever read it back.
Never calls ``refresh_day_index`` and never touches the dream catalog
(INV-2). When the short-circuit flag is set, no checkpoint happens so the
same material stays "changed" for the next round (INV-7).
"""
def __init__(self, persist: bool = True, skip_key: str = "proactive_skip", **kwargs):
super().__init__(**kwargs)
self.persist = persist
self.skip_key = skip_key
async def execute(self):
assert self.context is not None
if self.context.get(self.skip_key):
return passthrough_response(self, self.skip_key)
if self.file_catalog is None:
raise RuntimeError("proactive_finish_step requires file_catalog")
raw_state = self.context.get("proactive")
if not raw_state:
self.context.response.success = True
self.context.response.answer = "Skipped finish: no proactive extract state in context"
return self.context.response
state = ProactiveState.model_validate(raw_state)
if state.early_exit:
self.context.response.success = True
self.context.response.answer = f"Skipped finish: {state.early_exit}"
return self.context.response
ws = workspace_dir(self)
checkpoint = [rel for rel in state.changed_paths if (ws / rel).is_file()]
snapshot = state.changed_mtimes
nodes: list[FileNode] = []
deferred = 0
for rel in dict.fromkeys(checkpoint):
try:
mtime = (ws / rel).stat().st_mtime
except OSError:
continue
if rel in snapshot and mtime != snapshot[rel]:
# Modified while extract/plan/agenda were running: the new
# content never reached this round's prompt, so leave the path
# un-checkpointed for the next round (audit item 3).
deferred += 1
continue
nodes.append(FileNode(path=rel, st_mtime=mtime))
self.logger.info(
f"[{self.name}] start checkpoint={len(nodes)} deferred={deferred} persist={self.persist}",
)
if nodes:
await self.file_catalog.upsert(nodes)
if self.persist and nodes:
await self.file_catalog.dump()
state.checkpoint_paths = [n.path for n in nodes]
data = state.model_dump()
self.context["proactive"] = data
self.context.response.metadata["proactive"] = data
self.context.response.success = True
answer = f"Proactive finished: checkpointed {len(nodes)} path(s)"
if deferred:
answer += f", deferred {deferred} path(s) modified during the round"
self.context.response.answer = answer
self.logger.info(f"[{self.name}] finish checkpointed={len(nodes)}")
return self.context.response

View file

@ -0,0 +1,246 @@
"""Proactive plan step: expand push candidates into scenario cards (F2.5).
Runs between the topics and agenda steps. Only topics that qualify for today's
push (``first_seen == today`` and ``confidence >= min_push_confidence``,
computed by the topics step into ``state.push_candidates``) are expanded, so
the LLM cost stays proportional to what will actually be surfaced. One batched
LLM call per round; on any LLM failure every selected candidate still gets a
deterministic fallback card so the agenda step never sees an empty input.
Card contract: ``scenario_type`` (resume_task | answer_pending |
explore_interest | prepare_upcoming), ``opener`` (a casual, friend-like
conversation opener that already names the minimal next action - never a
"based on your records" notification tone), ``next_action``, ``preconditions``
and ``delivery`` (in_conversation | notification | agenda_item).
``linked_memory`` is derived from the topic's paths/evidence by code, never by
the LLM.
"""
import asyncio
import json
import time
from ...base_step import BaseStep
from .._evolve import agent_reply_result_text, passthrough_response
from ....components import R
from ....schema import ProactiveState
from ..dream.utils import workspace_dir
from .utils import load_personal_profile_block, parse_plan_reply, resolve_agent_wrapper
SCENARIO_TYPES = ("resume_task", "answer_pending", "explore_interest", "prepare_upcoming")
DELIVERY_MODES = ("in_conversation", "notification", "agenda_item")
DEFAULT_SCENARIO_BY_KIND = {"follow_up": "resume_task", "interest_extend": "explore_interest"}
MAX_OPENER_CHARS = 300
MAX_NEXT_ACTION_CHARS = 200
MAX_PRECONDITIONS = 5
MAX_PRECONDITION_CHARS = 80
def linked_memory(candidate: dict) -> list[str]:
"""Workspace paths related to a topic, derived from paths + evidence."""
out: list[str] = []
raw = list(candidate.get("paths") or [])
evidence = str(candidate.get("evidence") or "").strip()
if evidence:
raw.append(evidence)
for path in raw:
rel = str(path or "").strip().split("#", 1)[0]
if rel and rel not in out:
out.append(rel)
return out
def fallback_card(candidate: dict) -> dict:
"""Deterministic card used when the LLM is absent or its output is unusable."""
kind = str(candidate.get("kind") or "interest_extend")
title = str(candidate.get("title") or "")
scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, "explore_interest")
if kind == "follow_up":
opener = f"上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?"
next_action = "先回顾相关记录,确认卡点,然后给出下一步动作"
else:
opener = f"感觉你最近可能会想看看「{title}」,有空的话可以先从相关材料扫一眼。"
next_action = "快速浏览相关材料,判断值不值得深入"
memory = linked_memory(candidate)
if memory:
next_action = f"{next_action}(材料:{memory[0]}"
return {
"scenario_type": scenario,
"opener": opener,
"next_action": next_action,
"preconditions": [],
"delivery": "in_conversation",
}
def _clean_card(raw: dict, candidate: dict) -> dict:
"""Validate one LLM card; per-field fallback keeps the card always usable."""
base = fallback_card(candidate)
scenario = str(raw.get("scenario_type") or "").strip()
if scenario not in SCENARIO_TYPES:
scenario = base["scenario_type"]
opener = str(raw.get("opener") or "").strip()[:MAX_OPENER_CHARS]
if not opener:
opener = base["opener"]
next_action = str(raw.get("next_action") or "").strip()[:MAX_NEXT_ACTION_CHARS]
if not next_action:
next_action = base["next_action"]
preconditions = raw.get("preconditions")
if not isinstance(preconditions, list):
preconditions = []
preconditions = [str(item).strip()[:MAX_PRECONDITION_CHARS] for item in preconditions if str(item).strip()]
delivery = str(raw.get("delivery") or "").strip()
if delivery not in DELIVERY_MODES:
delivery = base["delivery"]
return {
"scenario_type": scenario,
"opener": opener,
"next_action": next_action,
"preconditions": preconditions[:MAX_PRECONDITIONS],
"delivery": delivery,
}
@R.register("proactive_plan_step")
class ProactivePlanStep(BaseStep):
"""Expand today's push candidates into scenario cards.
Zero LLM calls when there are no push candidates. At most
``max_plan_topics`` candidates are expanded; the overflow stays card-less
and the agenda step suppresses it with an explicit reason.
"""
def __init__(
self,
max_plan_topics: int = 6,
llm_timeout_seconds: float = 120,
profile_max_chars: int = 800,
skip_key: str = "proactive_skip",
**kwargs,
):
super().__init__(**kwargs)
self.max_plan_topics = max(int(max_plan_topics), 1)
self.llm_timeout_seconds = float(llm_timeout_seconds)
self.profile_max_chars = max(int(profile_max_chars), 0)
self.skip_key = skip_key
async def execute(self):
assert self.context is not None
if self.context.get(self.skip_key):
return passthrough_response(self, self.skip_key)
started = time.monotonic()
raw_state = self.context.get("proactive")
if not raw_state:
self.context.response.success = True
self.context.response.answer = "Skipped plan: no proactive extract state in context"
return self.context.response
state = ProactiveState.model_validate(raw_state)
if state.early_exit:
self.context.response.success = True
self.context.response.answer = f"Skipped plan: {state.early_exit}"
return self.context.response
candidates = [c for c in state.push_candidates if isinstance(c, dict) and c.get("id")]
if not candidates:
state.scenario_cards = []
self._store(state)
self.context.response.success = True
self.context.response.answer = "Plan: no push candidates, 0 cards, 0 LLM calls"
self.logger.info(f"[{self.name}] no push candidates; skipping")
return self.context.response
selected = candidates[: self.max_plan_topics]
self.logger.info(
f"[{self.name}] start candidates={len(candidates)} selected={len(selected)} "
f"max_plan_topics={self.max_plan_topics}",
)
cards_by_id = await self._llm_cards(state, selected)
cards: list[dict] = []
fallbacks = 0
for candidate in selected:
cid = str(candidate.get("id"))
raw_card = cards_by_id.get(cid)
if raw_card is None:
fallbacks += 1
card = _clean_card(raw_card, candidate) if raw_card is not None else fallback_card(candidate)
card = {
"topic_id": cid,
"title": str(candidate.get("title") or ""),
"kind": str(candidate.get("kind") or "interest_extend"),
**card,
"linked_memory": linked_memory(candidate),
}
cards.append(card)
state.scenario_cards = cards
state.duration_ms = state.duration_ms + int((time.monotonic() - started) * 1000)
self._store(state)
answer = f"Plan: {len(cards)} scenario card(s), fallback={fallbacks}, llm_calls={state.plan_llm_calls}"
self.context.response.success = True
self.context.response.answer = answer
self.logger.info(f"[{self.name}] finish {answer}")
return self.context.response
async def _llm_cards(self, state: ProactiveState, selected: list[dict]) -> dict[str, dict]:
"""One batched LLM call; returns candidate-id -> raw card dict."""
wrapper = resolve_agent_wrapper(self)
if wrapper is None:
self.logger.warning(f"[{self.name}] no agent_wrapper available; using fallback cards")
return {}
selected_ids = {str(c.get("id")) for c in selected}
ws = workspace_dir(self)
user_message = self.prompt_format(
"plan_user_message",
profile_block=self._profile_block(ws),
candidates_json=json.dumps(
[
{
"id": c.get("id"),
"title": c.get("title"),
"kind": c.get("kind"),
"reason": c.get("reason"),
"confidence": c.get("confidence"),
"first_seen": c.get("first_seen"),
"evidence": c.get("evidence"),
}
for c in selected
],
ensure_ascii=False,
),
)
system_prompt = self.prompt_format("plan_system_prompt")
state.plan_llm_calls += 1
try:
result = await asyncio.wait_for(
wrapper.reply(user_message, system_prompt=system_prompt),
timeout=self.llm_timeout_seconds,
)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
return {}
except Exception as e: # noqa: BLE001 - network/provider errors degrade to fallback cards
self.logger.warning(
f"[{self.name}] LLM reply failed ({type(e).__name__}: {e}); using fallback cards",
)
return {}
raw = agent_reply_result_text(result)
cards_by_id: dict[str, dict] = {}
for card in parse_plan_reply(raw):
cid = str(card.get("topic_id") or "").strip()
if cid in selected_ids and cid not in cards_by_id:
cards_by_id[cid] = card
if not cards_by_id:
self.logger.warning(f"[{self.name}] plan reply unusable; raw={raw[:200]!r}")
return cards_by_id
def _profile_block(self, ws) -> str:
"""Digest-personal profile sketch so openers respect user preferences."""
digest_dir = str(self.config_value("digest_dir"))
return load_personal_profile_block(ws, digest_dir, self.profile_max_chars)
def _store(self, state: ProactiveState) -> None:
assert self.context is not None
data = state.model_dump()
self.context["proactive"] = data
self.context.response.metadata["proactive"] = data

View file

@ -0,0 +1,108 @@
plan_system_prompt: |
You are the conversation-opportunity designer of a personal memory system.
You receive the proactive topics selected for today's push. For EACH topic
produce one scenario card so an assistant can raise it naturally in the next
conversation - and guide the user toward a concrete next step, not merely
inform them about the topic.
## Fields per card
- `topic_id`: echo the given id unchanged.
- `scenario_type`: exactly one of
- `resume_task`: a task that was started but interrupted/postponed;
- `answer_pending`: a question that was asked but never answered;
- `explore_interest`: an interest the user has not dived into yet;
- `prepare_upcoming`: something with a future time anchor to prepare for.
follow_up topics become resume_task / answer_pending / prepare_upcoming;
interest_extend topics become explore_interest / prepare_upcoming. Pick by
semantics; use a time anchor whenever the topic carries one.
- `opener`: one casual spoken sentence, like a friend happening to mention
it ("by the way..."). STRICTLY forbidden: notification tone such as
"based on your previous records", "the system reminds you", "as you
mentioned on <date>". The opener must already point at the minimal
executable next action, so it invites doing, not just knowing.
- `next_action`: the smallest concrete step the user can start right now.
- `preconditions`: list of things that must hold first; empty list is fine.
- `delivery`: `in_conversation` (weave into talk), `notification`
(standalone nudge) or `agenda_item` (belongs on today's agenda).
## Hard rules
- Exactly one card per input topic, no extras.
- Ground everything in the given reason/evidence; never invent facts.
- Quote YAML strings containing punctuation such as `:`.
## Output format
Return only one YAML fenced block:
```yaml
cards:
- topic_id: <echoed id>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming
opener: <one casual sentence pointing at the next action>
next_action: <smallest executable step>
preconditions: [<condition>, ...]
delivery: in_conversation|notification|agenda_item
```
plan_system_prompt_zh: |
你是个人记忆系统的"对话机会设计师"。输入是今天被选中推送的 proactive
主题。为每个主题产出一张场景卡,让助手能在下一次对话里自然地把话题提起
——并引导用户迈出具体下一步,而不只是告知话题存在。
## 每张卡的字段
- `topic_id`:原样回显给定 id。
- `scenario_type`:四选一
- `resume_task`:开始过但被中断/搁置的任务;
- `answer_pending`:提出过但没有得到回答的问题;
- `explore_interest`:用户尚未深入的兴趣方向;
- `prepare_upcoming`:带未来时间点、需要提前准备的事项。
follow_up 主题对应 resume_task / answer_pending / prepare_upcoming
interest_extend 主题对应 explore_interest / prepare_upcoming。按语义选择
主题带时间线索时优先 prepare_upcoming。
- `opener`:一句口语化的开场,像朋友随口提起("对了,上次那个……")。
严禁通知腔:"根据您之前的记录"、"系统提醒您"、"您在某天提到过"等。
开场必须顺势给出最小可执行的下一步,让人愿意动手,而不只是知道。
- `next_action`:用户现在就能开始的最小具体动作。
- `preconditions`:需要先满足的条件列表,可以为空。
- `delivery``in_conversation`(对话中自然带出)/ `notification`
(单独提醒)/ `agenda_item`(列入当日议程)。
## 硬约束
- 每个输入主题恰好一张卡,不得多给。
- 所有内容必须基于给定的 reason/evidence禁止编造事实。
- 包含 `:` 等标点的 YAML 字符串请加引号。
## 输出格式
只返回一个 YAML fenced block
```yaml
cards:
- topic_id: <回显的 id>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming
opener: <一句口语化开场,顺势给出下一步动作>
next_action: <最小可执行步骤>
preconditions: [<条件>, ...]
delivery: in_conversation|notification|agenda_item
```
plan_user_message: |
user_profile (background: let it tune the opener's language and tone; it is
NOT material, never cite it):
{profile_block}
push_candidates (one scenario card per topic):
{candidates_json}
Emit the `cards` block per the contract.
plan_user_message_zh: |
用户画像(背景信息:用于把握开场白的语言与语气;它不是素材,禁止引用):
{profile_block}
推送候选主题(每个主题一张场景卡):
{candidates_json}
按契约输出 `cards` block。

View file

@ -0,0 +1,204 @@
"""Read interests.yaml for proactive consumption (F5).
Migrated from ``dream/proactive.py`` (INV-9 mechanical migration) and extended
with ``min_confidence`` filtering (default 0.4, safely below the 0.5 confidence
fallback) and ``horizon_days``: horizon=1 keeps the legacy single-day file
read (v1 compatible); horizon>1 reads the truth source directly, filtering by
``last_evidence_at`` recency (v5 R4 - the truth source already carries the
cross-day merge, so the reader no longer re-scans exposure products).
"""
import datetime as dt
import yaml
from ...base_step import BaseStep
from ....components import R
from ....schema import ProactiveResult
from ..dream.utils import today, workspace_dir
from .utils import (
dump_topic,
load_yaml_topics,
load_state,
parse_interests_topics,
sort_topics,
state_file_path,
topic_id,
)
@R.register("proactive_step")
class ProactiveStep(BaseStep):
"""Read ``daily/<date>/interests.yaml`` (optionally merged over a horizon).
- v1 files (nightly) are read as ``push=true`` and never rewritten;
- ``push=false`` days contribute nothing;
- resolved ids (truth source registry) are suppressed;
- ``horizon_days>1`` reads the truth source and filters by evidence recency.
"""
def __init__(self, include_content: bool = True, horizon_days: int = 1, min_confidence: float = 0.4, **kwargs):
super().__init__(**kwargs)
self.include_content = include_content
self.horizon_days = max(int(horizon_days), 1)
self.min_confidence = float(min_confidence)
async def execute(self):
assert self.context is not None
day = today(self, str(self.context.get("date", "") or ""))
include_content = bool(self.context.get("include_content", self.include_content))
horizon = int(self.context.get("horizon_days", self.horizon_days) or self.horizon_days)
horizon = max(horizon, 1)
raw_min_confidence = self.context.get("min_confidence", self.min_confidence)
try:
min_confidence = float(raw_min_confidence)
except (TypeError, ValueError):
min_confidence = self.min_confidence
daily = self.config_value("daily_dir")
ws = workspace_dir(self)
default_rel = f"{daily}/{day}/interests.yaml"
result = ProactiveResult(date=day, path=default_rel)
self.logger.info(
f"[{self.name}] start date={day} path={default_rel} include_content={include_content} "
f"horizon_days={horizon} min_confidence={min_confidence}",
)
if horizon == 1:
outcome = self._read_single(ws, daily, day, include_content, min_confidence, result)
if outcome is not None:
return outcome
return self._read_horizon(ws, daily, day, horizon, include_content, min_confidence, result)
# ------------------------------------------------------------------
# Single-day read (legacy-compatible path)
# ------------------------------------------------------------------
def _read_single(self, ws, daily, day, include_content, min_confidence, result: ProactiveResult):
rel_path = f"{daily}/{day}/interests.yaml"
abs_path = ws / daily / day / "interests.yaml"
if not abs_path.is_file():
result.skipped, result.summary = True, f"Skipped: interests file not found at {rel_path}"
self.logger.info(f"[{self.name}] skip missing path={rel_path}")
return self._finish(True, result, include_content=include_content)
try:
raw_text = abs_path.read_text(encoding="utf-8")
# The reader stays strictly read-only: a corrupt file is never
# moved or renamed here; the parse failure propagates to the
# outer handler and surfaces as success=False so callers can see
# it instead of a silent empty read (audit item 2).
data = yaml.safe_load(raw_text)
if not isinstance(data, dict):
raise ValueError("interests.yaml is not a mapping")
topics, is_v1, push = parse_interests_topics(data, day)
if push is False:
result.skipped = True
result.summary = f"Skipped: interests file at {rel_path} has push=false"
result.push = False
self.logger.info(f"[{self.name}] skip push=false path={rel_path}")
return self._finish(True, result, include_content=include_content)
result.push = True
result.content = raw_text if include_content else ""
if is_v1:
# Legacy shape (title/reason/evidence/keywords/paths) for v1 files;
# v1 topics carry no confidence and fall back to 0.5 for filtering.
resolved_ids = self._resolved_ids(ws, daily)
legacy_topics = load_yaml_topics(abs_path)
kept = [
{k: v for k, v in t.items() if k != "keywords"}
for t in legacy_topics
if topic_id(str(t.get("title") or "")) not in resolved_ids
]
if min_confidence > 0.5 + 1e-9: # v1 topics carry no confidence; fallback is 0.5 (F1.1)
kept = []
result.topics = kept
else:
result.generated_at = str(data.get("generated_at") or "")
resolved_ids = self._resolved_ids(ws, daily)
kept = [
dump_topic(t)
for t in sort_topics(topics)
if t.id not in resolved_ids and t.confidence >= min_confidence - 1e-9
]
result.topics = kept
agenda_raw = data.get("agenda")
if isinstance(agenda_raw, list):
kept_ids = {str(t.get("id") or "") for t in kept}
result.agenda = [
item
for item in agenda_raw
if isinstance(item, dict) and str(item.get("topic_id") or "") in kept_ids
]
result.summary = f"Read {len(result.topics)} proactive topic(s) from {rel_path}"
self.logger.info(f"[{self.name}] read done path={rel_path} topics={len(result.topics)}")
return self._finish(True, result, include_content=include_content)
except Exception as e: # noqa: BLE001
result.error, result.summary = f"{type(e).__name__}: {e}", ""
self.logger.error(f"[{self.name}] read failed path={rel_path}: {result.error}")
return self._finish(False, result, include_content=include_content)
# ------------------------------------------------------------------
# Multi-day merge (horizon_days > 1, or fallback target)
# ------------------------------------------------------------------
def _read_horizon(self, ws, daily, day, horizon, include_content, min_confidence, result: ProactiveResult):
"""Truth-source view (v5 R4): open topics with evidence inside the horizon.
The truth source already carries the cross-day merge (carry-forward),
so the reader no longer re-scans N days of exposure products.
"""
state_file, _needs_bootstrap = load_state(ws, daily)
if include_content:
try:
result.content = state_file_path(ws, daily).read_text(encoding="utf-8")
except OSError:
result.content = ""
if not state_file.open_topics:
result.skipped = True
result.summary = f"Skipped: truth source has no open topics (horizon_days={horizon})"
self.logger.info(f"[{self.name}] skip empty truth source horizon={horizon}")
return self._finish(True, result, include_content=include_content)
try:
base = dt.date.fromisoformat(day)
cutoff = (base - dt.timedelta(days=max(horizon - 1, 0))).isoformat()
upper_bound = base.isoformat()
except ValueError:
cutoff = ""
upper_bound = ""
kept = [
dump_topic(t)
for t in state_file.open_topics
if (
(not cutoff or cutoff <= str(t.last_evidence_at or "") <= upper_bound)
and t.confidence >= min_confidence - 1e-9
)
]
kept = sort_topics(kept)
result.path = f"{daily}/_proactive.yaml"
result.topics = kept
result.summary = f"Read {len(kept)} proactive topic(s) from the truth source (horizon_days={horizon})"
self.logger.info(f"[{self.name}] truth-source read done horizon={horizon} topics={len(kept)}")
return self._finish(True, result, include_content=include_content)
def _resolved_ids(self, ws, daily: str) -> set[str]:
state_file, _needs_bootstrap = load_state(ws, daily)
return {str(r.get("id") or "") for r in state_file.resolved if isinstance(r, dict) and r.get("id")}
def _finish(self, success: bool, result: ProactiveResult, *, include_content: bool):
assert self.context is not None
self.context.response.success = success
if not success:
self.context.response.answer = f"Error: {result.error}"
elif result.skipped:
self.context.response.answer = result.summary
else:
self.context.response.answer = {
"summary": result.summary,
"topics": result.topics,
**({"agenda": result.agenda} if result.agenda else {}),
**({"content": result.content} if include_content else {}),
}
self.context.response.metadata.update(result.model_dump())
self.logger.info(f"[{self.name}] finish success={success} answer={self.context.response.answer!r}")
return self.context.response

View file

@ -0,0 +1,462 @@
"""Proactive topics step: dedup, truth-source update, derived push, render (F2.4).
Pure computation step (v5 R2): no LLM calls, no budget, no agent_wrapper.
Semantic dedup keeps only the ``>= known_threshold`` "known" drop; everything
below is kept (loose-not-leaky). The main dedup defense lives upstream in the
extract prompt's same-matter rule plus same-id merging here.
``known_threshold`` is bound to the embedding model, dimensions AND vector-text
scheme it was calibrated for (v5.2: ``titlereason`` texts, threshold 0.85,
34-pair measurement: DUP band 0.773-0.943 vs KEEP band <=0.772). Cosine
magnitudes are not comparable across models, so a fingerprint mismatch degrades
the gate to exact normalize comparison instead of silently misfiring. Without
any configured embedder the step skips the semantic gate entirely and the
workflow continues on exact comparison (BM25-only deployments).
"""
import datetime as dt
import re
import time
from ...base_step import BaseStep
from .._evolve import passthrough_response
from ....components import R
from ....enumeration import ComponentEnum
from ....schema import ProactiveState, ProactiveStateFile, ProactiveTopic
from ....schema.proactive import clamp_confidence
from ..dream.utils import daily_dir, previous_dates, today, workspace_dir
from .utils import (
current_now,
dump_topic,
interests_path_for,
load_state,
norm_path,
normalize_topic,
parse_interests_topics,
read_interests_data,
render_interests,
save_state,
sort_topics,
trim_state_file,
write_interests_if_changed,
)
@R.register("proactive_topics_step")
class ProactiveTopicsStep(BaseStep):
"""Filter candidates, update the truth source, and render interests.yaml.
Without ``as_embedding`` the exact ``normalize_topic`` comparison applies.
With embeddings, ``sim >= known_threshold`` drops as known; below the
threshold candidates are kept. Candidates whose id matches a resolved
tombstone are resurrected (tombstone removed, original ``first_seen``
kept) instead of being silently suppressed.
"""
def __init__(
self,
known_threshold: float = 0.85,
min_push_confidence: float = 0.5,
max_topics: int = 10,
dedup_lookback_days: int = 7,
digest_compare_limit: int = 500,
known_threshold_calibrated_for: str = "text-embedding-v4@1024",
skip_key: str = "proactive_skip",
**kwargs,
):
super().__init__(**kwargs)
self.known_threshold = float(known_threshold)
self.known_threshold_calibrated_for = str(known_threshold_calibrated_for or "")
self.min_push_confidence = float(min_push_confidence)
self.max_topics = max(int(max_topics), 1)
self.dedup_lookback_days = max(int(dedup_lookback_days), 0)
self.digest_compare_limit = max(int(digest_compare_limit), 0)
self.skip_key = skip_key
# pylint: disable=too-many-statements
async def execute(self):
assert self.context is not None
if self.context.get(self.skip_key):
return passthrough_response(self, self.skip_key)
started = time.monotonic()
if not self.context.get("proactive"):
self.context.response.success = True
self.context.response.answer = "Skipped topics: no proactive extract state in context"
return self.context.response
state = ProactiveState.model_validate(self.context.get("proactive"))
if state.early_exit:
self.context.response.success = True
self.context.response.answer = f"Skipped topics: {state.early_exit}"
return self.context.response
day = state.date or today(self, str(self.context.get("date", "") or ""))
ws = workspace_dir(self)
daily = state.daily_dir or daily_dir(self)
self.logger.info(
f"[{self.name}] start date={day} follow_ups={len(state.follow_ups)} extends={len(state.extends)} "
f"updates={len(state.updates)} carry_forward={state.carry_forward_count}",
)
state_file, _needs_bootstrap = load_state(ws, daily)
open_by_id = {t.id: t for t in state_file.open_topics if t.id}
expiry_cutoff = self._expiry_cutoff(day, max(state.carry_forward_days, 1))
# 1) Apply updates to the truth source (F2.3). An action=update is only
# legal when its evidence anchors this round's new material (v5.2 hard
# check, same shape as INV-8); otherwise it degrades to keep so stale
# re-reads cannot rewrite evidence or refresh the freshness sort key.
new_material = {norm_path(str(path).split("#", 1)[0]) for path in state.changed_paths}
for update in state.updates:
topic = open_by_id.get(str(update.get("id") or ""))
if topic is None:
continue
action = str(update.get("action") or "keep")
if action == "update":
evidence_raw = str(update.get("evidence") or "").strip()
evidence_path = norm_path(evidence_raw.split("#", 1)[0])
if evidence_path in new_material:
topic.last_evidence_at = self._evidence_date(evidence_path) or day
topic.evidence = evidence_raw[:120]
if update.get("confidence") is not None:
topic.confidence = clamp_confidence(update.get("confidence"))
state.updates_applied += 1
else:
self.logger.info(
f"[{self.name}] update for {topic.id} downgraded to keep: evidence "
f"{evidence_path or '<empty>'!r} not in this round's new material",
)
elif action == "resolve":
state_file.open_topics = [t for t in state_file.open_topics if t.id != topic.id]
del open_by_id[topic.id]
state_file.resolved.append(
{
"id": topic.id,
"title": topic.title,
"resolved_at": day,
"first_seen": topic.first_seen,
"evidence": str(update.get("evidence") or "")[:120],
},
)
state.updates_resolved += 1
# 2) Candidates: same-id merge, tombstone resurrect, dedup the rest.
raw_candidates = list(state.follow_ups) + list(state.extends)
state.candidates_in = len(raw_candidates)
merged: list[dict] = []
resurrected: list[dict] = []
fresh: list[dict] = []
tombstone_by_id = {
str(r.get("id") or ""): r for r in state_file.resolved if isinstance(r, dict) and r.get("id")
}
for candidate in raw_candidates:
cid = str(candidate.get("id") or "")
existing = open_by_id.get(cid)
if existing is not None:
if expiry_cutoff and str(existing.first_seen or "") <= expiry_cutoff:
# Over-age re-mention: trim would prune it this round, so a
# fresh mention restarts the lifetime (unfinished business
# must keep being re-executed, v5.1).
self.logger.info(
f"[{self.name}] over-age topic {cid} re-mentioned; restarting first_seen "
f"({existing.first_seen} -> {day})",
)
existing.first_seen = day
self._merge_into(existing, candidate, day)
merged.append(candidate)
continue
tombstone = tombstone_by_id.get(cid)
if tombstone is not None:
topic = self._resurrect(tombstone, candidate, day)
state_file.resolved = [r for r in state_file.resolved if r is not tombstone]
del tombstone_by_id[cid]
state_file.open_topics.append(topic)
open_by_id[cid] = topic
resurrected.append(candidate)
self.logger.info(f"[{self.name}] resurrected resolved topic {cid} ({topic.title!r})")
continue
fresh.append(candidate)
dropped_duplicate = dropped_known = 0
survivors: list[dict] = []
seen_normalized: set[str] = set()
if fresh:
comparison = await self._comparison_texts(ws, daily, day, state_file, expiry_cutoff)
embedder = self._resolve_embedding()
if embedder is not None and self.known_threshold_calibrated_for:
fingerprint = self._embedding_fingerprint(embedder)
if fingerprint != self.known_threshold_calibrated_for:
self.logger.warning(
f"[{self.name}] embedding fingerprint {fingerprint or '<unknown>'!r} != "
f"known_threshold calibration {self.known_threshold_calibrated_for!r}; "
f"cosine is not comparable across models, degrading to exact normalize comparison",
)
embedder = None
comparison_norms = {normalize_topic(title) for title, _ in comparison}
for candidate in fresh:
normalized = normalize_topic(str(candidate.get("title") or ""))
if normalized in seen_normalized:
dropped_duplicate += 1
continue
if embedder is None:
if normalized in comparison_norms:
dropped_duplicate += 1
continue
else:
verdict, matched, similarity = await self._semantic_verdict(
embedder,
candidate,
comparison,
comparison_norms,
)
if verdict == "known":
dropped_known += 1
self.logger.info(
f"[{self.name}] candidate {candidate.get('title')!r} dropped as known "
f"(sim={similarity:.3f}, matched={matched!r})",
)
continue
if verdict == "duplicate": # embedding failure fallback
dropped_duplicate += 1
continue
seen_normalized.add(normalized)
survivors.append(candidate)
state.dropped_duplicate = dropped_duplicate
state.dropped_known = dropped_known
state.candidates = merged + resurrected + survivors
self.logger.info(
f"[{self.name}] candidates in={state.candidates_in} merged={len(merged)} "
f"resurrected={len(resurrected)} new={len(survivors)} "
f"dropped_duplicate={dropped_duplicate} dropped_known={dropped_known}",
)
# 3) Truth source: add new topics, prune, single atomic write (F1.3).
for candidate in survivors:
state_file.open_topics.append(ProactiveTopic.model_validate(candidate))
trim_state_file(state_file, day, max(state.carry_forward_days, 1))
await save_state(ws, state_file, daily)
# 4) Push derived from the cumulative truth source (v5 R1): a topic
# discovered today with sufficient confidence. Monotonic across same-day
# rounds because such topics persist in the truth source once added.
# The candidate list (sorted deterministically) feeds the plan/agenda
# steps; ``push`` is exactly "at least one candidate".
push_candidates = sort_topics(
[t for t in state_file.open_topics if t.first_seen == day and t.confidence >= self.min_push_confidence],
)
push = bool(push_candidates)
if push:
file_skip_reason = ""
elif merged or resurrected or survivors:
file_skip_reason = "low_confidence"
else:
file_skip_reason = "all_duplicates"
# 5) Render from the truth source and write idempotently (A4).
topics_out = sort_topics(list(state_file.open_topics))[: self.max_topics]
now_dt = current_now(self)
rendered = render_interests(day, topics_out, push, now_dt)
interests_path = interests_path_for(ws, daily, day)
written = write_interests_if_changed(ws, interests_path, rendered)
rel_path = norm_path(interests_path.relative_to(ws).as_posix())
state.topics_out = [dump_topic(t) for t in topics_out]
state.push_candidates = [dump_topic(t) for t in push_candidates]
state.push = push
state.file_skip_reason = file_skip_reason
state.interests_path = rel_path
state.interests_written = written
state.duration_ms = int((time.monotonic() - started) * 1000)
self._store(state)
answer = (
f"Topics: {len(topics_out)} rendered, push={push}, "
f"skip_reason={file_skip_reason or '-'}, written={written} to {rel_path}"
)
self.context.response.success = True
self.context.response.answer = answer
self.logger.info(f"[{self.name}] finish {answer}")
return self.context.response
@staticmethod
def _merge_into(existing: ProactiveTopic, candidate: dict, day: str) -> None:
"""Same-id candidate refreshes evidence in place; first_seen is kept."""
existing.last_evidence_at = day
if candidate.get("reason"):
existing.reason = str(candidate["reason"])
if candidate.get("evidence"):
existing.evidence = str(candidate["evidence"])[:120]
existing.confidence = clamp_confidence(candidate.get("confidence"))
if candidate.get("paths"):
existing.paths = list(candidate["paths"])
@staticmethod
def _resurrect(tombstone: dict, candidate: dict, day: str) -> ProactiveTopic:
"""Reopen a resolved topic: original first_seen kept, evidence refreshed.
The over-age trim still applies to the original ``first_seen``, so a
resurrection only extends a lifetime that has not fully elapsed.
"""
return ProactiveTopic(
id=str(tombstone.get("id") or candidate.get("id") or ""),
title=str(candidate.get("title") or tombstone.get("title") or ""),
reason=str(candidate.get("reason") or ""),
kind=str(candidate.get("kind") or "interest_extend"),
confidence=clamp_confidence(candidate.get("confidence")),
first_seen=str(tombstone.get("first_seen") or day),
last_evidence_at=day,
evidence=str(candidate.get("evidence") or "")[:120],
paths=candidate.get("paths") or [],
)
async def _comparison_texts(
self,
ws,
daily: str,
day: str,
state_file: ProactiveStateFile,
expiry_cutoff: str = "",
) -> list[tuple[str, str]]:
"""(title, embed_text) pairs from recent interests, open topics, digest nodes.
Embed text carries the reason when available (v5.2 calibration:
title+reason separates the DUP/KEEP bands; bare titles do not).
Over-age open topics are excluded (v5.1): they are pruned by trim this
round, so using them as "known" would silently swallow a re-mention of
a matter that is about to restart.
"""
pairs: list[tuple[str, str]] = []
for previous_day in previous_dates(day, self.dedup_lookback_days):
data = read_interests_data(interests_path_for(ws, daily, previous_day))
if not data:
continue
topics, _is_v1, _push = parse_interests_topics(data, previous_day)
pairs.extend((t.title, _known_text(t.title, getattr(t, "reason", ""))) for t in topics)
pairs.extend(
(t.title, _known_text(t.title, t.reason))
for t in state_file.open_topics
if not expiry_cutoff or str(t.first_seen or "") > expiry_cutoff
)
pairs.extend((title, title) for title in await self._digest_titles())
return pairs
async def _digest_titles(self) -> list[str]:
if self.app_context is None or self.digest_compare_limit <= 0:
return []
catalog = self.app_context.components.get(ComponentEnum.FILE_CATALOG, {}).get("digest")
if catalog is None:
return []
try:
nodes = await catalog.get_nodes()
except Exception: # noqa: BLE001
return []
ordered = sorted(nodes, key=lambda n: float(getattr(n, "st_mtime", 0.0) or 0.0), reverse=True)
return [str(n.path).rsplit("/", 1)[-1].rsplit(".", 1)[0] for n in ordered[: self.digest_compare_limit]]
def _resolve_embedding(self):
if self.context is not None:
candidate = self.context.get("as_embedding")
if candidate is not None:
return candidate
name = self.kwargs.get("as_embedding", "default")
if self.app_context is None:
return None
return self.app_context.components.get(ComponentEnum.AS_EMBEDDING, {}).get(name)
async def _semantic_verdict(
self,
embedder,
candidate: dict,
comparison: list[tuple[str, str]],
comparison_norms: set[str],
) -> tuple[str, str, float]:
"""Semantic gate: known (>= known_threshold) | keep; duplicate on embedding failure."""
candidate_text = _embed_text(candidate)
texts = [candidate_text] + [embed_text for _, embed_text in comparison]
try:
vectors = await embedder(texts)
except Exception as e: # noqa: BLE001
self.logger.warning(f"[{self.name}] embedding failed, falling back to exact dedup: {e}")
normalized = normalize_topic(str(candidate.get("title") or ""))
return ("duplicate" if normalized in comparison_norms else "keep"), "", 0.0
if not vectors or len(vectors) != len(texts):
return "keep", "", 0.0
best, best_idx = 0.0, -1
for idx, other in enumerate(vectors[1:]):
similarity = _cosine(vectors[0], other)
if similarity > best:
best, best_idx = similarity, idx
matched = comparison[best_idx][0] if 0 <= best_idx < len(comparison) else ""
if best >= self.known_threshold:
return "known", matched, best
return "keep", matched, best
@staticmethod
def _expiry_cutoff(day: str, carry_forward_days: int) -> str:
"""ISO cutoff matching trim_state_file: first_seen <= cutoff is over-age."""
try:
base = dt.date.fromisoformat(day)
except ValueError:
return ""
return (base - dt.timedelta(days=max(int(carry_forward_days), 0))).isoformat()
@staticmethod
def _evidence_date(path: str) -> str:
"""Date embedded in a daily evidence path; '' when unparseable (v5.2).
Lets last_evidence_at reflect when the evidence actually happened
(daily/2026-08-12/x.md -> 2026-08-12) instead of always today, so an
update anchored on an older file cannot game the freshness sort key.
"""
match = _EVIDENCE_DATE_RE.search(path or "")
if match:
try:
return dt.date.fromisoformat(match.group(1)).isoformat()
except ValueError:
return ""
return ""
@staticmethod
def _embedding_fingerprint(embedder) -> str:
"""`model@dimensions` of the resolved embedder; '' when not introspectable."""
model = ""
kwargs = getattr(embedder, "kwargs", None)
if isinstance(kwargs, dict):
model = str(kwargs.get("model") or "")
if not model:
model = str(getattr(getattr(embedder, "model", None), "model", "") or "")
try:
dimensions = int(getattr(embedder, "dimensions", 0) or 0)
except Exception: # noqa: BLE001 - property may raise RuntimeError pre-init
dimensions = 0
return f"{model}@{dimensions}" if model and dimensions else ""
def _store(self, state: ProactiveState) -> None:
assert self.context is not None
data = state.model_dump()
self.context["proactive"] = data
self.context.response.metadata["proactive"] = data
def _embed_text(candidate: dict) -> str:
"""Vector text for a candidate (v5.2 calibration: title+reason)."""
title = str(candidate.get("title") or "")
reason = str(candidate.get("reason") or "").strip()
return f"{title}{reason}" if reason else title
def _known_text(title: str, reason: str) -> str:
"""Vector text for a known topic: title+reason when available, else title."""
reason = str(reason or "").strip()
return f"{title}{reason}" if reason else title
_EVIDENCE_DATE_RE = re.compile(r"(?:^|/)(\d{4}-\d{2}-\d{2})/")
def _cosine(a: list[float], b: list[float]) -> float:
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)

View file

@ -0,0 +1,689 @@
"""Shared proactive helpers: frozen topic identity, truth-source state, rendering.
Implements the A7 skeleton from PROACTIVE_SPEC.md. ``normalize_topic`` is a
frozen contract (INV-4): any change to it drifts every historical topic id.
"""
import contextlib
import datetime as dt
import hashlib
import os
import re
import tempfile
import time
import unicodedata
from pathlib import Path
import yaml
from ....enumeration import ComponentEnum
from ....schema import ProactiveStateFile, ProactiveTopic
from ....schema.proactive import clamp_confidence
from ....utils import get_logger
from ...file_io._file_io import get_path_lock
from .._evolve import now
from ..dream.utils import clean_paths, recent_dates, scan_day_files
logger = get_logger(log_to_file=False)
PROACTIVE_STATE_NAME = "_proactive.yaml"
INTERESTS_NAME = "interests.yaml"
EXTRACT_SECTIONS = ("follow_ups", "extends", "updates")
def load_yaml_topics(path: Path, *, strict: bool = False) -> list[dict]:
"""Load legacy or current interests YAML topics."""
if not path.is_file():
return []
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception as exc:
if strict:
raise ValueError(f"Invalid interests YAML at {path}: {exc}") from exc
return []
if data is None:
if strict:
raise ValueError(f"Invalid interests YAML at {path}: expected an object")
return []
topics = data.get("topics") if isinstance(data, dict) else None
if not isinstance(topics, list):
if strict:
raise ValueError(f"Invalid interests YAML at {path}: topics must be a list")
return []
cleaned_topics = []
for index, topic in enumerate(topics):
if strict:
_validate_topic(topic, path, index)
if isinstance(topic, dict) and (cleaned := _clean_legacy_topic(topic)):
cleaned_topics.append(cleaned)
return cleaned_topics
def _validate_topic(topic: object, path: Path, index: int) -> None:
"""Reject topic data that would otherwise be silently discarded or coerced."""
prefix = f"Invalid interests YAML at {path}: topics[{index}]"
if not isinstance(topic, dict):
raise ValueError(f"{prefix} must be an object")
allowed = {"title", "reason", "evidence", "keywords", "paths"}
if unknown := sorted(set(topic) - allowed):
raise ValueError(f"{prefix} has unknown field(s): {', '.join(str(key) for key in unknown)}")
for field in ("title", "reason"):
value = topic.get(field)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{prefix}.{field} must be a non-empty string")
if "evidence" in topic and not isinstance(topic["evidence"], str):
raise ValueError(f"{prefix}.evidence must be a string")
for field in ("keywords", "paths"):
if field not in topic:
continue
values = topic[field]
if not isinstance(values, list) or any(not isinstance(value, str) or not value.strip() for value in values):
raise ValueError(f"{prefix}.{field} must be a list of non-empty strings")
def _clean_legacy_topic(raw: dict) -> dict:
"""Normalize the v1 topic shape returned by the compatibility reader."""
title = str(raw.get("title") or "").strip()
reason = str(raw.get("reason") or "").strip()
if not title or not reason:
return {}
keywords = raw.get("keywords") or []
paths = raw.get("paths") or []
return {
"title": title,
"reason": reason,
"evidence": str(raw.get("evidence") or "").strip(),
"keywords": ([str(k).strip() for k in keywords if str(k).strip()] if isinstance(keywords, list) else []),
"paths": ([str(p).strip() for p in paths if str(p).strip()] if isinstance(paths, list) else []),
}
# ---------------------------------------------------------------------------
# Frozen identity contract (A7 / INV-4)
# ---------------------------------------------------------------------------
def normalize_topic(title: str) -> str:
"""NFKC -> casefold -> keep only chars whose category starts with L/N.
Frozen contract (INV-4): removing all whitespace/punctuation means any
modification would drift every historical topic id.
"""
text = unicodedata.normalize("NFKC", title or "").casefold()
return "".join(ch for ch in text if unicodedata.category(ch)[0] in ("L", "N"))
def topic_id(title: str) -> str:
"""Stable topic identity: ``sha1(normalize_topic(title))[:12]``."""
return hashlib.sha1(normalize_topic(title).encode("utf-8")).hexdigest()[:12]
# ---------------------------------------------------------------------------
# Paths and material set M (F2.0)
# ---------------------------------------------------------------------------
def state_file_path(ws: Path, daily: str = "daily") -> Path:
"""Truth-source path ``daily/_proactive.yaml``."""
return ws / daily / PROACTIVE_STATE_NAME
def interests_path_for(ws: Path, daily: str, day: str) -> Path:
"""Exposure-product path ``daily/<day>/interests.yaml``."""
return ws / daily / day / INTERESTS_NAME
def norm_path(rel) -> str:
"""Normalize a workspace-relative path (posix, no leading ./)."""
text = str(rel or "").strip().replace("\\", "/")
while text.startswith("./"):
text = text[2:]
return text
def scan_material_daily(ws: Path, day: str, daily: str, scan_days: int) -> list[str]:
"""M_daily: chunk notes in the scan window, minus day indexes and ``_*`` files (INV-11)."""
out: list[str] = []
for scan_day in recent_dates(day, scan_days):
day_index = f"{daily}/{scan_day}.md"
for rel in scan_day_files(ws, scan_day, daily):
rel = norm_path(rel)
base = rel.rsplit("/", 1)[-1]
if rel == day_index or base.startswith("_"):
continue
if rel not in out:
out.append(rel)
return sorted(out)
# ---------------------------------------------------------------------------
# Truth-source state file daily/_proactive.yaml (F1.3)
# ---------------------------------------------------------------------------
def load_state(ws: Path, daily: str = "daily") -> tuple[ProactiveStateFile, bool]:
"""Load the truth source; returns ``(state_file, needs_bootstrap)``.
A missing file means first run (fresh workspace or upgrade) and triggers
the one-time F1.4 bootstrap from interests.yaml history. A corrupt or
invalid file rebuilds empty WITHOUT bootstrap (spec F1.3/A2/A5), as does
an existing file that already carries the ``open_topics`` key (an empty
list is a normal state, not a trigger).
"""
path = state_file_path(ws, daily)
if not path.is_file():
logger.info(f"proactive state file missing, first-run bootstrap scheduled: {path}")
return ProactiveStateFile(), True
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("state file is not a mapping")
except Exception as e: # noqa: BLE001
logger.warning(f"proactive state file corrupt, rebuilding empty: {path} ({e})")
return ProactiveStateFile(), False
needs_bootstrap = "open_topics" not in data
try:
state = ProactiveStateFile.model_validate(data)
except Exception as e: # noqa: BLE001
logger.warning(f"proactive state file invalid, rebuilding empty: {path} ({e})")
return ProactiveStateFile(), False
return state, needs_bootstrap
async def save_state(ws: Path, state_file: ProactiveStateFile, daily: str = "daily") -> None:
"""Atomically persist the truth source (path lock + tmp file + os.replace)."""
path = state_file_path(ws, daily)
lock = await get_path_lock(path)
async with lock:
path.parent.mkdir(parents=True, exist_ok=True)
rendered = yaml.safe_dump(state_file.model_dump(), allow_unicode=True, sort_keys=False)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(rendered if rendered.endswith("\n") else f"{rendered}\n")
os.replace(tmp, path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
def _safe_date(text: str) -> dt.date | None:
try:
return dt.date.fromisoformat(str(text or "").strip())
except ValueError:
return None
async def load_carry_forward(
ws: Path,
state_file: ProactiveStateFile,
day: str,
days: int,
top_k: int,
daily: str = "daily",
needs_bootstrap: bool = False,
) -> tuple[list[ProactiveTopic], list[ProactiveTopic]]:
"""Return ``(carry_forward_all, carry_forward_prompt)`` sorted per A4 rule 1.
Bootstraps the truth source from interests.yaml history exactly once on
first run (missing state file) or when an existing file lacks the
``open_topics`` key (F1.4). Over-age topics are dropped here with a log;
resolved ids are suppressed.
"""
if needs_bootstrap:
state_file.open_topics = _bootstrap_from_history(ws, day, days, daily)
await save_state(ws, state_file, daily)
resolved_ids = {str(r.get("id") or "") for r in state_file.resolved if isinstance(r, dict)}
base = _safe_date(day)
open_topics: list[ProactiveTopic] = []
expired = 0
for topic in state_file.open_topics:
if topic.id and topic.id in resolved_ids:
continue
first_seen = _safe_date(topic.first_seen)
# Boundary aligned with trim_state_file/_expiry_cutoff: age >= days is
# over-age everywhere, so a topic never enters the prompt in the same
# round it gets pruned from the truth source (audit item 9).
if base is not None and first_seen is not None and (base - first_seen).days >= int(days):
expired += 1
continue
open_topics.append(topic)
if expired:
logger.info(f"proactive carry-forward dropped {expired} over-age topic(s) (window={days}d)")
ordered = sort_topics(open_topics)
return ordered, ordered[: max(int(top_k), 0)]
def _bootstrap_from_history(ws: Path, day: str, days: int, daily: str) -> list[ProactiveTopic]:
"""One-time bootstrap: newest record per id wins, first_seen takes the min (F1.4)."""
if _safe_date(day) is None:
return []
records: dict[str, ProactiveTopic] = {}
first_seen: dict[str, str] = {}
for file_date in reversed(recent_dates(day, days)): # newest -> oldest
data = read_interests_data(interests_path_for(ws, daily, file_date))
if not data:
continue
topics, _is_v1, _push = parse_interests_topics(data, file_date)
for topic in topics:
anchor = topic.first_seen or file_date
if topic.id not in first_seen or anchor < first_seen[topic.id]:
first_seen[topic.id] = anchor
records.setdefault(topic.id, topic)
out: list[ProactiveTopic] = []
for tid, topic in records.items():
topic.first_seen = first_seen.get(tid) or topic.first_seen or day
out.append(topic)
logger.info(f"proactive bootstrap built {len(out)} open topic(s) from interests.yaml history")
return out
def trim_state_file(state_file: ProactiveStateFile, day: str, days: int) -> None:
"""Prune budget/exposure/resolved windows and over-age/resolved open topics."""
base = _safe_date(day)
if base is None:
return
cutoff = (base - dt.timedelta(days=max(int(days), 0))).isoformat()
state_file.resolved = [
r for r in state_file.resolved if isinstance(r, dict) and str(r.get("resolved_at") or "") > cutoff
]
resolved_ids = {str(r.get("id") or "") for r in state_file.resolved}
kept: list[ProactiveTopic] = []
for topic in state_file.open_topics:
if topic.id and topic.id in resolved_ids:
continue
first_seen = _safe_date(topic.first_seen)
if first_seen is not None and first_seen.isoformat() <= cutoff:
continue
kept.append(topic)
state_file.open_topics = kept
# ---------------------------------------------------------------------------
# interests.yaml read/render (F1.2 / A2 / A4)
# ---------------------------------------------------------------------------
def quarantine_interests(path: Path, error: Exception) -> None:
"""Rename a corrupt interests.yaml aside (A2): ``interests.corrupt-<ts>.yaml``."""
stamp = int(time.time())
corrupt = path.with_name(f"interests.corrupt-{stamp}.yaml")
try:
path.rename(corrupt)
logger.warning(f"quarantined corrupt interests file {path} -> {corrupt.name}: {error}")
except OSError:
logger.warning(f"corrupt interests file {path}: {error}")
def read_interests_file(path: Path) -> tuple[str, dict] | None:
"""Read interests.yaml returning ``(raw_text, data)``; quarantine corrupt files (A2)."""
if not path.is_file():
return None
try:
raw_text = path.read_text(encoding="utf-8")
data = yaml.safe_load(raw_text)
if not isinstance(data, dict):
raise ValueError("interests.yaml is not a mapping")
return raw_text, data
except Exception as e: # noqa: BLE001
quarantine_interests(path, e)
return None
def read_interests_data(path: Path) -> dict | None:
"""Parse interests.yaml; quarantine corrupt files (A2) and return None."""
loaded = read_interests_file(path)
return loaded[1] if loaded else None
def parse_interests_topics(data: dict, file_date: str) -> tuple[list[ProactiveTopic], bool, bool]:
"""Return ``(topics, is_v1, push)`` with A2 fallbacks applied.
Missing ``first_seen``/``last_evidence_at`` fall back to the file date
(not today); missing ids are derived from the frozen title hash.
"""
is_v1 = data.get("version") is None
push = data.get("push", True)
if not isinstance(push, bool):
push = True
raw_topics = data.get("topics") or []
topics: list[ProactiveTopic] = []
for raw in raw_topics if isinstance(raw_topics, list) else []:
if not isinstance(raw, dict):
continue
title = str(raw.get("title") or "").strip()
reason = str(raw.get("reason") or "").strip()
if not title or not reason:
continue
topics.append(
ProactiveTopic(
id=str(raw.get("id") or "").strip() or topic_id(title),
title=title,
reason=reason,
kind=raw.get("kind", "interest_extend"),
confidence=raw.get("confidence", 0.5),
first_seen=str(raw.get("first_seen") or "").strip() or file_date,
last_evidence_at=str(raw.get("last_evidence_at") or "").strip() or file_date,
evidence=str(raw.get("evidence") or "").strip()[:120],
paths=raw.get("paths") or [],
),
)
return topics, is_v1, push
def sort_topics(topics: list) -> list:
"""Order: last_evidence_at desc -> follow_up first -> confidence desc -> id asc.
Freshness is the primary key (v5 aging fix): stale topics sink below newly
evidenced ones of any kind, so long-lived follow_ups cannot permanently
crowd out new discoveries.
"""
def get(topic, key):
return getattr(topic, key) if not isinstance(topic, dict) else topic.get(key)
out = sorted(topics, key=lambda t: str(get(t, "id") or ""))
out.sort(key=lambda t: clamp_confidence(get(t, "confidence")), reverse=True)
out.sort(key=lambda t: 0 if get(t, "kind") == "follow_up" else 1)
out.sort(key=lambda t: str(get(t, "last_evidence_at") or ""), reverse=True)
return out
def dump_topic(topic) -> dict:
"""Render one topic as an A2-ordered dict for interests.yaml v2."""
get = (lambda k: getattr(topic, k)) if not isinstance(topic, dict) else topic.get
return {
"id": str(get("id") or ""),
"title": str(get("title") or ""),
"reason": str(get("reason") or ""),
"kind": str(get("kind") or "interest_extend"),
"confidence": clamp_confidence(get("confidence")),
"first_seen": str(get("first_seen") or ""),
"last_evidence_at": str(get("last_evidence_at") or ""),
"evidence": str(get("evidence") or "")[:120],
"paths": [str(p) for p in (get("paths") or [])],
}
def render_interests(
day: str,
topics: list,
push: bool,
now_dt: dt.datetime,
agenda: list | None = None,
suppressed: list | None = None,
) -> dict:
"""Render the full v2 file content from the truth source (INV-6).
v5: ``skip_reason`` is no longer persisted (no consumer); it survives as
structured log/metadata on ``ProactiveState.file_skip_reason`` (R7).
The plan/agenda enrichment keys (``agenda``/``suppressed``) are only
present when the agenda step rendered the file; the topics-only renderer
passes ``None`` so its file shape stays unchanged.
"""
rendered = {
"version": 2,
"date": day,
"generated_at": now_dt.isoformat(timespec="seconds"),
"push": bool(push),
"topics": [dump_topic(t) for t in topics],
}
if agenda is not None:
rendered["agenda"] = list(agenda)
if suppressed is not None:
rendered["suppressed"] = list(suppressed)
return rendered
def write_interests_if_changed(ws: Path, path: Path, rendered: dict) -> bool: # pylint: disable=unused-argument
"""Apply A4 render-write rules (idempotent skip + atomic replace).
v5 (R1): the "push=false never overwrites nightly v1" special case is
gone; ``push`` is derived from the cumulative truth source, so re-renders
are monotonic and need no guard.
"""
existing: dict | None = None
if path.is_file():
existing = read_interests_data(path)
if existing is not None:
existing_push = existing.get("push", True)
if not isinstance(existing_push, bool):
existing_push = True
if (
existing_push == bool(rendered.get("push"))
and existing.get("topics") == rendered.get("topics")
and (existing.get("agenda") or []) == (rendered.get("agenda") or [])
and (existing.get("suppressed") or []) == (rendered.get("suppressed") or [])
):
return False
path.parent.mkdir(parents=True, exist_ok=True)
payload = yaml.safe_dump(rendered, allow_unicode=True, sort_keys=False)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(payload if payload.endswith("\n") else f"{payload}\n")
os.replace(tmp, path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
return True
# ---------------------------------------------------------------------------
# Candidate cleaning (extract side, A3)
# ---------------------------------------------------------------------------
def clean_candidate(raw, allowed_paths: set[str], kind: str, day: str) -> dict:
"""Clean one LLM candidate; drop entries whose paths escape M (INV-8)."""
if not isinstance(raw, dict):
return {}
title = str(raw.get("title") or "").strip()
reason = str(raw.get("reason") or "").strip()
paths = clean_paths(raw.get("paths"), allowed_paths)
if not title or not reason or not paths:
return {}
return {
"id": topic_id(title),
"title": title,
"reason": reason,
"kind": kind,
"confidence": clamp_confidence(raw.get("confidence")),
"first_seen": day,
"last_evidence_at": day,
# Derived, not LLM-emitted: the first whitelisted path anchors the
# candidate, so the output contract stays one field smaller.
"evidence": paths[0][:120],
"paths": paths,
}
def current_now(step) -> dt.datetime:
"""Business-time access per INV-3 (timezone-aware; never datetime.now())."""
tz = step.app_context.app_config.timezone if step.app_context is not None else None
return now(tz)
def parse_extract_reply(text: str) -> dict:
"""Parse the A3 fenced YAML/JSON output; fenced blocks take priority.
A reply only counts as parsed when at least one contract section
(``EXTRACT_SECTIONS``) is present as a list; a non-empty mapping with
misspelled or missing section names is a parse failure, so
``_extract_with_retry`` retries instead of checkpointing changed files
on output that can never yield topics.
Unlike the dream parser there is no scalar-mapping fallback: proactive
output is sectioned lists, and a partial fallback would corrupt updates.
"""
candidates = [m.group(1).strip() for m in re.finditer(r"```(?:json|ya?ml)?\s*(.*?)```", text, re.S | re.I)]
candidates.append((text or "").strip())
for raw in candidates:
if not raw:
continue
try:
data = yaml.safe_load(raw)
except yaml.YAMLError:
continue
if isinstance(data, dict) and data and any(isinstance(data.get(key), list) for key in EXTRACT_SECTIONS):
return data
return {}
def strip_frontmatter(text: str) -> tuple[dict, str]:
"""Split leading ``---`` YAML frontmatter blocks from the body.
Handles files with several consecutive frontmatter blocks; returns
``(merged_meta, body)``. Parse failures degrade to empty meta.
"""
meta: dict = {}
body = text or ""
while True:
stripped = body.lstrip()
if not stripped.startswith("---"):
break
rest = stripped[3:]
end = rest.find("\n---")
if end < 0:
break
block = rest[:end]
body = rest[end + 4 :]
try:
data = yaml.safe_load(block)
except yaml.YAMLError:
data = None
if isinstance(data, dict):
for key, value in data.items():
meta.setdefault(str(key), value)
return meta, body.strip()
def _contained_workspace_path(ws: Path, rel_path: str) -> Path | None:
"""Resolve ``rel_path`` strictly inside the workspace (audit item 7).
Rejects absolute paths, home-relative paths and ``..`` traversal so the
profile fallback can never read (and then feed into prompts) files that
live outside the workspace.
"""
candidate = Path(rel_path)
if candidate.is_absolute() or candidate.anchor or rel_path.startswith("~") or ".." in candidate.parts:
return None
workspace = ws.resolve()
resolved = (workspace / candidate).resolve()
try:
resolved.relative_to(workspace)
except ValueError:
return None
return resolved
def load_personal_profile_block(
ws: Path,
digest_dir: str,
max_chars: int,
fallback_rel_path: str = "",
) -> str:
"""Build the user profile/preference block used to personalize proactive inference.
Primary source is ``<digest_dir>/personal/*.md`` (one file per profile
facet: identity, preferences, constraints). Each file contributes its
frontmatter ``description`` plus a body excerpt under an equal per-file
budget. Falls back to a single profile file (legacy ``profile.md``) when
the personal directory is absent, and to a sentinel when nothing exists.
"""
max_chars = int(max_chars)
if max_chars <= 0:
return "(no user profile)"
personal_dir = ws / digest_dir / "personal"
if personal_dir.is_dir():
files = sorted(path for path in personal_dir.glob("*.md") if path.is_file())
if files:
per_file = max(max_chars // len(files), 200)
sections: list[str] = []
for path in files:
try:
raw = path.read_text(encoding="utf-8")
except OSError:
continue
meta, body = strip_frontmatter(raw)
title = str(meta.get("name") or path.stem)
description = str(meta.get("description") or "").strip()
excerpt = body[:per_file].strip()
section = f"### {title}\n"
if description:
section += f"{description}\n"
if excerpt:
section += excerpt
sections.append(section.rstrip())
if sections:
return "\n\n".join(sections)[:max_chars]
if fallback_rel_path:
fallback = _contained_workspace_path(ws, fallback_rel_path)
if fallback is not None and fallback.is_file():
try:
text = fallback.read_text(encoding="utf-8")[:max_chars]
except OSError:
return "(no user profile)"
return text.strip() or "(no user profile)"
return "(no user profile)"
def parse_fenced_yaml(text: str) -> dict:
"""Parse the first fenced YAML/JSON block (or the bare text) into a dict.
Shared by the plan/agenda reply parsers; returns ``{}`` when nothing
parses to a mapping.
"""
candidates = [m.group(1).strip() for m in re.finditer(r"```(?:json|ya?ml)?\s*(.*?)```", text, re.S | re.I)]
candidates.append((text or "").strip())
for raw in candidates:
if not raw:
continue
try:
data = yaml.safe_load(raw)
except yaml.YAMLError:
continue
if isinstance(data, dict):
return data
return {}
def parse_plan_reply(text: str) -> list[dict]:
"""Parse the plan step reply; requires a ``cards`` list of mappings."""
data = parse_fenced_yaml(text)
cards = data.get("cards")
if not isinstance(cards, list):
return []
return [card for card in cards if isinstance(card, dict)]
def parse_agenda_reply(text: str) -> tuple[list[dict], list[dict]]:
"""Parse the agenda step reply into ``(agenda, suppressed)`` lists.
``agenda`` must be present as a list; ``suppressed`` defaults to empty.
"""
data = parse_fenced_yaml(text)
agenda = data.get("agenda")
if not isinstance(agenda, list):
return [], []
suppressed = data.get("suppressed")
if not isinstance(suppressed, list):
suppressed = []
return [item for item in agenda if isinstance(item, dict)], [item for item in suppressed if isinstance(item, dict)]
def resolve_agent_wrapper(step):
"""Return the step's agent_wrapper, falling back to the app default (F4.4)."""
wrapper = step.agent_wrapper
if wrapper is not None:
return wrapper
if step.app_context is not None:
fallback = step.app_context.components.get(ComponentEnum.AGENT_WRAPPER, {}).get("default")
if fallback is not None:
configured = step.kwargs.get("agent_wrapper", "default")
step.logger.warning(f"[{step.name}] agent_wrapper '{configured}' missing; using default")
return fallback
return None

View file

@ -53,7 +53,7 @@ the active environment's executable directory is on `PATH`; do not repeatedly re
### 2. Configure optional model credentials
Basic file operations, BM25 search, wikilink traversal, and reading existing proactive topics work without model
credentials. `auto_memory`, `auto_resource`, and `auto_dream` require an LLM configuration.
credentials. `auto_memory`, `auto_resource`, `auto_dream`, and proactive refresh require an LLM configuration.
When those model-powered jobs are needed, have the user provide valid values through the environment or a `.env` file:
@ -195,22 +195,28 @@ reme auto_resource changes='[{"path":"resource/<YYYY-MM-DD>/<file>","change":"ad
## Consolidate and Use Proactive Topics
The default service runs background and cron jobs while it remains active. `auto_dream` consolidates daily notes and
resource interpretations into long-term digest memory and generates interest topics. Run it manually when the host owns
the schedule or the user requests consolidation:
resource interpretations into long-term digest memory. Proactive refresh independently generates interest topics. Run
Auto Dream manually when the host owns the schedule or the user requests consolidation:
```bash
reme auto_dream date="<YYYY-MM-DD>"
```
Run proactive refresh once, without exposing its writer job through HTTP or MCP:
```bash
reme start job=proactive_refresh date="<YYYY-MM-DD>"
```
Read generated topics with:
```bash
reme proactive date="<YYYY-MM-DD>"
reme proactive_read date="<YYYY-MM-DD>"
```
`auto_dream` requires LLM credentials. `proactive` reads existing structured topics and works without an LLM call. Pass
`include_content=false` when raw YAML content is unnecessary. The host Agent decides whether and how to mention topics;
ReMe does not independently notify the user or take external action.
`auto_dream` and proactive refresh require LLM credentials. `proactive_read` reads existing structured topics and works
without an LLM call. Pass `include_content=false` when raw YAML content is unnecessary. The host Agent decides whether
and how to mention topics; ReMe does not independently notify the user or take external action.
## Integration Rules

View file

@ -1,8 +1,8 @@
"""Integration test for the 4-step auto_dream job and proactive reader.
"""Integration test for the three-step auto_dream job.
Runs against a real LLM. The test seeds a dream workspace, runs ``auto_dream`` for
2026-05-28, verifies digest/interests/catalog effects, then runs ``proactive``.
Agent messages and generated markdown/yaml/jsonl artifacts are copied to
2026-05-28, and verifies digest/catalog effects without producing proactive output.
Agent messages and generated markdown/jsonl artifacts are copied to
``tests/integration/logs/auto_dream_latest/`` for manual inspection.
"""
@ -12,8 +12,6 @@ import shutil
import sys
from pathlib import Path
import yaml
INTEGRATION_DIR = Path(__file__).resolve().parent
ARTIFACT_DIR = INTEGRATION_DIR / "logs" / "auto_dream_latest"
sys.path.insert(0, str(INTEGRATION_DIR))
@ -99,8 +97,8 @@ def _file_graph_links(env) -> dict[str, list[dict]]:
return out
def test_auto_dream_and_proactive():
"""Run auto_dream end to end, save transcripts/results, then read interests via proactive."""
def test_auto_dream():
"""Run auto_dream end to end and verify that it does not produce proactive interests."""
async def run():
_reset_artifacts()
@ -121,8 +119,6 @@ def test_auto_dream_and_proactive():
"auto_dream",
date=DREAM_DATE,
hint="Integration test: preserve SOC2, JWT kid, Redis current_kid, and small-PR facts.",
topic_count=3,
topic_diversity_days=7,
)
dumped = await recorder.dump()
session_jsonl = sorted((env.workspace_dir / "mem_session" / "agentscope").glob("*.jsonl"))
@ -142,7 +138,7 @@ def test_auto_dream_and_proactive():
interests = env.workspace_dir / "daily" / DREAM_DATE / "interests.yaml"
catalog = env.workspace_dir / "metadata" / "file_catalog" / "dream.jsonl.zst"
assert changed_note.is_file(), f"changed note missing: {changed_note}"
assert interests.is_file(), f"interests.yaml missing: {interests}"
assert not interests.exists(), f"auto_dream unexpectedly wrote interests.yaml: {interests}"
assert catalog.is_file(), f"dream catalog missing: {catalog}"
after_digest = _all_digest_text(env)
@ -195,16 +191,6 @@ def test_auto_dream_and_proactive():
"no digest↔digest wikilink found in integrated target markdown\n" f"targets: {target_paths}"
)
interests_text = _print_text_file("interests.yaml", interests)
interests_data = yaml.safe_load(interests_text) or {}
topics = interests_data.get("topics") or []
assert isinstance(topics, list) and topics, f"no topics in interests.yaml\n{interests_text}"
proactive = await app.run_job("proactive", date=DREAM_DATE, include_content=True)
assert proactive.success is True, f"proactive failed: {proactive.answer!r}"
assert proactive.metadata.get("path") == f"daily/{DREAM_DATE}/interests.yaml"
assert proactive.metadata.get("topics"), f"proactive returned no topics: {proactive.metadata!r}"
if day_index.is_file():
_print_text_file("day_index.md", day_index)
else:
@ -231,5 +217,5 @@ def test_auto_dream_and_proactive():
if __name__ == "__main__":
print("=== auto_dream integration test ===")
test_auto_dream_and_proactive()
test_auto_dream()
print("\nIntegration test passed!")

View file

@ -145,7 +145,6 @@ async def _run_loop(env, reme) -> None:
"auto_dream",
date=today,
hint="Integration e2e: preserve Project Meridian CRDT/Yjs/WebTransport facts.",
topic_count=3,
)
assert dream.success is True, f"auto_dream failed: {dream.answer!r}\n{dream.metadata!r}"
dmeta = (dream.metadata or {}).get("dream") or {}
@ -182,14 +181,13 @@ async def _run_loop(env, reme) -> None:
"search recalled none of the seeded facts — the provision->" "consolidate->index->search loop is broken"
)
# ---- 5. proactive: read the interests surfaced by the dream --
proactive = await reme.run_job("proactive", date=today, include_content=True)
# ---- 5. proactive: dream does not produce proactive interests --
proactive = await reme.run_job("proactive_read", date=today, include_content=True)
assert proactive.success is True, f"proactive failed: {proactive.answer!r}"
pmeta = proactive.metadata or {}
assert pmeta.get("path") == f"daily/{today}/interests.yaml", f"unexpected interests path: {pmeta!r}"
topics = pmeta.get("topics") or []
print(f"\n[5/5 proactive] topics: {topics}")
assert topics, f"proactive surfaced no interest topics: {pmeta!r}"
assert pmeta.get("skipped") is True, f"proactive unexpectedly found dream-produced topics: {pmeta!r}"
assert pmeta.get("topics") == [], f"proactive unexpectedly returned dream-produced topics: {pmeta!r}"
print("\n" + "=" * 70)
print("test_reme_e2e_full_loop passed")

View file

@ -18,9 +18,7 @@ from reme.schema import DreamState, FileNode
from reme.steps.evolve.dream.extract import DreamExtractStep
from reme.steps.evolve.dream.finish import DreamFinishStep
from reme.steps.evolve.dream.integrate import DreamIntegrateStep, _snapshot_digest
from reme.steps.evolve.dream.proactive import ProactiveStep
from reme.steps.evolve.dream.topics import DreamTopicsStep
from reme.steps.evolve.dream.utils import load_yaml_topics, parse_structured_reply, recent_dates, scan_day_files
from reme.steps.evolve.dream.utils import parse_structured_reply, recent_dates, scan_day_files
def _touch(path: Path, text: str = "x") -> Path:
@ -117,8 +115,8 @@ class _SequenceAgent(BaseAgentWrapper):
return outcome
def test_scan_day_files_includes_nested_md_and_excludes_interests():
"""Scan day files."""
def test_scan_day_files_includes_only_markdown_day_files():
"""Scan day indexes and nested Markdown notes without including YAML products."""
with tempfile.TemporaryDirectory() as tmp:
workspace = Path(tmp)
_touch(workspace / "daily" / "2026-05-28.md")
@ -171,6 +169,50 @@ def test_dream_extract_matches_posix_catalog_paths(tmp_path):
asyncio.run(run())
def test_dream_extract_removes_all_legacy_interests_entries_from_catalog(tmp_path):
"""Auto Dream removes historical interests watermarks without touching exposure files."""
class Catalog(_Catalog):
"""Catalog seeded with a legacy interests entry."""
def __init__(self, nodes):
super().__init__()
self.nodes = nodes
self.deleted = []
async def delete(self, path):
self.deleted.extend(path if isinstance(path, list) else [path])
async def get_nodes(self, paths=None):
return self.nodes
async def run():
interests = _touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", "topics: []\n")
old_interests = _touch(tmp_path / "daily" / "2026-01-01" / "interests.yaml", "topics: []\n")
rel_path = interests.relative_to(tmp_path).as_posix()
old_rel_path = old_interests.relative_to(tmp_path).as_posix()
catalog = Catalog(
[
FileNode(path=rel_path, st_mtime=interests.stat().st_mtime),
FileNode(path=old_rel_path, st_mtime=old_interests.stat().st_mtime),
],
)
step = DreamExtractStep(scan_days=1, app_context=ApplicationContext(workspace_dir=str(tmp_path)))
with patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}):
response = await step(
RuntimeContext(date="2026-05-28", file_catalog=catalog, file_store=_FileStore(tmp_path)),
)
assert response.success is True
assert response.metadata["dream"]["deleted_paths"] == [old_rel_path, rel_path]
assert catalog.deleted == [old_rel_path, rel_path]
assert interests.read_text(encoding="utf-8") == "topics: []\n"
assert old_interests.read_text(encoding="utf-8") == "topics: []\n"
asyncio.run(run())
def test_recent_dates_includes_anchor_and_previous_days():
"""Recent date window is inclusive and chronological."""
assert recent_dates("2026-05-28", 3) == ["2026-05-26", "2026-05-27", "2026-05-28"]
@ -249,7 +291,7 @@ def test_extract_unusable_receipt_is_a_warning_not_a_failure(tmp_path):
assert dream["units"] == []
assert dream["failed_paths"] == []
assert dream["warnings"] == [
"dream extract skipped unusable agent receipt after retry; expected units and topics lists",
"dream extract skipped unusable agent receipt after retry; expected a units list",
]
assert step.agent_wrapper.calls == 2
@ -261,7 +303,7 @@ def test_extract_retries_one_unusable_receipt(tmp_path):
async def run():
_touch(tmp_path / "daily" / "2026-05-28" / "session.md")
agent = _SequenceAgent({"result": "{}"}, {"result": '{"units": [], "topics": []}'})
agent = _SequenceAgent({"result": "{}"}, {"result": '{"units": []}'})
step = DreamExtractStep(
scan_days=1,
app_context=ApplicationContext(workspace_dir=str(tmp_path)),
@ -474,278 +516,6 @@ def test_extract_without_llm_marks_changed_paths_failed(tmp_path):
asyncio.run(run())
def test_topics_step_writes_only_target_date_interests():
"""Topics are written only to ``state.date`` even when scan dates span multiple days."""
async def run():
with tempfile.TemporaryDirectory() as tmp:
workspace = Path(tmp)
_touch(workspace / "daily" / "2026-05-26" / "old.md")
_touch(workspace / "daily" / "2026-05-28" / "today.md")
old_interests = workspace / "daily" / "2026-05-26" / "interests.yaml"
_touch(old_interests, "date: 2026-05-26\ntopics: []\n")
state = DreamState(
date="2026-05-28",
dates=["2026-05-26", "2026-05-27", "2026-05-28"],
workspace=str(workspace),
daily_dir="daily",
topics=[
{
"title": "Old changed topic",
"reason": "Old daily material changed.",
"paths": ["daily/2026-05-26/old.md"],
},
{
"title": "Today changed topic",
"reason": "Today's daily material changed.",
"paths": ["daily/2026-05-28/today.md"],
},
],
)
step = DreamTopicsStep()
resp = await step(RuntimeContext(dream=state.model_dump(), file_store=_FileStore(workspace)))
target = workspace / "daily" / "2026-05-28" / "interests.yaml"
dream = resp.metadata["dream"]
assert resp.success is True
assert target.is_file()
assert old_interests.read_text(encoding="utf-8") == "date: 2026-05-26\ntopics: []\n"
assert dream["interests_paths"] == ["daily/2026-05-28/interests.yaml"]
assert dream["modified_paths"] == ["daily/2026-05-28/interests.yaml"]
assert yaml.safe_load(target.read_text(encoding="utf-8"))["date"] == "2026-05-28"
asyncio.run(run())
def test_topics_same_content_is_not_modified(tmp_path):
"""Rewriting deterministic interests content does not count as a user-visible change."""
async def run():
topic = {"title": "Topic", "reason": "Reason", "paths": ["daily/source.md"]}
step = DreamTopicsStep()
with patch("reme.steps.evolve.dream.topics.refresh_day_index", return_value={}):
first = await step(
RuntimeContext(
dream=DreamState(
date="2026-05-28",
workspace=str(tmp_path),
daily_dir="daily",
topics=[topic],
).model_dump(),
file_store=_FileStore(tmp_path),
),
)
second = await step(
RuntimeContext(
dream=DreamState(
date="2026-05-28",
workspace=str(tmp_path),
daily_dir="daily",
topics=[topic],
).model_dump(),
file_store=_FileStore(tmp_path),
),
)
third = await step(
RuntimeContext(
dream=DreamState(
date="2026-05-28",
workspace=str(tmp_path),
daily_dir="daily",
topics=[topic],
).model_dump(),
file_store=_FileStore(tmp_path),
),
)
assert first.metadata["dream"]["modified_paths"] == ["daily/2026-05-28/interests.yaml"]
assert second.metadata["dream"]["modified_paths"] == ["daily/2026-05-28/interests.yaml"]
assert third.metadata["dream"]["modified_paths"] == []
asyncio.run(run())
def test_topics_agent_failure_falls_back_to_candidates(tmp_path):
"""Topic ranking remains best-effort when the optional agent is unavailable."""
async def run():
state = DreamState(
date="2026-05-28",
workspace=str(tmp_path),
daily_dir="daily",
topics=[{"title": "Topic", "reason": "Reason", "paths": ["daily/source.md"]}],
)
step = DreamTopicsStep()
step.agent_wrapper = _ReplyAgent(error=RuntimeError("temporary model failure"))
with (
patch("reme.steps.evolve.dream.topics.refresh_day_index", return_value={}),
patch("reme.steps.evolve.dream.topics.llm_available", return_value=True),
):
response = await step(
RuntimeContext(
dream=state.model_dump(),
file_store=_FileStore(tmp_path),
agent_wrapper=step.agent_wrapper,
),
)
dream = response.metadata["dream"]
assert response.success is True
assert dream["topics_written"] == 1
assert "deterministic fallback" in dream["warnings"][0]
asyncio.run(run())
def test_topics_does_not_overwrite_invalid_existing_yaml(tmp_path):
"""A malformed user-owned interests file is preserved instead of treated as empty."""
async def run():
target = _touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", "topics: [\n")
state = DreamState(
date="2026-05-28",
workspace=str(tmp_path),
daily_dir="daily",
topics=[{"title": "Topic", "reason": "Reason", "paths": ["daily/source.md"]}],
)
step = DreamTopicsStep()
response = await step(RuntimeContext(dream=state.model_dump(), file_store=_FileStore(tmp_path)))
assert response.success is False
assert target.read_text(encoding="utf-8") == "topics: [\n"
assert "Invalid interests YAML" in response.answer
asyncio.run(run())
def test_topics_does_not_overwrite_invalid_existing_topic_entry(tmp_path):
"""Strict loading rejects entries that lenient loading would discard."""
async def run():
content = "topics:\n - title: User topic\n paths:\n - daily/source.md\n"
target = _touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", content)
state = DreamState(
date="2026-05-28",
workspace=str(tmp_path),
daily_dir="daily",
topics=[{"title": "New topic", "reason": "New reason", "paths": ["daily/source.md"]}],
)
step = DreamTopicsStep()
response = await step(RuntimeContext(dream=state.model_dump(), file_store=_FileStore(tmp_path)))
assert response.success is False
assert target.read_text(encoding="utf-8") == content
assert "topics[0].reason must be a non-empty string" in response.answer
asyncio.run(run())
def test_strict_topic_loading_rejects_lossy_fields(tmp_path):
"""Strict mode rejects values and fields that clean_topic would silently lose."""
target = _touch(tmp_path / "interests.yaml", "topics:\n - title: Topic\n reason: Reason\n custom: keep me\n")
try:
load_yaml_topics(target, strict=True)
except ValueError as exc:
assert "unknown field(s): custom" in str(exc)
else:
raise AssertionError("strict topic loading accepted a lossy field")
def test_strict_topic_loading_rejects_invalid_field_types(tmp_path):
"""Strict mode rejects list fields that would otherwise be normalized away."""
target = _touch(
tmp_path / "interests.yaml",
"topics:\n - title: Topic\n reason: Reason\n paths: daily/source.md\n",
)
try:
load_yaml_topics(target, strict=True)
except ValueError as exc:
assert "topics[0].paths must be a list of non-empty strings" in str(exc)
else:
raise AssertionError("strict topic loading accepted an invalid paths type")
def test_proactive_answer_includes_topics_and_requested_content(tmp_path):
"""Successful proactive reads expose useful data through the primary answer."""
async def run():
content = (
"date: 2026-05-28\n"
"topics:\n"
" - title: Retrieval quality\n"
" reason: Search behavior changed repeatedly.\n"
" evidence: daily/2026-05-28/session.md\n"
)
_touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", content)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(RuntimeContext(date="2026-05-28", include_content=True, file_store=_FileStore(tmp_path)))
assert response.success is True
assert response.answer == {
"summary": "Read 1 proactive topic(s) from daily/2026-05-28/interests.yaml",
"topics": [
{
"title": "Retrieval quality",
"reason": "Search behavior changed repeatedly.",
"evidence": "daily/2026-05-28/session.md",
"keywords": [],
"paths": [],
},
],
"content": content,
}
assert response.metadata["topics"] == response.answer["topics"]
assert response.metadata["content"] == content
asyncio.run(run())
def test_proactive_answer_omits_unrequested_content(tmp_path):
"""Raw YAML is absent from the primary answer when include_content is false."""
async def run():
_touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", "topics:\n - title: Topic\n reason: Reason\n")
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
response = await step(RuntimeContext(date="2026-05-28", include_content=False, file_store=_FileStore(tmp_path)))
assert response.success is True
assert "content" not in response.answer
assert response.answer["topics"][0]["title"] == "Topic"
assert response.metadata["content"] == ""
asyncio.run(run())
def test_proactive_keeps_skipped_and_error_answers_explicit(tmp_path):
"""Empty and failure outcomes remain distinguishable without reading metadata."""
async def run():
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
skipped = await step(RuntimeContext(date="2026-05-28", file_store=_FileStore(tmp_path)))
assert skipped.success is True
assert skipped.answer == "Skipped: interests file not found at daily/2026-05-28/interests.yaml"
assert skipped.metadata["skipped"] is True
_touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", "topics: []\n")
with patch("reme.steps.evolve.dream.proactive.load_yaml_topics", side_effect=ValueError("bad topics")):
failed = await step(RuntimeContext(date="2026-05-28", file_store=_FileStore(tmp_path)))
assert failed.success is False
assert failed.answer == "Error: ValueError: bad topics"
assert failed.metadata["error"] == "ValueError: bad topics"
asyncio.run(run())
def test_finish_does_not_checkpoint_failed_changed_paths():
"""Finish does not checkpoint failed changed paths."""
@ -755,7 +525,6 @@ def test_finish_does_not_checkpoint_failed_changed_paths():
ok = _touch(workspace / "daily" / "2026-05-28" / "ok.md")
failed = _touch(workspace / "daily" / "2026-05-28" / "failed.md")
day_index = _touch(workspace / "daily" / "2026-05-28.md")
interests = _touch(workspace / "daily" / "2026-05-28" / "interests.yaml")
state = DreamState(
date="2026-05-28",
dates=["2026-05-26", "2026-05-27", "2026-05-28"],
@ -763,7 +532,6 @@ def test_finish_does_not_checkpoint_failed_changed_paths():
daily_dir="daily",
changed_paths=[ok.relative_to(workspace).as_posix(), failed.relative_to(workspace).as_posix()],
failed_paths=[failed.relative_to(workspace).as_posix()],
interests_paths=[interests.relative_to(workspace).as_posix()],
modified_paths=["digest/procedure/example.md"],
integrate_results=[
{
@ -785,7 +553,6 @@ def test_finish_does_not_checkpoint_failed_changed_paths():
assert "- [digest/procedure/example.md][CREATE]: Created a concise procedure node." in resp.answer
assert ok.relative_to(workspace).as_posix() in upserted
assert failed.relative_to(workspace).as_posix() not in upserted
assert interests.relative_to(workspace).as_posix() in upserted
assert day_index.relative_to(workspace).as_posix() in upserted
assert catalog.dumps == 1
assert resp.metadata["modified"] is True

View file

@ -109,6 +109,18 @@ def test_default_config_registers_workspace_web_jobs():
assert jobs["chat"]["steps"] == [{"backend": "chat_step", "agent_wrapper": "default"}]
def test_default_config_registers_manual_and_scheduled_proactive_refresh():
"""Proactive refresh is locally runnable while only the scheduled job starts automatically."""
jobs = _load_config("default.yaml")["jobs"]
manual = jobs["proactive_refresh"]
scheduled = jobs["proactive_refresh_cron"]
assert manual["backend"] == "base"
assert manual["enable_serve"] is False
assert scheduled["backend"] == "cron"
assert manual["steps"] == scheduled["steps"]
def test_default_config_keeps_frontmatter_chunk_metadata_opt_in():
"""Markdown frontmatter-to-chunk metadata is disabled by default for compatibility."""
cfg = _load_config("default.yaml")

File diff suppressed because it is too large Load diff