refactor(proactive): expand topic kinds from two to four

Give each kind one job: follow_up reports progress on the user's unfinished
or still-tracked matters, interest_extend recommends knowledge-base content
that grows the wiki network, reminder reviews the user's own schedule plus
must-tell notices and key events from external resources, and new_idea
proposes delegatable work grounded in capabilities the agent already has.
Reminders are the priority kind and always enter today's agenda.

- extract: two-stage round (batched recall, then one adjudication pass per
  kind), on by default and fail-open per kind
- schedules: long-lived registry so an appointment noted weeks ago still
  fires; only evidence-anchored schedule_updates may move a due date
- agenda/plan: separate reminder and discovery budgets, so a reminder-heavy
  day still surfaces discovery items

Includes the fixes surfaced by the smoke runs: ISO coercion of unquoted YAML
dates, per-entry salvage of a malformed truth source, refine move guards for
notice-anchored reminders, no revert of an authoritative reschedule,
re-anchored reminder due dates, resource-path anchors for schedule updates,
pin reasons that name the real cause, empty verdicts read as a clean reply,
and one registration per matter.
This commit is contained in:
imrewce 2026-10-12 15:36:14 +08:00
parent 084c02e43a
commit c0bad3d30e
16 changed files with 4231 additions and 225 deletions

View file

@ -100,13 +100,33 @@ jobs:
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
profile_max_chars: 1500
capability_max_chars: 1200
extends_enabled: true
reminders_enabled: true
new_ideas_enabled: true
max_follow_ups: 5
max_extends: 5
max_new_ideas: 3
reminder_lead_days: 3
resources_enabled: true
resource_max_chars: 40000
max_notice_reminders: 3
refine_enabled: true
refine_timeout_seconds: 150
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
min_reminder_confidence: 0.3
max_topics: 10
schedule_retention_days: 180
max_schedules: 80
max_schedule_history: 8
- backend: proactive_plan_step
max_reminder_cards: 8
- backend: proactive_agenda_step
min_discovery_slots: 2
- backend: proactive_finish_step
file_catalog: proactive
@ -122,13 +142,33 @@ jobs:
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
profile_max_chars: 1500
capability_max_chars: 1200
extends_enabled: true
reminders_enabled: true
new_ideas_enabled: true
max_follow_ups: 5
max_extends: 5
max_new_ideas: 3
reminder_lead_days: 3
resources_enabled: true
resource_max_chars: 40000
max_notice_reminders: 3
refine_enabled: true
refine_timeout_seconds: 150
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
min_reminder_confidence: 0.3
max_topics: 10
schedule_retention_days: 180
max_schedules: 80
max_schedule_history: 8
- backend: proactive_plan_step
max_reminder_cards: 8
- backend: proactive_agenda_step
min_discovery_slots: 2
- backend: proactive_finish_step
file_catalog: proactive

View file

@ -9,6 +9,7 @@ from .dream import (
)
from .proactive import (
ProactiveResult,
ProactiveSchedule,
ProactiveState,
ProactiveStateFile,
ProactiveTopic,
@ -42,6 +43,7 @@ __all__ = [
"IntegrateOutcome",
"JobConfig",
"ProactiveResult",
"ProactiveSchedule",
"ProactiveState",
"ProactiveStateFile",
"ProactiveTopic",

View file

@ -6,9 +6,28 @@ validated structurally by ``parse_extract_reply`` instead of a model here.
See ``PROACTIVE_SPEC.md`` sections F1/A2 for the full contracts.
"""
import datetime as dt
from pydantic import BaseModel, ConfigDict, Field, field_validator
TOPIC_KINDS = ("follow_up", "interest_extend")
# Topic kinds, ordered by proactive priority: ``reminder`` outranks the rest
# and pins today's agenda, ``interest_extend`` is the lowest-priority
# discovery kind. ``KIND_PRIORITY`` is the single ordering source used by
# ``sort_topics`` and by the plan/agenda steps.
TOPIC_KINDS = ("reminder", "follow_up", "new_idea", "interest_extend")
KIND_PRIORITY = {kind: index for index, kind in enumerate(TOPIC_KINDS)}
DEFAULT_KIND = "interest_extend"
# Reminder registry (``daily/_proactive.yaml: schedules``) vocabularies. The
# registry outlives ``open_topics`` because a schedule mentioned weeks ago
# still has to fire on its due date.
SCHEDULE_ACTIONS = ("add", "reschedule", "confirm", "done", "cancel")
SCHEDULE_STATUSES = ("open", "done", "cancelled")
RECURRENCES = ("", "daily", "weekly", "monthly", "yearly")
REMINDER_TRIGGERS = ("overdue", "due_today", "due_soon", "conflict")
# Keys inside the untyped ``history`` audit trail that hold a date/datetime.
HISTORY_WHEN_KEYS = ("at", "from", "to")
def clamp_confidence(value) -> float:
@ -19,6 +38,29 @@ def clamp_confidence(value) -> float:
return 0.5
def iso_text(value) -> str:
"""Coerce a YAML-native scalar into the ISO text the string fields expect.
``yaml.safe_load`` reads an unquoted ``2026-10-11`` as ``datetime.date`` and
``2026-10-11T20:00`` as ``datetime.datetime``. The truth source is a
hand-editable file and the schedule registry is meant to outlive topics by
months, so a bare date must never invalidate an entry: date and time
scalars are rendered back to ISO text, anything else falls back to ``str``.
"""
if value is None:
return ""
if isinstance(value, (dt.datetime, dt.date, dt.time)):
return value.isoformat()
if isinstance(value, str):
return value
return str(value)
def kind_priority(kind) -> int:
"""Priority rank of a topic kind; unknown kinds sort last."""
return KIND_PRIORITY.get(str(kind or "").strip(), len(TOPIC_KINDS))
class ProactiveTopic(BaseModel):
"""One proactive topic; every field has a default so v1 files parse seamlessly.
@ -31,24 +73,33 @@ class ProactiveTopic(BaseModel):
id: str = ""
title: str = ""
reason: str = ""
kind: str = "interest_extend"
kind: str = DEFAULT_KIND
confidence: float = 0.5
first_seen: str = ""
last_evidence_at: str = ""
evidence: str = ""
paths: list[str] = Field(default_factory=list)
# Reminder-only links: ``schedule_id`` points at the long-lived registry
# entry, ``due_at`` is the ISO date/datetime the reminder is about.
schedule_id: str = ""
due_at: str = ""
@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"
return text if text in TOPIC_KINDS else DEFAULT_KIND
@field_validator("confidence", mode="before")
@classmethod
def _fallback_confidence(cls, value):
return clamp_confidence(value)
@field_validator("first_seen", "last_evidence_at", "due_at", mode="before")
@classmethod
def _iso_when(cls, value):
return iso_text(value)
@field_validator("paths", mode="before")
@classmethod
def _clean_str_list(cls, value):
@ -57,6 +108,86 @@ class ProactiveTopic(BaseModel):
return [str(item).strip() for item in value if str(item).strip()]
class ProactiveSchedule(BaseModel):
"""One personal schedule/appointment in the long-lived reminder registry.
The registry is a separate truth-source section from ``open_topics``: a
schedule survives topic trimming (``carry_forward_days``) until its due
date has passed by ``schedule_retention_days``, and every change the
pipeline observes is appended to ``history`` so a reschedule or a
cancellation stays auditable long after the originating daily note left
the extract scan window.
"""
model_config = ConfigDict(extra="ignore")
id: str = ""
title: str = ""
detail: str = ""
due_at: str = ""
due_end_at: str = ""
recurrence: str = ""
# 0 = "not specified": the reminder scan then applies the step-level
# ``reminder_lead_days`` default instead of a per-schedule value.
lead_days: int = 0
status: str = "open"
confidence: float = 0.5
first_seen: str = ""
last_seen_at: str = ""
evidence: str = ""
paths: list[str] = Field(default_factory=list)
history: list[dict] = Field(default_factory=list)
@field_validator("status", mode="before")
@classmethod
def _fallback_status(cls, value):
text = str(value or "").strip()
return text if text in SCHEDULE_STATUSES else "open"
@field_validator("recurrence", mode="before")
@classmethod
def _fallback_recurrence(cls, value):
text = str(value or "").strip().lower()
return text if text in RECURRENCES else ""
@field_validator("due_at", "due_end_at", "first_seen", "last_seen_at", mode="before")
@classmethod
def _iso_when(cls, value):
return iso_text(value)
@field_validator("confidence", mode="before")
@classmethod
def _fallback_confidence(cls, value):
return clamp_confidence(value)
@field_validator("lead_days", mode="before")
@classmethod
def _fallback_lead_days(cls, value):
try:
return max(0, min(int(value), 365))
except (TypeError, ValueError):
return 0
@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()]
@field_validator("history", mode="before")
@classmethod
def _clean_history(cls, value):
if not isinstance(value, list):
return []
cleaned = []
for item in value:
if not isinstance(item, dict):
continue
cleaned.append({k: iso_text(v) if k in HISTORY_WHEN_KEYS else v for k, v in item.items()})
return cleaned
class ProactiveState(BaseModel):
"""Chain-shared proactive context state (``context['proactive']``).
@ -77,20 +208,49 @@ class ProactiveState(BaseModel):
carry_forward_days: int = 14
changed_paths: list[str] = Field(default_factory=list)
changed_mtimes: dict[str, float] = Field(default_factory=dict)
# External source records (``resource/``) scanned for user-facing notices.
# They feed the reminder branch only, but share ``changed_mtimes`` and the
# proactive catalog watermark with the daily material set, so a notice that
# lands on a day without any conversation still triggers a round.
resource_scanned: int = 0
resource_skipped: int = 0
resource_changed: list[str] = Field(default_factory=list)
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)
reminders: list[dict] = Field(default_factory=list)
new_ideas: list[dict] = Field(default_factory=list)
updates: list[dict] = Field(default_factory=list)
schedule_updates: list[dict] = Field(default_factory=list)
# Stage-2 adjudication (precision pass over stage-1 candidates). Every
# failure is fail-open, so ``refine_degraded`` counts the kinds whose
# stage-1 candidates were kept untouched.
refine_calls: int = 0
refine_degraded: int = 0
refine_dropped: list[dict] = Field(default_factory=list)
refine_moved: list[dict] = Field(default_factory=list)
early_exit: str = ""
# ``reminder_only`` marks a clock-driven round with no new material: the
# chain stays alive (no ``early_exit``) but made zero LLM calls.
reminder_only: bool = False
due_schedules: list[dict] = Field(default_factory=list)
updates_applied: int = 0
updates_resolved: int = 0
schedules_added: int = 0
schedules_updated: int = 0
schedules_closed: int = 0
# Reminders whose published due value was re-read from the registry because
# this round's ``schedule_updates`` moved it after the clock scan.
reminders_reanchored: int = 0
schedules_out: list[dict] = Field(default_factory=list)
candidates_in: int = 0
candidates: list[dict] = Field(default_factory=list)
dropped_missing: int = 0
dropped_duplicate: int = 0
dropped_known: int = 0
dropped_over_cap: 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)
@ -110,6 +270,8 @@ class ProactiveStateFile(BaseModel):
``resolved`` tombstones carry ``first_seen`` so a resurrected topic can
keep its original age anchor (F2.4 reopen channel).
``schedules`` is the long-lived reminder registry; it is optional so
files written before the reminder branch existed still load.
"""
model_config = ConfigDict(extra="ignore")
@ -117,6 +279,7 @@ class ProactiveStateFile(BaseModel):
version: int = 1
open_topics: list[ProactiveTopic] = Field(default_factory=list)
resolved: list[dict] = Field(default_factory=list)
schedules: list[ProactiveSchedule] = Field(default_factory=list)
class ProactiveResult(BaseModel):

View file

@ -14,6 +14,11 @@ 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``.
Reminder priority is enforced here, not negotiated: every reminder card is
pinned to the front of the agenda (nearest due date first) before the editor
LLM sees anything, and only the slots the reminders leave are up for
selection. Pinned items are never suppressed and never counted as "silence".
"""
import asyncio
@ -37,6 +42,32 @@ from .utils import (
)
FALLBACK_ORDER_REASON = "deterministic fallback: freshness/confidence order"
PINNED_REMINDER_REASONS = {
"due": "reminder pinned: a due schedule always enters today's agenda",
"new": "reminder pinned: a new reminder always enters today's agenda",
"open": "reminder pinned: an open reminder always enters today's agenda",
}
def _pin_reason(candidate: dict, day: str = "") -> str:
"""Order reason for a pinned reminder, naming the due date when known.
Every reminder is pinned, but not for the same reason: the audit trail
distinguishes a schedule the clock shows as due/overdue from one this round
merely found (whose due date may still be weeks away), so a reader can tell
a calendar fact from a freshness rule.
"""
due = str(candidate.get("due_at") or "").strip()
due_day, day = due[:10], str(day or "").strip()[:10]
first_seen = str(candidate.get("first_seen") or "").strip()[:10]
if due_day and day and due_day <= day:
cause = "due"
elif first_seen and day and first_seen >= day:
cause = "new"
else:
cause = "open"
base = PINNED_REMINDER_REASONS[cause]
return f"{base} (due {due})" if due else base
@R.register("proactive_agenda_step")
@ -51,6 +82,7 @@ class ProactiveAgendaStep(BaseStep):
def __init__(
self,
max_agenda_items: int = 6,
min_discovery_slots: int = 2,
llm_timeout_seconds: float = 120,
profile_rel_path: str = "profile.md",
profile_max_chars: int = 2500,
@ -59,6 +91,7 @@ class ProactiveAgendaStep(BaseStep):
):
super().__init__(**kwargs)
self.max_agenda_items = max(int(max_agenda_items), 1)
self.min_discovery_slots = max(int(min_discovery_slots), 0)
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)
@ -91,27 +124,64 @@ class ProactiveAgendaStep(BaseStep):
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]
# Reminders are pinned: every reminder - a due schedule or a freshly
# found one - always reaches today's agenda and is never traded away for
# narrative coherence. What the editor LLM gets
# to choose is the remaining (discovery) part of the agenda, inside the
# slots the pinned reminders leave - but never fewer than
# ``min_discovery_slots``, so a reminder-heavy day still surfaces the
# discovery kinds instead of collapsing into a plain calendar. On such a
# day the agenda may exceed ``max_agenda_items``: pinned reminders are
# additive by design, and dropping either side would break a guarantee.
pinned = [c for c in carded if str(c.get("kind")) == "reminder"]
optional = [c for c in carded if str(c.get("kind")) != "reminder"]
pinned_ids = [str(c.get("id")) for c in pinned]
slots = max(self.max_agenda_items - len(pinned_ids), self.min_discovery_slots)
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}",
f"over_budget={len(over_budget)} "
f"pinned_reminders={len(pinned_ids)} slots={slots} "
f"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"}
if not optional:
selected_ids, order_reasons, suppressed_reasons = [], {}, {}
elif slots == 0:
selected_ids, order_reasons = [], {}
suppressed_reasons = {
str(c.get("id")): f"agenda capacity reserved for {len(pinned_ids)} reminder(s)" for c in optional
}
elif len(optional) == 1:
selected_ids = [str(optional[0].get("id"))]
order_reasons = {selected_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)
selected_ids, order_reasons, suppressed_reasons = await self._llm_agenda(
state,
ws,
optional,
cards_by_id,
slots,
)
for cid in pinned_ids:
order_reasons.setdefault(
cid,
_pin_reason(next((c for c in pinned if str(c.get("id")) == cid), {}), state.date),
)
agenda_ids = pinned_ids + [cid for cid in selected_ids if cid not in pinned_ids]
suppressed: list[dict] = []
for candidate in over_budget:
is_reminder = str(candidate.get("kind")) == "reminder"
suppressed.append(
{
"topic_id": str(candidate.get("id")),
"title": str(candidate.get("title") or ""),
"reason": "over plan budget: not expanded into a scenario card",
"reason": (
"over reminder card budget: not expanded into a scenario card"
if is_reminder
else "over plan budget: not expanded into a scenario card"
),
},
)
for candidate in carded:
@ -136,12 +206,15 @@ class ProactiveAgendaStep(BaseStep):
{
"topic_id": cid,
"title": str(candidate.get("title") or ""),
"kind": str(candidate.get("kind") 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 []),
**({"due_at": str(candidate.get("due_at"))} if candidate.get("due_at") else {}),
**({"schedule_id": str(candidate.get("schedule_id"))} if candidate.get("schedule_id") else {}),
"order_reason": order_reasons.get(cid) or "",
},
)
@ -182,13 +255,19 @@ class ProactiveAgendaStep(BaseStep):
ws,
carded: list[dict],
cards_by_id: dict[str, dict],
slots: int,
) -> tuple[list[str], dict[str, str], dict[str, str]]:
"""One list-wise LLM call; returns (agenda ids, order reasons, suppressed reasons)."""
"""One list-wise LLM call over the non-pinned cards.
Returns ``(agenda ids, order reasons, suppressed reasons)``. ``slots``
is what the pinned reminders left of ``max_agenda_items``, and it is
both the cap told to the model and the hard truncation limit.
"""
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)
return self._fallback_agenda(carded_ids, slots)
cards_json = json.dumps(
[
@ -207,12 +286,12 @@ class ProactiveAgendaStep(BaseStep):
)
user_message = self.prompt_format(
"agenda_user_message",
max_agenda_items=self.max_agenda_items,
max_agenda_items=slots,
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)
system_prompt = self.prompt_format("agenda_system_prompt", max_agenda_items=slots)
state.plan_llm_calls += 1
try:
result = await asyncio.wait_for(
@ -221,12 +300,12 @@ class ProactiveAgendaStep(BaseStep):
)
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)
return self._fallback_agenda(carded_ids, slots)
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)
return self._fallback_agenda(carded_ids, slots)
raw = agent_reply_result_text(result)
agenda_raw, suppressed_raw = parse_agenda_reply(raw)
@ -236,7 +315,7 @@ class ProactiveAgendaStep(BaseStep):
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:
if len(agenda_ids) >= slots:
break
order_reasons = {
str(item.get("topic_id") or "").strip(): str(item.get("order_reason") or "").strip()
@ -250,12 +329,12 @@ class ProactiveAgendaStep(BaseStep):
}
if not agenda_ids:
self.logger.warning(f"[{self.name}] agenda reply unusable; raw={raw[:200]!r}")
return self._fallback_agenda(carded_ids)
return self._fallback_agenda(carded_ids, slots)
return agenda_ids, order_reasons, suppressed_reasons
def _fallback_agenda(self, carded_ids: list[str]) -> tuple[list[str], dict[str, str], dict[str, str]]:
def _fallback_agenda(self, carded_ids: list[str], slots: int) -> 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]
agenda_ids = carded_ids[: max(int(slots), 0)]
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

View file

@ -4,10 +4,16 @@ agenda_system_prompt: |
candidates), the files that changed today (a proxy for what the user is
focused on right now) and an optional user profile.
Reminder cards are NOT in your input: a due schedule is pinned to today's
agenda by the pipeline and cannot be traded away. You are editing the
remaining discovery slots.
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.
When {max_agenda_items} is 0 the pinned reminders already fill today's
agenda: suppress every card with that reason instead of forcing one in.
## Editorial rules
@ -21,6 +27,10 @@ agenda_system_prompt: |
- 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.
- Quote every YAML string containing punctuation such as `:` - an unquoted
`order_reason` with a colon in it makes the whole reply unparseable, and
the agenda then degrades to a deterministic order. Keep both reasons to one
short sentence.
## Output format
@ -38,9 +48,14 @@ agenda_system_prompt_zh: |
你是个人记忆助手当日主动议程的编辑。输入是场景卡(由推送候选扩展而来)、
今天发生变化的文件(代表用户当前关注点)以及可选的用户画像。
提醒类(reminder)场景卡不在你的输入里:到期日程已由流程固定进入当日议程,
不可被替换或压制。你编辑的是剩下的发现类名额。
决定哪些卡片进入今日议程、按什么顺序,哪些暂缓。议程条数由你自行裁决:
依据内容丰富度和重复程度决定,最少 1 条,至多不超过 {max_agenda_items} 条。
少而强胜过多而弱。
当 {max_agenda_items} 为 0 时,说明固定提醒已占满今日议程:请把每张卡都
放入 suppressed 并写明该原因,不要硬塞。
## 编辑规则
@ -52,6 +67,9 @@ agenda_system_prompt_zh: |
压制它并在 reason 中说明。
- 沉默可审计:每个候选 topic_id 必须恰好出现一次,要么在 agenda,要么在
suppressed(附简短理由)。禁止不声不响地丢弃任何候选。
- 包含 `:` 等标点的 YAML 字符串请加引号——`order_reason` 里出现未加引号的
冒号会让整份回复无法解析,议程随之退化为确定性排序。两类理由都请控制在一
句话以内。
## 输出格式

View file

@ -1,4 +1,25 @@
"""Proactive refresh extract step: material scan + follow_ups/extends/updates (F2.0-F2.3)."""
"""Proactive refresh extract step: material scan + four proposal kinds (F2.0-F2.3).
The step owns discovery. It reads the changed daily notes plus three cheap
read-only views - the carry-forward topics, the long-lived schedule registry
and the digest capability inventory - together with the current business
clock, and asks for one sectioned reply covering four proposal kinds:
``follow_ups`` open loops: propose fetching the latest status and reporting;
``extends`` knowledge-base recommendations: propose a wiki node to expand;
``reminders`` personal schedule review: propose due/conflicting reminders,
plus must-tell notices found in changed ``resource/`` records;
``new_ideas`` delegatable work the user has not thought of: propose only.
Only the reminder branch reads the ``resource/`` layer, which is the reserved
hook for external source interfaces: whatever lands there becomes eligible for
the schedule review without any further change to the chain.
Reminders additionally have a deterministic, clock-driven path
(``reminder_candidates_from_view``) so a due schedule still reaches today's
agenda when there is no new material, no agent wrapper, or an unusable reply.
Nothing here executes a proposal; the output stays a proposal end to end.
"""
import asyncio
import json
@ -9,25 +30,55 @@ 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 .refine import apply_verdicts, candidate_payload, candidates_by_kind, parse_refine_reply
from .schedules import (
DUE_TRIGGERS,
cap_notice_reminders,
clean_reminder,
clean_schedule_update,
merge_reminders,
reminder_candidates_from_view,
schedule_prompt_entries,
schedule_view,
)
from .utils import (
clean_candidate,
current_now,
load_capability_block,
load_carry_forward,
load_personal_profile_block,
load_state,
parse_extract_reply,
resolve_agent_wrapper,
scan_material_daily,
scan_material_resource,
)
DUE_SCHEDULE_KEYS = (
"id",
"title",
"status",
"due_at",
"next_due",
"due_in_days",
"trigger",
"conflict_with",
)
@R.register("proactive_extract_step")
class ProactiveExtractStep(BaseStep):
"""Scan the proactive material set and extract follow-ups, extends and updates.
"""Scan the proactive material set and extract the four proposal kinds.
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).
budget/timeout per F4.3; early-exits with zero LLM calls when no new
evidence exists (F2.0) - unless the clock produced due reminders, which
are delivered without any LLM call.
Per-kind ceilings keep discovery proportional (``max_follow_ups``,
``max_extends``, ``max_new_ideas``); reminders are deliberately uncapped
because they are bounded by the registry itself and pin today's agenda.
"""
def __init__(
@ -39,7 +90,21 @@ class ProactiveExtractStep(BaseStep):
max_chars_per_file: int = 60000,
max_total_chars: int = 300000,
profile_max_chars: int = 1500,
capability_max_chars: int = 1200,
extends_enabled: bool = True,
reminders_enabled: bool = True,
new_ideas_enabled: bool = True,
max_follow_ups: int = 5,
max_extends: int = 5,
max_new_ideas: int = 3,
reminder_lead_days: int = 3,
schedule_closed_lookback_days: int = 7,
resources_enabled: bool = True,
resource_scan_days: int = 0,
resource_max_chars: int = 40000,
max_notice_reminders: int = 3,
refine_enabled: bool = True,
refine_timeout_seconds: float = 90,
skip_key: str = "proactive_skip",
**kwargs,
):
@ -51,9 +116,26 @@ class ProactiveExtractStep(BaseStep):
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.capability_max_chars = max(int(capability_max_chars), 0)
self.extends_enabled = bool(extends_enabled)
self.reminders_enabled = bool(reminders_enabled)
self.new_ideas_enabled = bool(new_ideas_enabled)
self.max_follow_ups = max(int(max_follow_ups), 0)
self.max_extends = max(int(max_extends), 0)
self.max_new_ideas = max(int(max_new_ideas), 0)
self.reminder_lead_days = max(int(reminder_lead_days), 0)
self.schedule_closed_lookback_days = max(int(schedule_closed_lookback_days), 0)
self.resources_enabled = bool(resources_enabled)
# 0 inherits ``scan_days``: the notice window normally matches the
# conversation window, but can be widened on its own.
self.resource_scan_days = max(int(resource_scan_days), 0)
self.resource_max_chars = max(int(resource_max_chars), 0)
self.max_notice_reminders = max(int(max_notice_reminders), 0)
self.refine_enabled = bool(refine_enabled)
self.refine_timeout_seconds = float(refine_timeout_seconds)
self.skip_key = skip_key
# pylint: disable=too-many-return-statements
async def execute(self):
assert self.context is not None
if self.context.get(self.skip_key):
@ -71,10 +153,27 @@ class ProactiveExtractStep(BaseStep):
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}")
self.logger.info(
f"[{self.name}] start date={day} scan_days={self.scan_days} extends={self.extends_enabled} "
f"reminders={self.reminders_enabled} new_ideas={self.new_ideas_enabled}",
)
# 1) Material set M (F2.0) - all cheap, before any LLM work.
# 1) Material set M (F2.0) - all cheap, before any LLM work. Daily
# notes feed every branch. The ``resource/`` layer feeds the reminder
# branch only (external notices the user must be told about), so it is
# scanned only when reminders are active.
m_daily = scan_material_daily(ws, day, daily, self.scan_days)
notices = self.reminders_enabled and self.resources_enabled
if notices:
m_resource, resource_skipped = scan_material_resource(
ws,
day,
self.resource_scan_days or self.scan_days,
str(self.config_value("resource_dir")),
)
else:
m_resource, resource_skipped = [], 0
state.resource_scanned, state.resource_skipped = len(m_resource), resource_skipped
# 2) Truth source + carry-forward (F1.3/F1.4).
state_file, needs_bootstrap = load_state(ws, daily)
@ -90,57 +189,120 @@ class ProactiveExtractStep(BaseStep):
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.
# 3) Change detection against the proactive catalog watermark. Both
# material sets share one watermark, so an external notice arriving on
# a day without any conversation still triggers a round.
existing = {}
for rel in m_daily:
for rel in (*m_daily, *m_resource):
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]
changed = [rel for rel, mt in existing.items() if indexed.get(rel) != mt]
daily_set = set(m_daily)
state.changed_paths = [rel for rel in changed if rel in daily_set]
state.resource_changed = [rel for rel in changed if rel not in daily_set]
# 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}
state.changed_mtimes = {rel: existing[rel] for rel in changed}
self.logger.info(
f"[{self.name}] material daily={len(m_daily)} "
f"changed={len(state.changed_paths)} carry_forward={len(carry_all)}",
f"[{self.name}] material daily={len(m_daily)} resource={len(m_resource)} "
f"resource_skipped={resource_skipped} changed={len(state.changed_paths)} "
f"notices_changed={len(state.resource_changed)} carry_forward={len(carry_all)}",
)
# 4) Zero-consumption early exit (A4 row 1).
if not state.changed_paths:
# 4) Clock + schedule registry scan (branch C). Deterministic and free,
# so it runs before any LLM decision: a due schedule must reach today's
# agenda even when there is no new material, no agent wrapper, or an
# unusable reply.
now_dt = current_now(self)
view = schedule_view(state_file.schedules, now_dt, self.reminder_lead_days) if self.reminders_enabled else []
schedules_by_id = {str(entry.get("id") or ""): entry for entry in view if entry.get("id")}
seeded = reminder_candidates_from_view(view, day)
state.due_schedules = [
{key: entry.get(key) for key in DUE_SCHEDULE_KEYS} for entry in view if entry.get("trigger") in DUE_TRIGGERS
]
if seeded:
self.logger.info(
f"[{self.name}] clock scan: registry={len(state_file.schedules)} "
f"due={len(state.due_schedules)} seeded_reminders={len(seeded)} now={now_dt.isoformat()}",
)
# 5) Zero-consumption early exit (A4 row 1) - unless the clock alone
# already produced reminders, which need neither material nor an LLM.
if not state.changed_paths and not state.resource_changed:
if seeded:
return self._clock_only(state, started, seeded, "Skipped LLM: no new evidence")
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
# 6) 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:
if seeded:
return self._clock_only(state, started, seeded, "Degraded: no agent_wrapper configured")
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)
# 7) One LLM call with sectioned output (A3); retry once on parse failure.
# meta is None when this round produced no usable output (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, failure = await self._extract_with_retry(wrapper, state, ws, day, daily, now_dt, view)
if meta is None:
if seeded:
return self._clock_only(state, started, seeded, f"Degraded: {failure}")
self.context[self.skip_key] = {"reason": failure}
self._store(state)
return passthrough_response(self, self.skip_key)
self._clean_output(state, meta, set(m_daily), day)
self._clean_output(state, meta, set(m_daily), day, schedules_by_id, set(m_resource))
if self.refine_enabled:
await self._refine_stage(state, wrapper, ws, day, now_dt, view, set(m_resource))
state.reminders = merge_reminders(seeded, state.reminders)
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)"
f"{len(state.reminders)} reminder(s), {len(state.new_ideas)} idea(s), "
f"{len(state.updates)} update(s), {len(state.schedule_updates)} schedule update(s) "
f"from {len(state.changed_paths)} changed file(s) "
f"and {len(state.resource_changed)} external notice file(s)"
)
if state.refine_calls:
answer += (
f"; refine: {state.refine_calls} pass(es), dropped {len(state.refine_dropped)}, "
f"moved {len(state.refine_moved)}, degraded {state.refine_degraded}"
)
return self._finish(state, started, answer)
def _build_messages(self, state: ProactiveState, ws, day: str, daily: str, material_blob: str) -> tuple[str, str]:
def _clock_only(self, state: ProactiveState, started: float, seeded: list[dict], reason: str):
"""Deliver clock-driven reminders without consuming this round's material.
``changed_paths`` is cleared so the finish step checkpoints nothing and
the unread material stays "changed" for the next round; the chain still
runs, so today's agenda gets its reminders.
"""
state.changed_paths, state.resource_changed, state.changed_mtimes = [], [], {}
state.reminders = seeded
state.reminder_only = True
self.logger.warning(f"[{self.name}] {reason}; delivering {len(seeded)} clock-driven reminder(s)")
return self._finish(state, started, f"{reason}; {len(seeded)} clock-driven reminder(s), 0 LLM output used")
def _build_messages(
self,
state: ProactiveState,
ws,
day: str,
daily: str,
material_blob: str,
notice_blob: str,
now_dt,
view: list[dict],
) -> tuple[str, str]:
carry_forward_json = json.dumps(
[
{
@ -157,20 +319,43 @@ class ProactiveExtractStep(BaseStep):
ensure_ascii=False,
)
changed = list(dict.fromkeys(state.changed_paths))
notices = list(dict.fromkeys(state.resource_changed))
schedules_json = json.dumps(
schedule_prompt_entries(view, self.schedule_closed_lookback_days, day),
ensure_ascii=False,
)
user_message = self.prompt_format(
"extract_user_message",
date=day,
clock=f"{now_dt.isoformat(timespec='minutes')} ({now_dt.strftime('%A')})",
resource_dir=str(self.config_value("resource_dir")),
changed_paths_json=json.dumps(changed, ensure_ascii=False),
carry_forward_json=carry_forward_json,
schedules_json=schedules_json,
material_blob=material_blob,
profile_block=self._profile_block(ws),
capability_block=self._capability_block(ws),
resource_paths_json=json.dumps(notices, ensure_ascii=False),
resource_block=notice_blob or "(no external notices in this round)",
extends=self.extends_enabled,
reminders=self.reminders_enabled,
ideas=self.new_ideas_enabled,
resources=self.reminders_enabled and self.resources_enabled,
)
system_prompt = self.prompt_format(
"extract_system_prompt",
workspace_dir=str(ws),
daily_dir=daily,
resource_dir=str(self.config_value("resource_dir")),
output_shape=self._output_shape(),
max_follow_ups=self.max_follow_ups,
max_extends=self.max_extends,
max_new_ideas=self.max_new_ideas,
max_notice_reminders=self.max_notice_reminders,
extends=self.extends_enabled,
reminders=self.reminders_enabled,
ideas=self.new_ideas_enabled,
resources=self.reminders_enabled and self.resources_enabled,
)
return user_message, system_prompt
@ -179,8 +364,41 @@ class ProactiveExtractStep(BaseStep):
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."""
def _capability_block(self, ws) -> str:
"""Digest procedure/wiki inventory; grounds the ``new_ideas`` branch."""
digest_dir = str(self.config_value("digest_dir"))
return load_capability_block(ws, digest_dir, self.capability_max_chars)
def _output_shape(self) -> str:
"""YAML sample listing exactly the active sections.
Composed from per-section snippets rather than inline ``[flag]`` lines:
the flag filter left-strips kept lines, which would flatten the
indentation of a nested YAML example.
"""
names = ["follow_ups"]
if self.extends_enabled:
names.append("extends")
if self.reminders_enabled:
names.append("reminders")
if self.new_ideas_enabled:
names.append("new_ideas")
names.append("updates")
if self.reminders_enabled:
names.append("schedule_updates")
return "\n".join(self.get_prompt(f"extract_shape_{name}") for name in names)
async def _extract_with_retry(
self,
wrapper,
state,
ws,
day: str,
daily: str,
now_dt,
view,
) -> tuple[dict | None, str]:
"""Returns ``(meta, failure_reason)``; meta None means no usable output."""
# 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]
@ -190,48 +408,111 @@ class ProactiveExtractStep(BaseStep):
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)
# External notices are packed under their own, smaller budget so a
# large upload can neither crowd out the conversation material nor be
# crowded out by it.
notice_blob = pack_paths(
ws,
list(dict.fromkeys(state.resource_changed))[::-1],
limit_per_file=self.max_chars_per_file,
max_total_chars=self.resource_max_chars or None,
)
user_message, system_prompt = self._build_messages(
state,
ws,
day,
daily,
material_blob,
notice_blob,
now_dt,
view,
)
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
return None, "llm_timeout"
meta = parse_extract_reply(raw)
if meta:
return 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}",
f"[{self.name}] reply unusable after 2 attempts; keeping material un-checkpointed; raw={raw[:200]!r}",
)
self.context[self.skip_key] = {"reason": "extract_parse_failed"}
return None
return None, "extract_parse_failed"
async def _reply(self, wrapper, state, user_message, system_prompt):
async def _reply(self, wrapper, state, user_message, system_prompt, timeout: float | None = None):
"""One timeout-wrapped reply; returns raw text or None on timeout."""
state.llm_calls += 1
limit = self.llm_timeout_seconds if timeout is None else float(timeout)
try:
result = await asyncio.wait_for(
wrapper.reply(user_message, system_prompt=system_prompt),
timeout=self.llm_timeout_seconds,
timeout=limit,
)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
self.logger.warning(f"[{self.name}] LLM reply timed out after {limit}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
def _clean_output(
self,
state: ProactiveState,
meta: dict,
allowed: set[str],
day: str,
schedules_by_id: dict,
notice_allowed: set[str] | None = None,
) -> None:
# Only the reminder branch may cite the ``resource/`` layer: the other
# three kinds reason about the user's own conversations, and letting
# them quote an external upload would blur that boundary.
reminder_allowed = set(allowed) | set(notice_allowed or ())
self._append_kind(
state.follow_ups,
meta.get("follow_ups"),
allowed,
"follow_up",
day,
self.max_follow_ups,
state,
)
if self.extends_enabled:
for raw in meta.get("extends") or []:
if candidate := clean_candidate(raw, allowed, "interest_extend", day):
state.extends.append(candidate)
self._append_kind(
state.extends,
meta.get("extends"),
allowed,
"interest_extend",
day,
self.max_extends,
state,
)
if self.reminders_enabled:
cleaned: list[dict] = []
for raw in meta.get("reminders") or []:
if candidate := clean_reminder(raw, reminder_allowed, day, schedules_by_id, notice_allowed):
cleaned.append(candidate)
else:
state.dropped_missing += 1
# Dateless notice/key-event reminders are bounded; clock-anchored
# ones are not (they are facts, and they pin today's agenda).
state.reminders, over_cap = cap_notice_reminders(cleaned, self.max_notice_reminders)
state.dropped_over_cap += over_cap
for raw in meta.get("schedule_updates") or []:
if update := clean_schedule_update(raw, reminder_allowed, day):
state.schedule_updates.append(update)
else:
state.dropped_missing += 1
if self.new_ideas_enabled:
self._append_kind(
state.new_ideas,
meta.get("new_ideas"),
allowed,
"new_idea",
day,
self.max_new_ideas,
state,
)
for raw in meta.get("updates") or []:
if not isinstance(raw, dict):
continue
@ -251,6 +532,142 @@ class ProactiveExtractStep(BaseStep):
},
)
def _append_kind(
self,
bucket: list[dict],
raw_items,
allowed: set[str],
kind: str,
day: str,
cap: int,
state: ProactiveState,
) -> None:
"""Clean one discovery section into ``bucket``, honouring its ceiling.
``cap <= 0`` means unlimited. Over-cap entries are counted separately
from invalid ones so a noisy round stays diagnosable in metadata.
"""
for raw in raw_items or []:
if cap and len(bucket) >= cap:
state.dropped_over_cap += 1
continue
if candidate := clean_candidate(raw, allowed, kind, day):
bucket.append(candidate)
else:
state.dropped_missing += 1
# ------------------------------------------------------------------
# Stage 2: per-kind adjudication (precision pass)
# ------------------------------------------------------------------
def _enabled_kinds(self) -> set[str]:
"""Kinds this round may hold candidates in (the legal ``move`` targets)."""
return {
kind
for kind, active in (
("follow_up", True),
("interest_extend", self.extends_enabled),
("reminder", self.reminders_enabled),
("new_idea", self.new_ideas_enabled),
)
if active
}
def _kind_caps(self) -> dict[str, int]:
"""Per-kind ceilings re-applied after stage-2 moves (0 = uncapped)."""
return {
"follow_up": self.max_follow_ups,
"interest_extend": self.max_extends,
"new_idea": self.max_new_ideas,
"reminder": 0,
}
def _cap_label(self, kind: str) -> str:
"""Ceiling as the refine prompt should state it."""
if kind != "reminder":
return str(self._kind_caps()[kind])
return f"uncapped, but at most {self.max_notice_reminders} dateless notice/key-event reminder(s)"
async def _refine_stage(self, state, wrapper, ws, day: str, now_dt, view, notice_paths: set[str]) -> None:
"""One small adjudication call per kind that produced candidates.
Stage 2 never sees the material blob: it receives that kind's candidates
plus the only context slice the kind needs, which keeps the pass cheap
and its judgement focused. Each pass is independently fail-open, so a
timeout or an unusable reply keeps the stage-1 candidates of that kind.
"""
verdicts: list[dict] = []
for kind, bucket in candidates_by_kind(state):
known = {str(candidate.get("id") or "") for candidate in bucket}
user_message = self.prompt_format(
"refine_user_message",
kind=kind,
cap=self._cap_label(kind),
checks=self.get_prompt(f"refine_checks_{kind}"),
candidates_json=json.dumps(candidate_payload(bucket), ensure_ascii=False),
context_block=self._refine_context(kind, state, ws, view, day, now_dt),
)
system_prompt = self.prompt_format("refine_system_prompt", kind=kind)
raw = await self._reply(wrapper, state, user_message, system_prompt, self.refine_timeout_seconds)
state.refine_calls += 1
if raw is None:
state.refine_degraded += 1
self.logger.warning(f"[{self.name}] refine({kind}) timed out; keeping stage-1 candidates")
continue
parsed = parse_refine_reply(raw)
if parsed is None:
state.refine_degraded += 1
self.logger.warning(f"[{self.name}] refine({kind}) reply unusable; raw={raw[:160]!r}")
continue
# A pass may not reach into another kind's bucket: ids outside it
# are ignored rather than trusted.
verdicts.extend(verdict for verdict in parsed if verdict["id"] in known)
if not verdicts:
# Nothing to adjudicate: every pass answered "keep everything".
# The caps only need re-applying when a move could have crossed a
# kind boundary, but the summary is worth logging either way.
self.logger.info(
f"[{self.name}] refine passes={state.refine_calls} dropped=0 moved=0 "
f"degraded={state.refine_degraded} no verdicts; kept: "
f"reminders={len(state.reminders)} follow_ups={len(state.follow_ups)} "
f"ideas={len(state.new_ideas)} extends={len(state.extends)}",
)
return
apply_verdicts(state, verdicts, self._kind_caps(), self._enabled_kinds(), notice_paths)
state.reminders, over_cap = cap_notice_reminders(state.reminders, self.max_notice_reminders)
state.dropped_over_cap += over_cap
self.logger.info(
f"[{self.name}] refine passes={state.refine_calls} dropped={len(state.refine_dropped)} "
f"moved={len(state.refine_moved)} degraded={state.refine_degraded} kept: "
f"reminders={len(state.reminders)} follow_ups={len(state.follow_ups)} "
f"ideas={len(state.new_ideas)} extends={len(state.extends)}",
)
def _refine_context(self, kind: str, state, ws, view, day: str, now_dt) -> str:
"""The single context slice this kind's adjudication needs (no material)."""
if kind == "reminder":
entries = schedule_prompt_entries(view, self.schedule_closed_lookback_days, day)
return (
f"now: {now_dt.isoformat(timespec='minutes')} ({now_dt.strftime('%A')})\n"
"schedules (registry, triggers already computed from the clock):\n"
f"{json.dumps(entries, ensure_ascii=False)}"
)
if kind == "follow_up":
carried = [
{"id": t.id, "title": t.title, "kind": t.kind, "last_evidence_at": t.last_evidence_at}
for t in state.carry_forward_prompt
]
return (
"carry_forward_topics (already tracked; a candidate that restates one of these "
f"must be dropped):\n{json.dumps(carried, ensure_ascii=False)}"
)
if kind == "new_idea":
return f"capability_inventory (what the agent can actually do):\n{self._capability_block(ws)}"
return (
"knowledge_base inventory (existing wiki nodes; a candidate that merely restates one "
f"of these must be dropped):\n{self._capability_block(ws)}"
)
def _store(self, state: ProactiveState) -> None:
assert self.context is not None
data = state.model_dump()

View file

@ -1,27 +1,111 @@
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.
You read recently changed conversation notes, the user's schedule registry
and the current clock, and you propose what the assistant should bring up
next. You never browse the web, never write files, never collect material
yourself, and never execute anything: every entry you emit is a PROPOSAL
that the host agent may raise with the user.
workspace_dir: {workspace_dir}
## Branch A - follow_ups (open loops)
## The four kinds and their division of labour
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.
Each branch below has its own section, its own job and its own ceiling.
Every branch is optional; a quiet day produces empty lists. Never let one
branch borrow another's job (a due schedule is a reminder, not a follow-up;
a wiki node worth expanding is an extend, not an idea).
## Branch A - follow_ups (kind: follow_up)
Scope: matters the user assigned, or keeps caring about, that are NOT closed
yet - tasks started/interrupted/postponed, questions asked but never
answered, commitments mentioned without follow-through. Carry-forward topics
already track the long-lived ones, so prefer `updates` for those and open a
`follow_ups` entry only for a matter no carry-forward topic covers.
Job: state the latest progress visible in the material, then propose going
and fetching the current status so the assistant can report back to the user
on its own initiative ("let me check where X stands and tell you"). The
deliverable is a report, never a silent completion of the work.
Emit each as a `follow_ups` entry.
[extends]## Branch B - extends (interest boundary)
[extends]## Branch B - extends (kind: interest_extend)
[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]Scope: content recommendation for the user's personal knowledge
[extends]base. Starting from a topic the user already cares about (see the
[extends]wiki inventory in the user message), name one refinement or
[extends]extension of it that is worth adding as a new wiki node: a
[extends]sub-topic, an adjacent concept, a comparison, a missing prerequisite.
[extends]Job: propose introducing and walking the user through that content.
[extends]You only describe the node to expand; you never collect or write it.
[extends]Emit each as an `extends` entry.
[reminders]## Branch C - reminders (kind: reminder) + schedule_updates
[reminders]
[reminders]Scope: the user's personal plans and calendar. You are given
[reminders]`schedules` (a long-lived registry that may hold items mentioned
[reminders]weeks ago) and `now` (the current clock). Your jobs:
[reminders]1. Review - compare the registry and the material against the
[reminders] clock, then propose a reminder for what is due soon, due today,
[reminders] already overdue, or clashing with another entry. Warn about a
[reminders] possible conflict explicitly and say which entries collide.
[reminders] Also propose the upcoming todos the user would want to be
[reminders] nudged about. Propose only: never claim a calendar was changed.
[reminders]2. Maintain - whenever the material adds, moves, confirms,
[reminders] completes or cancels a schedule, emit a `schedule_updates`
[reminders] entry. The registry is the only structure that lives long
[reminders] enough to fire on a due date mentioned long ago, so a schedule
[reminders] you do not register will never be reminded.
[reminders] Title an entry by the matter alone, never by its date or time:
[reminders] the id is derived from the title, so a date in it mints a brand
[reminders] new registry entry every time that schedule moves, and the user
[reminders] ends up reminded twice about one appointment.
[resources]3. Notices and key events - scan `resource_notices` (external
[resources] source records that changed this round: whatever an interface
[resources] dropped into `{resource_dir}/` - mail, feeds, exports,
[resources] documents) and pick out only what the user MUST be told today.
[resources] Two shapes count:
[resources] a) a direct notice about the user's own affairs - a deadline or
[resources] date that moved, a cancellation, a bill or payment falling
[resources] due, a confirmation that needs action;
[resources] b) a key event that materially affects the user even though
[resources] nobody addressed it to them - a policy or rule change, an
[resources] incident/recall/security advisory, a development in a matter
[resources] they track, a market or supply shift they are exposed to.
[resources] Emit each as a `reminders` entry citing that resource path. Give
[resources] `due_at` whenever the item has a date, and register that dated
[resources] commitment via `schedule_updates` so it keeps firing from the
[resources] registry. A key event with no date may omit `due_at`; at most
[resources] {max_notice_reminders} such dateless entries survive a round, so
[resources] make them count. Merely interesting reading material is neither
[resources] a notice nor a key event; leave it out.
[reminders]Traceability: a reminder about a registered schedule cites it via
[reminders]`schedule_id` and may leave `paths` empty (its evidence is the
[reminders]clock). A reminder about a brand-new schedule must cite
[reminders]changed_paths and carry `due_at`.
[resources]A notice-driven reminder cites its resource_paths entry instead.
[reminders]Reminders are the highest-priority kind: every reminder you emit
[reminders]enters today's agenda, so they are not capped - but never invent a
[reminders]schedule to justify one, and never repeat a schedule the registry
[reminders]already shows as done or cancelled.
[reminders]Emit reminders as `reminders` entries and registry maintenance as
[reminders]`schedule_updates` entries.
[ideas]## Branch D - new_ideas (kind: new_idea)
[ideas]
[ideas]Scope: work the user probably does not realise can be delegated.
[ideas]Combine what the agent recently executed, the data it collected and
[ideas]the experience/skills/tools/APIs it already has (the capability
[ideas]inventory in the user message) with what the material shows the user
[ideas]is working on, then propose one concrete piece of work worth handing
[ideas]over - the kind of task a user would never think to ask for.
[ideas]Job: describe the deliverable, why it is valuable right now, and what
[ideas]existing resource makes it feasible. Stop at the proposal: never
[ideas]execute it and never imply it is already done.
[ideas]Ground every idea in a capability or a dataset that actually appears
[ideas]in the inventory or the material; an idea needing resources nobody
[ideas]has is noise.
[ideas]Emit each as a `new_ideas` entry.
## updates (carried-forward topics)
For every topic in carry_forward_topics, decide exactly one action:
@ -41,6 +125,7 @@ extract_system_prompt: |
## Confidence rubric (fixed; do not freestyle scores)
- 0.9: explicit unfinished action with a time or commitment;
[reminders]- 0.8: a registered schedule the clock shows as due, overdue or clashing;
- 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;
@ -50,9 +135,21 @@ extract_system_prompt: |
- 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.
- Ceilings are not targets, and entries over a ceiling are discarded
downstream: at most {max_follow_ups} follow_ups.
[extends]- Ceiling: at most {max_extends} extends.
[ideas]- Ceiling: at most {max_new_ideas} new_ideas.
[reminders]- Reminders are uncapped, but each one must be a real finding of
[reminders]the schedule review - never invent a schedule to justify one.
- Every topic must be traceable: `paths` may only contain values from
changed_paths (paths look like `{daily_dir}/<date>/...`); entries violating
this are discarded.
[reminders]- Traceability exception: a `reminders` entry citing a registered
[reminders]`schedule_id` may take its paths from the registry instead.
[resources]- Traceability exception: `reminders` and `schedule_updates` may also
[resources]cite resource_paths (`{resource_dir}/...`). No other section may.
- user_profile and the capability inventory are background knowledge: use
them to judge relevance, never cite them in paths/evidence.
- 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
@ -69,45 +166,93 @@ extract_system_prompt: |
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>
{output_shape}
```
extract_system_prompt_zh: |
你是个人记忆 workspace 的 proactive 发现 agent。你阅读近期变化的对话笔记
,维护一小组用户兴趣主题。你绝不联网、
绝不写文件、绝不自行收集资料。
你是个人记忆 workspace 的 proactive 发现 agent。你阅读近期变化的对话笔记、
用户日程注册表与当前时钟,产出"下一步值得主动提起什么"的提议。你绝不联网、
绝不写文件、绝不自行收集资料、绝不执行任何动作:你输出的每一条都是提议
(proposal),由宿主 agent 决定是否向用户提起。
workspace_dir: {workspace_dir}
## 分支 A - follow_ups(未决事项 / open loop)
## 四类标签及其职责边界
找出用户可能仍想关闭的未决事项:
- 提出过但没有得到回答的问题;
- 开始过但被中断或搁置的任务;
- 提到过但没有后续动作的承诺或计划。
下面每个分支有自己的 section、职责与条数上限。每个分支都是可选的,平静的
一天就应该输出空列表。禁止串味:到期的日程属于 reminder 而不是 follow_up;
值得展开的知识节点属于 interest_extend 而不是 new_idea。
## 分支 A - follow_ups(kind: follow_up)
范围:用户指派过、或持续关心但尚未关闭的事项——开始过却被中断/搁置的任务、
提出过但没有回答的问题、提到过却没有后续动作的承诺。carry_forward_topics
已经跟踪长期未决事项,这类请优先用 `updates` 处置,只有当没有任何
carry-forward 主题覆盖时,才新开 `follow_ups` 条目。
职责:先说清素材里能看到的最新进展,然后提议去检索该事项的最新情况,以便
助手主动向用户汇报("我去确认一下 X 现在到哪一步了,再告诉你")。交付物是
一次汇报,绝不是悄悄替用户把事做完。
每条作为一个 `follow_ups` 条目输出。
[extends]## 分支 B - extends(兴趣边界扩展)
[extends]## 分支 B - extends(kind: interest_extend)
[extends]
[extends]推断用户尚未关注、但与其近期工作大概率相关的主题,依据是对话笔记
[extends]。它们用于指引未来的知识源扩展;你只描述主题,不收集任何资料。
[extends]范围:面向个人知识库的内容推荐。从用户已经关心的主题出发(见用户
[extends]消息中的 wiki 节点清单),指出一个值得作为新 wiki 节点补上的细化或
[extends]延伸方向:子话题、相邻概念、横向对比、缺失的前置知识。
[extends]职责:提议向用户介绍并展示这部分内容。你只描述要扩展哪个节点,
[extends]绝不自行收集或撰写内容。
[extends]每条作为一个 `extends` 条目输出。
[reminders]## 分支 C - reminders(kind: reminder)+ schedule_updates
[reminders]
[reminders]范围:用户的个人计划与日程。输入包含 `schedules`(长生命周期
[reminders]注册表,可能有几周前提到的条目)与 `now`(当前时钟)。职责如下:
[reminders]1. 检查回顾——把注册表与素材对照时钟检查一遍,对即将到期、今天
[reminders] 到期、已经逾期、或与其它日程冲突的安排提出提醒;冲突要明确
[reminders] 预警并指出是哪几条相撞;同时提议提醒即将到来的 todos。
[reminders] 只做提议:绝不声称已经改动了任何日历。
[reminders]2. 维护注册表——素材中出现新增、改期、确认、完成、取消日程时,
[reminders] 必须输出 `schedule_updates` 条目。注册表是唯一能活到"很久以
[reminders] 前提到的到期日"那天的结构;不登记的日程永远不会被提醒。
[reminders] 条目标题只写事项本身,绝不要带日期或时间:id 由标题推导,标题
[reminders] 里含日期会让这条日程每次改期都在注册表里新增一条,同一个约定
[reminders] 就会被提醒两次。
[resources]3. 通知与关键事件——通读 `resource_notices`(本轮发生变化的外部
[resources] 信源记录,即外部信源接口投递到 `{resource_dir}/` 的邮件、订阅
[resources] 源、导出件、文档等),只挑出"今天必须告知用户"的内容。两种形态
[resources] 都算:
[resources] a) 直接通知——与用户自身事务相关的:时间/截止日被改动、被取消、
[resources] 账单或付款到期、需要动作的确认件;
[resources] b) 关键事件——虽不是发给用户本人、但对其有实质影响的:政策或规则
[resources] 变化、事故/召回/安全公告、其持续跟踪事项的重要进展、其所暴露
[resources] 的市场或供应变化。
[resources] 每条作为 `reminders` 输出并引用该 resource 路径。带日期的一律给出
[resources] `due_at`,并用 `schedule_updates` 把该承诺登记进注册表,使其在素材
[resources] 离开扫描窗口后仍能按到期日触发。没有日期的关键事件可以省略
[resources] `due_at`;这类无日期条目每轮最多保留 {max_notice_reminders} 条,
[resources] 请挑真正重要的。仅仅是"有点意思"的阅读材料既不是通知也不是关键
[resources] 事件,不要输出。
[reminders]可追溯性:针对已登记日程的提醒用 `schedule_id` 引用,`paths`
[reminders]可以为空(它的证据是时钟);针对全新日程的提醒必须引用
[reminders]changed_paths 并给出 `due_at`。
[resources]来自外部通知的提醒改为引用 resource_paths 中的路径。
[reminders]reminder 是四类中优先级最高的:你输出的每条 reminder 都会进入
[reminders]当日 agenda,因此不设条数上限——但绝不为了凑提醒编造日程,也
[reminders]绝不重复注册表中已完成/已取消的条目。
[reminders]提醒输出为 `reminders` 条目,注册表维护输出为 `schedule_updates`。
[ideas]## 分支 D - new_ideas(kind: new_idea)
[ideas]
[ideas]范围:用户根本没想到"这事可以委托 AI 办"的工作事项。把 agent 最近
[ideas]执行的结果、收集的数据、已积累的经验/skill/工具/API(见用户消息中
[ideas]的能力清单)与素材里用户正在做的事情结合起来,提议一件具体、值得
[ideas]交出去做的工作。
[ideas]职责:说清交付物是什么、为什么现在有价值、依靠哪个已有资源才可行。
[ideas]停留在提议:绝不执行,也绝不暗示已经做完。
[ideas]每个想法必须落在能力清单或素材里真实存在的能力/数据上;需要谁都没有
[ideas]的资源才能完成的想法属于噪音。
[ideas]每条作为一个 `new_ideas` 条目输出。
## updates(carry-forward 主题处置)
对 carry_forward_topics 中的每个主题,恰好选择一个动作:
@ -125,6 +270,7 @@ extract_system_prompt_zh: |
## confidence 评分规则(固化,禁止自由打分)
- 0.9:有明确未完成动作且带时间/承诺;
[reminders]- 0.8:注册表中已登记、且时钟显示即将到期/已逾期/存在冲突的日程;
- 0.7:用户显式表达意图但无明确动作;
- 0.5:推断性关联(多条弱信号);
- 0.3:弱联想;
@ -134,8 +280,20 @@ extract_system_prompt_zh: |
- 空列表是正常且预期的输出。不得为凑数编造主题,不得把一次性弱提及
拔高为主题。
- 每个主题必须能追溯到本轮素材:`paths` 只能使用 changed_paths 中的值
(路径形如 `{daily_dir}/<date>/...`),越界条目会被丢弃。
- 上限是天花板而不是目标,超出上限的条目会在下游被丢弃:follow_ups 至多
{max_follow_ups} 条。
[extends]- 上限:extends 至多 {max_extends} 条。
[ideas]- 上限:new_ideas 至多 {max_new_ideas} 条。
[reminders]- reminders 不设上限,但每条都必须来自真实的日程检查结论,
[reminders]绝不为了凑提醒编造日程。
- 每个主题必须可追溯:`paths` 只能使用 changed_paths 中的值(路径形如
`{daily_dir}/<date>/...`),越界条目会被丢弃。
[reminders]- 可追溯性例外:引用了已登记 `schedule_id` 的 `reminders` 条目,
[reminders]其 paths 可以来自注册表。
[resources]- 可追溯性例外:`reminders` 与 `schedule_updates` 还可以引用
[resources]resource_paths(`{resource_dir}/...`);其他 section 不可以。
- user_profile 与能力清单属于背景知识:只用于判断相关度,禁止引用进
paths/evidence。
- 不要复述 carry_forward_topics 中已 open 的主题;要改变其状态请用
`updates`。
- 同一件事规则:若新发现其实只是某个 carry_forward 主题的换种说法,
@ -151,61 +309,427 @@ extract_system_prompt_zh: |
只返回一个 YAML fenced block,结构必须严格如下:
```yaml
{output_shape}
```
extract_user_message: |
date: {date}
now (current clock in the business timezone; the reference for every due
judgement): {clock}
changed_paths (the ONLY allowed values for `paths`):
{changed_paths_json}
[resources]resource_paths (external source records changed this round; also
[resources]allowed in `paths`, but ONLY for reminders/schedule_updates):
[resources]{resource_paths_json}
carry_forward_topics (already open; only act on them via updates):
{carry_forward_json}
[reminders]schedules (long-lived registry; `next_due`, `due_in_days`,
[reminders]`trigger` and `conflict_with` are already computed from the clock
[reminders]above; recently closed entries are shown so you do not re-add
[reminders]them):
[reminders]{schedules_json}
user_profile (background knowledge about the user's identity, preferences
and constraints; use it ONLY to judge relevance and confidence - it is NOT
material, never cite it in paths/evidence):
{profile_block}
[ideas]capability_inventory (reusable experience/skills and knowledge nodes
[ideas]the agent already has; background for new_ideas only - it is NOT
[ideas]material, never cite it in paths/evidence):
[ideas]{capability_block}
# Material
{material_blob}
[resources]# External notices ({resource_dir}/, reminder branch only)
[resources]
[resources]{resource_block}
Emit the sections your active branches define, per the contract:
- follow_ups and updates
[extends]- extends
[reminders]- reminders and schedule_updates
[ideas]- new_ideas
Remember: every section is optional, empty lists are fine, never invent
topics, and never execute a proposal.
extract_user_message_zh: |
日期:{date}
当前时钟(业务时区;所有"是否到期"判断的基准):{clock}
changed_paths(`paths` 唯一允许的取值):
{changed_paths_json}
[resources]resource_paths(本轮变化的外部信源记录;也允许写进 `paths`,
[resources]但仅限 reminders/schedule_updates):
[resources]{resource_paths_json}
carry_forward_topics(已 open 的主题;只能通过 updates 处置):
{carry_forward_json}
[reminders]schedules(长生命周期日程注册表;其中 `next_due`、`due_in_days`、
[reminders]`trigger`、`conflict_with` 已按上面的时钟算好;近期已关闭的条目
[reminders]也会列出,避免你重复登记):
[reminders]{schedules_json}
用户画像(用户身份、偏好与约束的背景知识;仅用于判断相关度与
confidence——它不是素材,禁止引用进 paths/evidence):
{profile_block}
[ideas]能力清单(agent 已经具备的可复用经验/skill 与已有知识节点;仅供
[ideas]new_ideas 参考——它不是素材,禁止引用进 paths/evidence):
[ideas]{capability_block}
# 本轮素材
{material_blob}
[resources]# 外部通知({resource_dir}/,仅供 reminder 分支使用)
[resources]
[resources]{resource_block}
按契约输出当前启用分支对应的 section:
- follow_ups 与 updates
[extends]- extends
[reminders]- reminders 与 schedule_updates
[ideas]- new_ideas
记住:每个 section 都是可选的,空列表是正常输出,绝不编造主题,也绝不
执行任何提议。
# ---------------------------------------------------------------------------
# Output-shape snippets, composed by ``_output_shape`` for the active branches
# only. They are separate keys (not ``[flag]`` lines) so the flag filter's
# left-strip cannot flatten the YAML indentation of the example.
# ---------------------------------------------------------------------------
extract_shape_follow_ups: |
follow_ups:
- title: <specific unresolved matter>
reason: <latest progress seen in the material, and what to go fetch and report>
confidence: 0.7
paths:
- <changed path>
extract_shape_extends: |
extends:
- title: <related-but-unfocused topic>
reason: <which cared-about topic it refines or extends, and the wiki node it would add>
confidence: 0.5
paths:
- <changed path>
extract_shape_reminders: |
reminders:
- title: <the scheduled matter>
reason: <why now: due soon / due today / overdue / clashes with what>
due_at: <YYYY-MM-DD or YYYY-MM-DDTHH:MM>
schedule_id: <registry id; omit only for a brand-new schedule>
conflict_with: [<title or schedule_id it clashes with>]
confidence: 0.8
paths:
- <changed path; may be empty when schedule_id is given>
extract_shape_new_ideas: |
new_ideas:
- title: <a concrete piece of work worth delegating>
reason: <the deliverable, why now, and which existing skill/tool/data makes it feasible>
confidence: 0.5
paths:
- <changed path>
extract_shape_updates: |
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_shape_schedule_updates: |
schedule_updates:
- id: <registry id; omit for action=add>
action: add|reschedule|confirm|done|cancel
title: <required for add; the matter alone, never its date or time>
due_at: <YYYY-MM-DD or YYYY-MM-DDTHH:MM; required for add and reschedule>
due_end_at: <optional end of a range>
recurrence: <omit or one of daily|weekly|monthly|yearly>
lead_days: <optional; how many days ahead to start reminding>
detail: <optional short note>
evidence: <path from changed_paths, or the notice path; required, it anchors the change>
reason: <short justification>
extract_shape_follow_ups_zh: |
follow_ups:
- title: <具体的未决事项>
reason: <为什么仍未关闭>
reason: <素材中看到的最新进展,以及要去检索并汇报什么>
confidence: 0.7
paths: [<素材路径>, ...]
[extends]extends:
[extends] - title: <相关但未被关注的主题>
[extends] reason: <为什么重要,基于素材>
[extends] confidence: 0.5
[extends] paths: [<素材路径>, ...]
paths:
- <素材路径>
extract_shape_extends_zh: |
extends:
- title: <相关但尚未被关注的主题>
reason: <它细化/延伸了哪个已关心主题,会补上哪个 wiki 节点>
confidence: 0.5
paths:
- <素材路径>
extract_shape_reminders_zh: |
reminders:
- title: <对应的日程事项>
reason: <为什么是现在:即将到期 / 今天到期 / 已逾期 / 与哪条冲突>
due_at: <YYYY-MM-DD 或 YYYY-MM-DDTHH:MM>
schedule_id: <注册表 id;仅全新日程可省略>
conflict_with: [<与之冲突的标题或 schedule_id>]
confidence: 0.8
paths:
- <素材路径;给了 schedule_id 时可以为空>
extract_shape_new_ideas_zh: |
new_ideas:
- title: <一件具体、值得委托出去的工作>
reason: <交付物是什么、为什么是现在、依靠哪个已有 skill/工具/数据才可行>
confidence: 0.5
paths:
- <素材路径>
extract_shape_updates_zh: |
updates:
- id: <回显的 carry-forward id>
action: keep|update|resolve
evidence: <路径或 路径#锚点>
reason: <简短理由>
confidence: <按 rubric 重新打分;action=update 时给出,其余可省略>
extract_shape_schedule_updates_zh: |
schedule_updates:
- id: <注册表 id;action=add 时可省略>
action: add|reschedule|confirm|done|cancel
title: <add 时必填;只写事项本身,不要带日期或时间>
due_at: <YYYY-MM-DD 或 YYYY-MM-DDTHH:MM;add 与 reschedule 必填>
due_end_at: <可选的时间段结束点>
recurrence: <可省略,或 daily|weekly|monthly|yearly 之一>
lead_days: <可选;提前多少天开始提醒>
detail: <可选的简短备注>
evidence: <changed_paths 中的路径,或通知所来自的外部信源路径;必填,它是证据锚点>
reason: <简短理由>
# ---------------------------------------------------------------------------
# Stage 2: per-kind adjudication prompts (``_refine_stage``). One small call per
# kind that produced candidates. The material blob is never re-sent: a pass gets
# its own candidates plus the single context slice that kind needs.
# ---------------------------------------------------------------------------
refine_system_prompt: |
You are the quality gate of the proactive discovery stage of a personal
memory system. An earlier pass already read today's material and proposed
candidates of kind `{kind}`. You do NOT re-read the material, and you do NOT
write new candidates: you adjudicate the list you are given.
For each candidate decide exactly one of:
- `keep`: it belongs to this kind, it is grounded, and it is worth raising;
- `drop`: reject it, and say why in one short clause;
- `move`: it is unmistakably another kind's job - name `to_kind`.
You may also rescore `confidence` with the fixed rubric when the proposer
clearly over- or under-rated it. Never rewrite `title` or `reason`: the
wording the user will see has already been produced.
## Rubric (fixed; do not freestyle scores)
- 0.9: explicit unfinished action with a time or commitment;
- 0.8: a registered schedule the clock shows as due, overdue or clashing;
- 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: drop the candidate instead of scoring it.
## Hard rules
- Echo ids exactly; an id that is not in the candidate list is discarded.
- A candidate you do not mention is kept unchanged, so mention only the ones
you want to drop, move or rescore.
- Dropping is for real defects, not for taste: when in doubt, keep.
- The context block is background knowledge, never evidence; do not cite it.
- These stay proposals: never word a verdict as if work had been done.
- Quote YAML strings containing punctuation such as `:`.
## Output format
Return only one YAML fenced block:
```yaml
verdicts:
- id: <echoed id>
decision: keep|drop|move
to_kind: <only for move: reminder|follow_up|new_idea|interest_extend>
confidence: <optional rescore per the rubric>
reason: <required for drop; one short clause>
```
extract_user_message: |
date: {date}
refine_system_prompt_zh: |
你是个人记忆系统 proactive 发现阶段的质量闸门。前一遍已经读过今天的素材,
并产出了 kind 为 `{kind}` 的候选。你**不重读素材**,也**不新增候选**:
你只裁决给你的这份列表。
changed_paths (the ONLY allowed values for `paths`):
{changed_paths_json}
对每个候选恰好给出一个结论:
- `keep`:属于该 kind、有依据、值得提起;
- `drop`:驳回,并用一小句说明原因;
- `move`:它明显属于另一类职责——给出 `to_kind`。
carry_forward_topics (already open; only act on them via updates):
{carry_forward_json}
当 proposer 明显打高或打低时,你可以按固定评分规则重打 `confidence`。
绝不改写 `title` 或 `reason`:给用户看的措辞已经产出了。
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
- 0.9:有明确未完成动作且带时间/承诺;
- 0.8:注册表中已登记、且时钟显示即将到期/已逾期/存在冲突的日程;
- 0.7:用户显式表达意图但无明确动作;
- 0.5:推断性关联(多条弱信号);
- 0.3:仅弱关联;
- 低于 0.3:直接 drop,不要打分。
{material_blob}
## 硬约束
Extract follow_ups[extends] / extends / updates per the contract. Remember:
empty lists are fine; never invent topics.
- id 必须原样回显;不在候选列表中的 id 会被丢弃。
- 未被提及的候选按原样保留,所以只写你要 drop / move / 重打分的那几条。
- drop 只用于真实缺陷,不用于口味差异:拿不准就 keep。
- 上下文块是背景知识,不是证据,禁止引用。
- 这些仍然是提议:任何结论都不得写成"已经做完了"。
- 包含 `:` 等标点的 YAML 字符串请加引号。
extract_user_message_zh: |
日期:{date}
## 输出格式
changed_paths(`paths` 唯一允许的取值):
{changed_paths_json}
只返回一个 YAML fenced block:
```yaml
verdicts:
- id: <回显的 id>
decision: keep|drop|move
to_kind: <仅 move 时给出:reminder|follow_up|new_idea|interest_extend>
confidence: <可选,按评分规则重打>
reason: <drop 时必填;一小句>
```
carry_forward_topics(已 open 的主题;只能通过 updates 处置):
{carry_forward_json}
refine_user_message: |
kind under review: {kind}
ceiling for this kind: {cap}
用户画像(用户身份、偏好与约束的背景知识;仅用于判断兴趣相关度与
confidence——它不是素材,禁止引用进 paths/evidence):
{profile_block}
## What to reject for this kind
# 本轮素材
{checks}
{material_blob}
## Context for this kind only (background knowledge, never evidence)
按契约抽取 follow_ups[extends] / extends / updates。记住:空列表是正常
输出,绝不编造主题。
{context_block}
## Candidates to adjudicate
{candidates_json}
Return one YAML fenced block with a `verdicts` list, mentioning only the
candidates you want to drop, move or rescore.
refine_user_message_zh: |
本次裁决的 kind:{kind}
该类上限:{cap}
## 该类应当驳回什么
{checks}
## 仅供该类使用的上下文(背景知识,不是证据)
{context_block}
## 待裁决候选
{candidates_json}
只返回一个 YAML fenced block,其中 `verdicts` 列表只写你要 drop / move /
重打分的候选。
refine_checks_reminder: |
- Drop a reminder whose schedule the registry already shows as done or
cancelled, or whose due value contradicts the registry entry it cites.
- Drop one that is really an open loop on the user's own work (follow_up) or
a topic worth reading about (interest_extend) - but never one anchored on
the external source layer, which no other kind is allowed to quote.
- Keep an external notice or key event even when it also reads as interesting
background: "the user must be told" is exactly this kind's job, and a key
event that materially affects them is not merely reading material.
- Keep a dated reminder even when it looks minor: a clock fact about the
user's own calendar is exactly what this kind exists for.
- A dateless reminder must be an external notice or a key event the user has
to be told today; drop one that is merely interesting background, and
remember only a few dateless entries survive the ceiling.
- Move to follow_up only when the candidate tracks progress on a matter the
user assigned and has no schedule attached.
refine_checks_reminder_zh: |
- 若注册表已显示该日程完成或取消,或候选给出的到期值与其引用的注册表条目
矛盾,drop。
- 若它其实是用户自己工作的未闭环事项(follow_up),或只是值得阅读的主题
(interest_extend),drop;但锚定在外部信源上的候选例外——其他任何类都不
允许引用外部信源。
- 外部通知或关键事件即使同时读起来像"有意思的背景资料",也要 keep:"必须
告知用户"正是这一类的职责,实质影响用户的关键事件不只是阅读材料。
- 带日期的提醒即使看起来很小也要 keep:关于用户自己日程的时钟事实正是这一
类存在的意义。
- 无日期的提醒必须是"今天必须告知"的外部通知或关键事件;仅仅是有意思的背景
信息就 drop,并记住无日期条目受上限约束、只能留下少数几条。
- 仅当候选跟踪的是用户指派事项的进展且不带任何日程时,才 move 到 follow_up。
refine_checks_follow_up: |
- Drop a candidate that restates a carry_forward topic from the context:
long-lived open loops are maintained through updates, never re-proposed.
- Drop one with no visible open loop (nothing was assigned, asked, or left
unfinished); a general interest is interest_extend.
- Drop one whose "latest progress" is asserted rather than visible in its own
reason.
- Keep a candidate that is really a dated commitment instead of dropping it:
stage 2 cannot attach a due date, so the calendar stays the reminder
branch's job.
refine_checks_follow_up_zh: |
- 若候选只是复述上下文中的 carry_forward 主题,drop:长期未闭环事项通过
updates 维护,不应被重新提议。
- 若看不到任何未闭环动作(没有被指派、没有被提问、没有被搁置的事项),drop;
泛泛的兴趣属于 interest_extend。
- 若所谓"最新进展"只是断言、在其自身 reason 里看不到依据,drop。
- 若候选其实是一个带日期的承诺,keep 而不要 drop:第二阶段无法补上到期日,
日历始终由 reminder 分支负责。
refine_checks_new_idea: |
- Drop an idea that needs a capability, dataset, tool or API absent from the
capability inventory: it would be an offer nobody can fulfil.
- Drop one that is really a task the user already assigned (follow_up) or a
topic to read about (interest_extend).
- Drop one worded as a report of work already done; these stay proposals.
- Keep only ideas naming a concrete deliverable, and rescore vague ones down.
refine_checks_new_idea_zh: |
- 若想法依赖能力清单中不存在的经验/数据/工具/API,drop:那是没人能兑现的邀约。
- 若它其实是用户已经指派的任务(follow_up)或只是可读的主题(interest_extend),
drop。
- 若措辞像"已经做完了的汇报",drop;这些只能是提议。
- 只保留说清了具体交付物的想法;含糊的把 confidence 打低。
refine_checks_interest_extend: |
- Drop a candidate that merely restates a wiki node that already exists in the
inventory: an extend must add a refinement or a new adjacent node.
- Drop one the user has no visible interest in, so there is nothing to extend.
- Drop one that is a to-do or a schedule rather than content worth introducing.
- Move to new_idea only when the candidate is a piece of work to delegate
rather than content to explain.
refine_checks_interest_extend_zh: |
- 若候选只是复述清单里已存在的 wiki 节点,drop:extend 必须带来细化或新的
相邻节点。
- 若看不到用户的相关兴趣、无从延伸,drop。
- 若它其实是一条待办或日程,而不是值得介绍的内容,drop。
- 仅当候选是"可委托的一件工作"而不是"可讲解的内容"时,才 move 到 new_idea。

View file

@ -42,7 +42,7 @@ class ProactiveFinishStep(BaseStep):
return self.context.response
ws = workspace_dir(self)
checkpoint = [rel for rel in state.changed_paths if (ws / rel).is_file()]
checkpoint = [rel for rel in (*state.changed_paths, *state.resource_changed) if (ws / rel).is_file()]
snapshot = state.changed_mtimes
nodes: list[FileNode] = []
deferred = 0

View file

@ -1,19 +1,22 @@
"""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.
push (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.
explore_interest | prepare_upcoming | propose_delegation), ``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.
Reminders are expanded first and are never squeezed out by ``max_plan_topics``
(they are bounded by ``max_reminder_cards`` instead), because the agenda step
pins every reminder card it receives.
"""
import asyncio
@ -24,12 +27,18 @@ from ...base_step import BaseStep
from .._evolve import agent_reply_result_text, passthrough_response
from ....components import R
from ....schema import ProactiveState
from ....schema.proactive import DEFAULT_KIND
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")
SCENARIO_TYPES = ("resume_task", "answer_pending", "explore_interest", "prepare_upcoming", "propose_delegation")
DELIVERY_MODES = ("in_conversation", "notification", "agenda_item")
DEFAULT_SCENARIO_BY_KIND = {"follow_up": "resume_task", "interest_extend": "explore_interest"}
DEFAULT_SCENARIO_BY_KIND = {
"follow_up": "resume_task",
"interest_extend": "explore_interest",
"reminder": "prepare_upcoming",
"new_idea": "propose_delegation",
}
MAX_OPENER_CHARS = 300
MAX_NEXT_ACTION_CHARS = 200
MAX_PRECONDITIONS = 5
@ -52,10 +61,18 @@ def linked_memory(candidate: dict) -> list[str]:
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")
kind = str(candidate.get("kind") or DEFAULT_KIND)
title = str(candidate.get("title") or "")
scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, "explore_interest")
if kind == "follow_up":
scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, DEFAULT_SCENARIO_BY_KIND[DEFAULT_KIND])
if kind == "reminder":
due = str(candidate.get("due_at") or "").strip()
when = f"({due})" if due else ""
opener = f"对了,「{title}」{when}就快到了,要不要现在把准备事项先过一遍?"
next_action = "确认时间、所需材料与是否和其它安排冲突,需要改期就现在说"
elif kind == "new_idea":
opener = f"有件活儿我觉得可以替你办:「{title}」,要不要我先出个方案你看看?"
next_action = "确认值不值得委托;值得的话由 agent 先产出一版草案供你审阅"
elif kind == "follow_up":
opener = f"上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?"
next_action = "先回顾相关记录,确认卡点,然后给出下一步动作"
else:
@ -105,14 +122,19 @@ def _clean_card(raw: dict, candidate: dict) -> dict:
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.
Zero LLM calls when there are no push candidates. The two budgets are
independent: at most ``max_reminder_cards`` reminders plus
``max_plan_topics`` discovery candidates are expanded. A reminder-heavy day
therefore cannot starve the discovery kinds of cards, because reminders are
pinned into the agenda later and would otherwise take every slot with them.
The overflow stays card-less and the agenda step suppresses it with an
explicit reason.
"""
def __init__(
self,
max_plan_topics: int = 6,
max_reminder_cards: int = 8,
llm_timeout_seconds: float = 120,
profile_max_chars: int = 800,
skip_key: str = "proactive_skip",
@ -120,6 +142,7 @@ class ProactivePlanStep(BaseStep):
):
super().__init__(**kwargs)
self.max_plan_topics = max(int(max_plan_topics), 1)
self.max_reminder_cards = max(int(max_reminder_cards), 1)
self.llm_timeout_seconds = float(llm_timeout_seconds)
self.profile_max_chars = max(int(profile_max_chars), 0)
self.skip_key = skip_key
@ -149,10 +172,15 @@ class ProactivePlanStep(BaseStep):
self.logger.info(f"[{self.name}] no push candidates; skipping")
return self.context.response
selected = candidates[: self.max_plan_topics]
# Reminders first and outside the discovery budget: they pin today's
# agenda, so starving them here would silently break that guarantee.
reminders = [c for c in candidates if str(c.get("kind")) == "reminder"][: self.max_reminder_cards]
others = [c for c in candidates if str(c.get("kind")) != "reminder"]
selected = reminders + others[: 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}",
f"reminders={len(reminders)} max_plan_topics={self.max_plan_topics} "
f"max_reminder_cards={self.max_reminder_cards}",
)
cards_by_id = await self._llm_cards(state, selected)
@ -167,7 +195,7 @@ class ProactivePlanStep(BaseStep):
card = {
"topic_id": cid,
"title": str(candidate.get("title") or ""),
"kind": str(candidate.get("kind") or "interest_extend"),
"kind": str(candidate.get("kind") or DEFAULT_KIND),
**card,
"linked_memory": linked_memory(candidate),
}
@ -203,6 +231,8 @@ class ProactivePlanStep(BaseStep):
"confidence": c.get("confidence"),
"first_seen": c.get("first_seen"),
"evidence": c.get("evidence"),
**({"due_at": c.get("due_at")} if c.get("due_at") else {}),
**({"schedule_id": c.get("schedule_id")} if c.get("schedule_id") else {}),
}
for c in selected
],

View file

@ -12,25 +12,39 @@ plan_system_prompt: |
- `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.
- `prepare_upcoming`: something with a future time anchor to prepare for;
- `propose_delegation`: a piece of work worth handing over to the agent.
Map by kind first, then refine by semantics:
- `follow_up` -> resume_task / answer_pending; the card offers to fetch the
latest status and report back to the user;
- `interest_extend` -> explore_interest; the card offers to walk the user
through a refinement or extension of a topic they care about;
- `reminder` -> prepare_upcoming, naming the due date (and the clash to
resolve when the topic mentions one);
- `new_idea` -> propose_delegation, naming the deliverable the agent would
produce - an offer, never a report of work already done.
- `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.
A reminder opener still sounds like a person, but it may not hide the date:
the user has to hear when it is due.
- `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).
Reminders with a near or clashing due date belong on `notification` or
`agenda_item`, not buried in conversation.
## Hard rules
- Exactly one card per input topic, no extras.
- Ground everything in the given reason/evidence; never invent facts.
- Proposals stay proposals: never word a card as if the work were done.
- Quote YAML strings containing punctuation such as `:`.
- Write `opener` and `next_action` in the language the user's own material is
written in, so the assistant never switches tongues mid-conversation.
## Output format
@ -38,7 +52,7 @@ plan_system_prompt: |
```yaml
cards:
- topic_id: <echoed id>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation
opener: <one casual sentence pointing at the next action>
next_action: <smallest executable step>
preconditions: [<condition>, ...]
@ -53,27 +67,40 @@ plan_system_prompt_zh: |
## 每张卡的字段
- `topic_id`:原样回显给定 id。
- `scenario_type`:四选一
- `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。
- `prepare_upcoming`:带未来时间点、需要提前准备的事项;
- `propose_delegation`:一件值得委托给 agent 去办的工作。
先按 kind 映射,再按语义细化:
- `follow_up` -> resume_task / answer_pending(卡片要表达"我去查最新进展
再向你汇报");
- `interest_extend` -> explore_interest(卡片要表达"带你看看这个主题的
细化/延伸内容");
- `reminder` -> prepare_upcoming,必须点明到期时间,若主题提到冲突还要
点明要先协调哪一件事;
- `new_idea` -> propose_delegation,说清 agent 会交付什么——是邀约,
绝不能写成已经做完的汇报。
- `opener`:一句口语化的开场,像朋友随口提起("对了,上次那个……")。
严禁通知腔:"根据您之前的记录"、"系统提醒您"、"您在某天提到过"等。
开场必须顺势给出最小可执行的下一步,让人愿意动手,而不只是知道。
reminder 的开场同样要像人话,但不能藏时间:用户必须听到"什么时候到期"。
- `next_action`:用户现在就能开始的最小具体动作。
- `preconditions`:需要先满足的条件列表,可以为空。
- `delivery`:`in_conversation`(对话中自然带出)/ `notification`
(单独提醒)/ `agenda_item`(列入当日议程)。
临近到期或存在冲突的 reminder 应用 `notification` 或 `agenda_item`,
不要埋在闲聊里。
## 硬约束
- 每个输入主题恰好一张卡,不得多给。
- 所有内容必须基于给定的 reason/evidence,禁止编造事实。
- 提议就是提议:任何卡片都不得写成工作已经完成。
- 包含 `:` 等标点的 YAML 字符串请加引号。
- `opener` 与 `next_action` 使用用户素材本身所用的语言书写,避免助手在对话
中途切换语言。
## 输出格式
@ -81,7 +108,7 @@ plan_system_prompt_zh: |
```yaml
cards:
- topic_id: <回显的 id>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation
opener: <一句口语化开场,顺势给出下一步动作>
next_action: <最小可执行步骤>
preconditions: [<条件>, ...]

View file

@ -27,6 +27,22 @@ from .utils import (
)
def _above_floor(topic, min_confidence: float) -> bool:
"""Confidence floor check; clock-anchored reminders are exempt.
A reminder carrying a due value is a clock fact about the user's own
schedule, not a discovery guess, and the refresh chain already applied the
(lower) ``min_reminder_confidence`` when it pinned it to the agenda.
Filtering it again here would silently drop today's agenda item. A dateless
reminder - an external notice or a key event - is a judgement call, so it
still has to clear the reader's floor.
"""
get = topic.get if isinstance(topic, dict) else (lambda key: getattr(topic, key, ""))
if str(get("kind") or "") == "reminder" and str(get("due_at") or "").strip():
return True
return float(get("confidence") or 0.0) >= min_confidence - 1e-9
@R.register("proactive_step")
class ProactiveStep(BaseStep):
"""Read ``daily/<date>/interests.yaml`` (optionally merged over a horizon).
@ -119,7 +135,7 @@ class ProactiveStep(BaseStep):
kept = [
dump_topic(t)
for t in sort_topics(topics)
if t.id not in resolved_ids and t.confidence >= min_confidence - 1e-9
if t.id not in resolved_ids and _above_floor(t, min_confidence)
]
result.topics = kept
agenda_raw = data.get("agenda")
@ -142,7 +158,16 @@ class ProactiveStep(BaseStep):
# Multi-day merge (horizon_days > 1, or fallback target)
# ------------------------------------------------------------------
def _read_horizon(self, ws, daily, day, horizon, include_content, min_confidence, result: ProactiveResult):
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),
@ -171,7 +196,7 @@ class ProactiveStep(BaseStep):
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
and _above_floor(t, min_confidence)
)
]
kept = sort_topics(kept)

View file

@ -0,0 +1,196 @@
"""Stage-2 per-kind adjudication for proactive extraction (precision pass).
Stage 1 owns recall: one batched reply over the changed material produces the
candidates of all four kinds. Stage 2 owns precision: every enabled kind that
actually produced candidates gets one small, material-free call that re-checks
those candidates against the kind's own contract plus the only context slice
that kind needs (registry and clock for reminders, carry-forward topics for
follow-ups, capability/wiki inventory for ideas and extends).
A pass may keep, drop or move a candidate and rescore its confidence. It never
rewrites wording - stage 1 owns the user-facing text - and it never invents a
candidate. Every failure mode is fail-open: a timeout or an unusable reply keeps
the stage-1 candidates untouched, because losing a real finding costs more than
keeping a borderline one.
"""
from ....schema.proactive import TOPIC_KINDS, clamp_confidence
from .utils import parse_fenced_yaml
# Refine order = proactive priority order, which keeps the audit log readable:
# the highest-priority kind is adjudicated first.
REFINE_ORDER = ("reminder", "follow_up", "new_idea", "interest_extend")
DECISIONS = ("keep", "drop", "move")
# Fields only a reminder candidate carries. A move out of ``reminder`` has to
# drop them, otherwise the receiving kind publishes a ``schedule_id`` pointing
# at a registry entry that was never created plus a due date nothing fires on.
REMINDER_ONLY_FIELDS = ("schedule_id", "due_at", "next_due", "trigger", "conflict_with")
def _notice_anchored(item: dict, notice_paths: set[str]) -> bool:
"""True when the candidate cites the external source (``resource/``) layer."""
return bool({str(p) for p in (item.get("paths") or [])} & notice_paths)
def _reminder_contract_met(item: dict, notice_paths: set[str]) -> bool:
"""``clean_reminder``'s traceability rule: a due value or a notice anchor."""
return bool(str(item.get("due_at") or "").strip()) or _notice_anchored(item, notice_paths)
def candidates_by_kind(state) -> list[tuple[str, list[dict]]]:
"""``(kind, bucket)`` pairs in refine order, skipping empty buckets."""
buckets = kind_buckets(state)
return [(kind, buckets[kind]) for kind in REFINE_ORDER if buckets[kind]]
def kind_buckets(state) -> dict[str, list[dict]]:
"""Map each kind to the ``ProactiveState`` bucket holding its candidates."""
return {
"reminder": list(state.reminders),
"follow_up": list(state.follow_ups),
"new_idea": list(state.new_ideas),
"interest_extend": list(state.extends),
}
def candidate_payload(bucket: list[dict]) -> list[dict]:
"""Candidate fields worth adjudicating; no material, paths trimmed."""
out = []
for item in bucket:
payload = {
"id": str(item.get("id") or ""),
"title": str(item.get("title") or ""),
"reason": str(item.get("reason") or ""),
"confidence": clamp_confidence(item.get("confidence")),
"paths": [str(p) for p in (item.get("paths") or [])][:3],
}
for key in ("due_at", "schedule_id", "trigger"):
if str(item.get(key) or "").strip():
payload[key] = str(item.get(key))
conflicts = item.get("conflict_with")
if isinstance(conflicts, list) and conflicts:
payload["conflict_with"] = [str(c) for c in conflicts][:3]
out.append(payload)
return out
def parse_refine_reply(text: str) -> list[dict] | None:
"""Verdict list from a refine reply; ``None`` when the reply is unusable.
An empty list is a valid reply ("keep everything"); only a missing or
non-list ``verdicts`` key counts as unusable, which lets the caller keep
the stage-1 candidates instead of silently dropping a whole kind.
"""
payload = parse_fenced_yaml(text)
if "verdicts" not in payload:
return None
verdicts = payload["verdicts"]
if verdicts is None:
# `verdicts:` with nothing beneath it is how YAML spells the empty list,
# and an unmentioned candidate is kept - so this is the documented
# "nothing to change" reply, not a broken one. Counting it as unusable
# would flag the most common clean round as a degrade and hide a real
# stage-2 failure behind the noise.
return []
if not isinstance(verdicts, list):
return None
out = []
for raw in verdicts:
if not isinstance(raw, dict):
continue
cid = str(raw.get("id") or "").strip()
if not cid:
continue
decision = str(raw.get("decision") or "keep").strip().lower()
verdict = {
"id": cid,
"decision": decision if decision in DECISIONS else "keep",
"to_kind": str(raw.get("to_kind") or "").strip(),
"reason": str(raw.get("reason") or "").strip()[:200],
}
if raw.get("confidence") is not None:
verdict["confidence"] = clamp_confidence(raw.get("confidence"))
out.append(verdict)
return out
def apply_verdicts(
state,
verdicts: list[dict],
caps: dict[str, int],
enabled: set[str],
notice_paths: set[str] | None = None,
) -> None:
"""Rebuild the four buckets from the stage-2 verdicts, in place on ``state``.
A missing verdict means keep (fail-open), an unknown id is ignored, and a
move to an unknown or disabled kind is ignored rather than fatal. Caps are
re-applied afterwards in stage-1 order, so a move can never push a kind over
its ceiling.
Two moves are refused rather than applied, because stage 1 owns the
path/kind invariants and stage 2 must not break them:
* out of ``reminder`` for a candidate anchored on the external notice
layer - only the reminder branch may cite ``resource/``, so demoting it
would publish an external upload under a kind that may not quote one;
* into ``reminder`` for a candidate with neither a due value nor a notice
anchor - stage 2 cannot attach a date, so it could never satisfy
``clean_reminder``'s contract for a dateless reminder.
Any other successful move out of ``reminder`` strips the reminder-only
fields so no dangling registry link survives the reclassification.
"""
notices = {str(p) for p in (notice_paths or set())}
buckets = kind_buckets(state)
index: dict[str, tuple[str, dict]] = {}
for kind, bucket in buckets.items():
for item in bucket:
index.setdefault(str(item.get("id") or ""), (kind, item))
for verdict in verdicts:
entry = index.get(verdict["id"])
if entry is None:
continue
kind, item = entry
if verdict["decision"] == "drop":
buckets[kind] = [c for c in buckets[kind] if c is not item]
state.refine_dropped.append(_audit(verdict, kind, item))
elif verdict["decision"] == "move":
target = verdict["to_kind"]
if target not in TOPIC_KINDS or target not in enabled or target == kind:
continue
if kind == "reminder" and _notice_anchored(item, notices):
continue
if target == "reminder" and not _reminder_contract_met(item, notices):
continue
buckets[kind] = [c for c in buckets[kind] if c is not item]
item["kind"] = target
if kind == "reminder":
for field in REMINDER_ONLY_FIELDS:
item.pop(field, None)
buckets[target].append(item)
index[verdict["id"]] = (target, item)
state.refine_moved.append({**_audit(verdict, kind, item), "to_kind": target})
if "confidence" in verdict:
item["confidence"] = verdict["confidence"]
for kind, bucket in buckets.items():
cap = int(caps.get(kind, 0))
if cap and len(bucket) > cap:
state.dropped_over_cap += len(bucket) - cap
buckets[kind] = bucket[:cap]
state.reminders = buckets["reminder"]
state.follow_ups = buckets["follow_up"]
state.new_ideas = buckets["new_idea"]
state.extends = buckets["interest_extend"]
def _audit(verdict: dict, kind: str, item: dict) -> dict:
"""One audit record so a refine decision stays diagnosable in metadata."""
return {
"id": verdict["id"],
"kind": kind,
"title": str(item.get("title") or "")[:80],
"decision": verdict["decision"],
"reason": verdict["reason"],
}

File diff suppressed because it is too large Load diff

View file

@ -23,8 +23,18 @@ 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 ....schema.proactive import DEFAULT_KIND, clamp_confidence
from ..dream.utils import daily_dir, previous_dates, today, workspace_dir
from .schedules import (
apply_schedule_updates,
effective_due,
refresh_reminder_dues,
slim_schedule,
sort_reminders,
topic_from_candidate,
trim_schedules,
upsert_schedule_from_reminder,
)
from .utils import (
current_now,
dump_topic,
@ -61,6 +71,10 @@ class ProactiveTopicsStep(BaseStep):
dedup_lookback_days: int = 7,
digest_compare_limit: int = 500,
known_threshold_calibrated_for: str = "text-embedding-v4@1024",
min_reminder_confidence: float = 0.3,
schedule_retention_days: int = 180,
max_schedules: int = 80,
max_schedule_history: int = 8,
skip_key: str = "proactive_skip",
**kwargs,
):
@ -71,6 +85,10 @@ class ProactiveTopicsStep(BaseStep):
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.min_reminder_confidence = float(min_reminder_confidence)
self.schedule_retention_days = max(int(schedule_retention_days), 1)
self.max_schedules = max(int(max_schedules), 0)
self.max_schedule_history = max(int(max_schedule_history), 0)
self.skip_key = skip_key
# pylint: disable=too-many-statements
@ -93,7 +111,9 @@ class ProactiveTopicsStep(BaseStep):
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}",
f"reminders={len(state.reminders)} new_ideas={len(state.new_ideas)} "
f"updates={len(state.updates)} schedule_updates={len(state.schedule_updates)} "
f"carry_forward={state.carry_forward_count}",
)
state_file, _needs_bootstrap = load_state(ws, daily)
@ -105,6 +125,13 @@ class ProactiveTopicsStep(BaseStep):
# 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}
# The reminder branch is the only one allowed to cite the ``resource/``
# layer and ``clean_schedule_update`` already accepts those paths, so
# the registry anchor has to agree with it: without the notice paths a
# dated external notice could never be registered through the explicit
# channel and would only reach the registry via the reminder safety
# net, losing its detail/end-time/recurrence and its own reason.
schedule_material = new_material | {norm_path(str(path).split("#", 1)[0]) for path in state.resource_changed}
for update in state.updates:
topic = open_by_id.get(str(update.get("id") or ""))
if topic is None:
@ -138,8 +165,49 @@ class ProactiveTopicsStep(BaseStep):
)
state.updates_resolved += 1
# 1b) Schedule registry (branch C). Applied before the candidates so a
# reminder always sees the registry it just helped maintain. The
# registry has its own, much longer lifecycle than open_topics: a
# schedule mentioned weeks ago must still fire on its due date.
counts, touched_schedules = apply_schedule_updates(state_file, state.schedule_updates, day, schedule_material)
state.schedules_added += counts["added"]
state.schedules_updated += counts["updated"]
state.schedules_closed += counts["closed"]
# The clock scan read the registry before those updates landed, so a
# seeded reminder may still advertise the previous due value; a reader
# sorts and notifies by it, so re-anchor it on the registry.
state.reminders_reanchored = refresh_reminder_dues(
state.reminders,
state_file,
touched_schedules,
current_now(self),
)
adopted_schedules = 0
for candidate in state.reminders:
outcome = upsert_schedule_from_reminder(state_file, candidate, day, touched_schedules)
if outcome == "added":
state.schedules_added += 1
elif outcome == "updated":
state.schedules_updated += 1
elif outcome == "adopted":
adopted_schedules += 1
dropped_schedules = trim_schedules(
state_file,
day,
self.schedule_retention_days,
self.max_schedules,
self.max_schedule_history,
)
state.schedules_out = [slim_schedule(s) for s in state_file.schedules if s.status == "open"]
self.logger.info(
f"[{self.name}] schedule registry added={state.schedules_added} updated={state.schedules_updated} "
f"closed={state.schedules_closed} dropped={dropped_schedules} open={len(state.schedules_out)} "
f"skipped_updates={counts['skipped']} reminders_reanchored={state.reminders_reanchored} "
f"adopted={adopted_schedules}",
)
# 2) Candidates: same-id merge, tombstone resurrect, dedup the rest.
raw_candidates = list(state.follow_ups) + list(state.extends)
raw_candidates = list(state.reminders) + list(state.follow_ups) + list(state.extends) + list(state.new_ideas)
state.candidates_in = len(raw_candidates)
merged: list[dict] = []
resurrected: list[dict] = []
@ -196,6 +264,13 @@ class ProactiveTopicsStep(BaseStep):
if normalized in seen_normalized:
dropped_duplicate += 1
continue
if str(candidate.get("kind") or "") == "reminder":
# A due schedule must fire even when the same words were
# published recently: the historical "known" gate filters
# discovery noise, it is not a calendar.
seen_normalized.add(normalized)
survivors.append(candidate)
continue
if embedder is None:
if normalized in comparison_norms:
dropped_duplicate += 1
@ -230,7 +305,7 @@ class ProactiveTopicsStep(BaseStep):
# 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))
state_file.open_topics.append(topic_from_candidate(candidate))
trim_state_file(state_file, day, max(state.carry_forward_days, 1))
await save_state(ws, state_file, daily)
@ -239,9 +314,24 @@ class ProactiveTopicsStep(BaseStep):
# 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],
)
# Reminders are the exception on both counts: one refreshed today pushes
# regardless of ``first_seen`` (a due schedule recurs daily until it is
# closed) and only has to clear the lower ``min_reminder_confidence``.
reminders_today = [
t
for t in state_file.open_topics
if t.kind == "reminder"
and day in (t.first_seen, t.last_evidence_at)
and t.confidence >= self.min_reminder_confidence
]
others_today = [
t
for t in state_file.open_topics
if t.kind != "reminder" and t.first_seen == day and t.confidence >= self.min_push_confidence
]
push_candidates = sort_reminders([dump_topic(t) for t in reminders_today]) + [
dump_topic(t) for t in sort_topics(others_today)
]
push = bool(push_candidates)
if push:
file_skip_reason = ""
@ -286,6 +376,13 @@ class ProactiveTopicsStep(BaseStep):
existing.confidence = clamp_confidence(candidate.get("confidence"))
if candidate.get("paths"):
existing.paths = list(candidate["paths"])
if existing.kind == "reminder":
# Keep the published due date and registry link current so a
# recurring reminder shows its next occurrence, not the anchor.
if due := effective_due(candidate):
existing.due_at = due
if candidate.get("schedule_id"):
existing.schedule_id = str(candidate["schedule_id"])
@staticmethod
def _resurrect(tombstone: dict, candidate: dict, day: str) -> ProactiveTopic:
@ -294,16 +391,19 @@ class ProactiveTopicsStep(BaseStep):
The over-age trim still applies to the original ``first_seen``, so a
resurrection only extends a lifetime that has not fully elapsed.
"""
kind = str(candidate.get("kind") or DEFAULT_KIND)
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"),
kind=kind,
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 [],
schedule_id=str(candidate.get("schedule_id") or ""),
due_at=effective_due(candidate) if kind == "reminder" else "",
)
async def _comparison_texts(

View file

@ -17,8 +17,13 @@ from pathlib import Path
import yaml
from ....enumeration import ComponentEnum
from ....schema import ProactiveStateFile, ProactiveTopic
from ....schema.proactive import clamp_confidence
from ....schema import ProactiveSchedule, ProactiveStateFile, ProactiveTopic
from ....schema.proactive import (
DEFAULT_KIND,
TOPIC_KINDS,
clamp_confidence,
kind_priority,
)
from ....utils import get_logger
from ...file_io._file_io import get_path_lock
from .._evolve import now
@ -26,9 +31,17 @@ 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")
EXTRACT_SECTIONS = ("follow_ups", "extends", "reminders", "new_ideas", "updates", "schedule_updates")
# Registry triggers that deserve a reminder; ``later``/``unscheduled``/``closed``
# stay in the prompt view but never become candidates.
def load_yaml_topics(path: Path, *, strict: bool = False) -> list[dict]:
@ -158,6 +171,75 @@ def scan_material_daily(ws: Path, day: str, daily: str, scan_days: int) -> list[
return sorted(out)
# External source records the reminder branch may read directly. Binary sources
# are deliberately absent: ``auto_resource`` interprets them into daily cards,
# which already reach extraction through ``scan_material_daily``.
RESOURCE_NOTICE_SUFFIXES = (
".md",
".markdown",
".txt",
".json",
".yaml",
".yml",
".csv",
".tsv",
".html",
".eml",
".ics",
)
def _mtime_day(path: Path) -> str:
"""Local date a file last changed, or ``""`` when it cannot be stat'ed."""
try:
return dt.date.fromtimestamp(path.stat().st_mtime).isoformat()
except OSError:
return ""
def scan_material_resource(
ws: Path,
day: str,
scan_days: int,
resource: str = "resource",
) -> tuple[list[str], int]:
"""M_resource: external source records that may carry notices for the user.
Mirrors the ``resource/`` entry-point semantics of Auto Resource: a dated
directory ``resource/<YYYY-MM-DD>/...`` belongs to that date, while a file
directly under ``resource/`` (or under a non-dated folder) belongs to the
day it landed, i.e. its mtime. Returns ``(paths, skipped_binary)``; only
text-readable suffixes are returned, because a binary source has to be
interpreted by ``auto_resource`` before anyone can quote it.
This is the reserved hook for future external source interfaces: whatever
writes into ``resource/`` becomes eligible for the reminder review without
any further change to the proactive chain.
"""
window = set(recent_dates(day, scan_days))
root = ws / resource
if not window or not root.is_dir():
return [], 0
out: list[str] = []
skipped = 0
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = norm_path(path.relative_to(ws).as_posix())
parts = rel.split("/")
if any(part.startswith("_") or part.startswith(".") for part in parts[1:]):
continue
owner = parts[1] if len(parts) > 2 and safe_date(parts[1]) else _mtime_day(path)
if owner not in window:
continue
if path.suffix.lower() not in RESOURCE_NOTICE_SUFFIXES:
skipped += 1
continue
if rel not in out:
out.append(rel)
return sorted(out), skipped
# ---------------------------------------------------------------------------
# Truth-source state file daily/_proactive.yaml (F1.3)
# ---------------------------------------------------------------------------
@ -187,11 +269,56 @@ def load_state(ws: Path, daily: str = "daily") -> tuple[ProactiveStateFile, bool
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
# Salvage entry by entry instead of discarding the file: the schedule
# registry is designed to outlive topics by months, so one malformed
# hand-edited entry must not silently delete every long-lived reminder.
# Still no bootstrap - the file exists, so this is not a first run.
state, dropped = _salvage_state(data)
logger.warning(
f"proactive state file invalid, salvaged entry by entry: {path} "
f"(dropped {dropped} unreadable entr(ies); kept open_topics={len(state.open_topics)} "
f"schedules={len(state.schedules)}; {e})",
)
return state, False
return state, needs_bootstrap
# Truth-source sections whose entries are validated one by one when the whole
# file fails to parse. ``resolved`` is a plain dict list, so it only needs the
# non-mapping items filtered out.
_STATE_SECTIONS = (("open_topics", ProactiveTopic), ("schedules", ProactiveSchedule))
def _salvage_state(data: dict) -> tuple[ProactiveStateFile, int]:
"""Rebuild a partly-unreadable truth source, dropping only the bad entries.
Returns the salvaged model and the number of entries that had to be
dropped. Never raises: a section that is not even a list is treated as
absent, exactly like a file written before that section existed.
"""
sections: dict[str, list] = {}
dropped = 0
for key, model in _STATE_SECTIONS:
raw = data.get(key)
raw = raw if isinstance(raw, list) else []
kept = []
for item in raw:
try:
kept.append(model.model_validate(item))
except Exception: # noqa: BLE001
dropped += 1
sections[key] = kept
raw_resolved = data.get("resolved")
resolved = [item for item in (raw_resolved if isinstance(raw_resolved, list) else []) if isinstance(item, dict)]
version = data.get("version")
state = ProactiveStateFile(
version=version if isinstance(version, int) else 1,
resolved=resolved,
**sections,
)
return state, dropped
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)
@ -210,7 +337,8 @@ async def save_state(ws: Path, state_file: ProactiveStateFile, daily: str = "dai
raise
def _safe_date(text: str) -> dt.date | None:
def safe_date(text: str) -> dt.date | None:
"""Parse an ISO ``YYYY-MM-DD`` string, returning None when it is not one."""
try:
return dt.date.fromisoformat(str(text or "").strip())
except ValueError:
@ -237,13 +365,13 @@ async def load_carry_forward(
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)
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)
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).
@ -259,7 +387,7 @@ async def load_carry_forward(
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:
if safe_date(day) is None:
return []
records: dict[str, ProactiveTopic] = {}
first_seen: dict[str, str] = {}
@ -283,7 +411,7 @@ def _bootstrap_from_history(ws: Path, day: str, days: int, daily: str) -> list[P
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)
base = safe_date(day)
if base is None:
return
cutoff = (base - dt.timedelta(days=max(int(days), 0))).isoformat()
@ -295,7 +423,7 @@ def trim_state_file(state_file: ProactiveStateFile, day: str, days: int) -> None
for topic in state_file.open_topics:
if topic.id and topic.id in resolved_ids:
continue
first_seen = _safe_date(topic.first_seen)
first_seen = safe_date(topic.first_seen)
if first_seen is not None and first_seen.isoformat() <= cutoff:
continue
kept.append(topic)
@ -363,23 +491,28 @@ def parse_interests_topics(data: dict, file_date: str) -> tuple[list[ProactiveTo
id=str(raw.get("id") or "").strip() or topic_id(title),
title=title,
reason=reason,
kind=raw.get("kind", "interest_extend"),
kind=raw.get("kind", DEFAULT_KIND),
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 [],
schedule_id=str(raw.get("schedule_id") or "").strip(),
due_at=str(raw.get("due_at") or "").strip(),
),
)
return topics, is_v1, push
def sort_topics(topics: list) -> list:
"""Order: last_evidence_at desc -> follow_up first -> confidence desc -> id asc.
"""Order: last_evidence_at desc -> kind priority -> 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.
Kind priority is ``KIND_PRIORITY`` (reminder > follow_up > new_idea >
interest_extend), so a reminder always outranks discovery kinds inside the
same freshness band and survives the ``max_topics`` render cap.
"""
def get(topic, key):
@ -387,7 +520,7 @@ def sort_topics(topics: list) -> list:
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: kind_priority(get(t, "kind")))
out.sort(key=lambda t: str(get(t, "last_evidence_at") or ""), reverse=True)
return out
@ -395,17 +528,24 @@ def sort_topics(topics: list) -> list:
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 {
dumped = {
"id": str(get("id") or ""),
"title": str(get("title") or ""),
"reason": str(get("reason") or ""),
"kind": str(get("kind") or "interest_extend"),
"kind": str(get("kind") or DEFAULT_KIND),
"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 [])],
}
# Reminder links are emitted only when present so the v2 topic shape of
# every other kind stays byte-identical.
if schedule_id := str(get("schedule_id") or ""):
dumped["schedule_id"] = schedule_id
if due_at := str(get("due_at") or ""):
dumped["due_at"] = due_at
return dumped
def render_interests(
@ -491,7 +631,7 @@ def clean_candidate(raw, allowed_paths: set[str], kind: str, day: str) -> dict:
"id": topic_id(title),
"title": title,
"reason": reason,
"kind": kind,
"kind": kind if kind in TOPIC_KINDS else DEFAULT_KIND,
"confidence": clamp_confidence(raw.get("confidence")),
"first_seen": day,
"last_evidence_at": day,
@ -631,6 +771,57 @@ def load_personal_profile_block(
return "(no user profile)"
CAPABILITY_SECTIONS = (
("procedure", "procedure (reusable experience, skills, tool/API know-how the agent already has)"),
("wiki", "wiki (existing knowledge nodes of the personal knowledge base)"),
)
def load_capability_block(ws: Path, digest_dir: str, max_chars: int) -> str:
"""Inventory of what the agent can already do, used by the ``new_ideas`` branch.
Reads only rebuildable digest nodes: ``<digest_dir>/procedure/*.md``
(accumulated experience, skills, tool/API know-how) and
``<digest_dir>/wiki/*.md`` (knowledge nodes an interest can extend).
Each node contributes its frontmatter ``description`` so proposals stay
grounded in resources that actually exist instead of invented capabilities.
"""
max_chars = int(max_chars)
if max_chars <= 0:
return "(no capability inventory)"
per_section = max(max_chars // max(len(CAPABILITY_SECTIONS), 1), 200)
sections: list[str] = []
for subdirectory, heading in CAPABILITY_SECTIONS:
lines = _digest_inventory_lines(ws / digest_dir / subdirectory, per_section)
if lines:
sections.append(f"### {heading}\n" + "\n".join(lines))
if not sections:
return "(no capability inventory)"
return "\n\n".join(sections)[:max_chars]
def _digest_inventory_lines(directory: Path, budget: int) -> list[str]:
"""``- <title>: <description>`` lines for one digest subdirectory, budget-bounded."""
if not directory.is_dir():
return []
lines: list[str] = []
used = 0
for path in sorted(p for p in directory.glob("*.md") if p.is_file()):
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 = " ".join(str(meta.get("description") or "").split())
line = f"- {title}" + (f": {description[:160]}" if description else "")
if used + len(line) > budget:
break
lines.append(line)
used += len(line) + 1
return lines
def parse_fenced_yaml(text: str) -> dict:
"""Parse the first fenced YAML/JSON block (or the bare text) into a dict.

File diff suppressed because it is too large Load diff