Skip to content

fix(web): reflect on idle so web conversations evolve Lisa's desires#242

Merged
oratis merged 2 commits into
mainfrom
claude/desire-web-reflect
Jul 14, 2026
Merged

fix(web): reflect on idle so web conversations evolve Lisa's desires#242
oratis merged 2 commits into
mainfrom
claude/desire-web-reflect

Conversation

@oratis

@oratis oratis commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Stacked series (1/3). This is the base of a 3-PR stack fixing why desires don't evolve. Order: #242#246#247. #246 and #247 are branched on top of this one.

Why

Investigating "the wish never changes no matter what we talk about" traced to a dead code path: reflectOnSession — the only automatic way a conversation turns into or updates a desire — is never triggered in the web deployment. It's wired to CLI --reflect and the channel router only; the POST /reflect endpoint exists but no client calls it. So web conversations never touched Lisa's desires. This is the dominant root cause (full analysis in the plan doc).

What

A dedicated, debounced reflection scheduler in startWebServer:

  • After LISA_REFLECT_DEBOUNCE_MS (default 5 min) of no user input, reflect once on the accumulated history.
  • Only when the user added a new message since the last reflection — counting user messages (not total) so Lisa's own idle [while you were away] notes never trigger reflection on her own monologue.
  • Independent of the 60-min "dream" idle (src/cli.ts default) — works even with --no-idle. It reuses the process-wide idle watcher purely as an activity clock (/chat already tick()s it).
  • Robust: advances its marker only on success (a failed reflect retries next tick); snapshots history before the async call; unref()s its timer; skips while a reflection or idle run is in flight.
  • Emits an SSE reflect_done event and appends to the session log for observability.

Decision logic is extracted to a pure, unit-tested module (src/web/reflect-scheduler.ts).

Plan doc

This is PR 1 of 3 from docs/PLAN_DESIRE_EVOLUTION_v1.0.md, which bundles the root-cause analysis, the pro/con debates (正反方辩论), and the PR1–3 breakdown. PR2 makes reflection able to revise/close desires (not just append); PR3 surfaces the most recently active desire instead of a filesystem-order pick.

Testing

  • src/web/reflect-scheduler.test.ts — 8 tests covering the debounce/guard truth table and user-message counting. All green.
  • npm run typecheck: no new errors (pre-existing imapflow optional-dep error only).
  • npm test: zero regressions — same 5 pre-existing failures as main (imapflow mail tests, a stale HTML snapshot, an auth test), plus the 8 new passing tests.

Backend timer change — not observable in the browser preview; verified via unit tests + typecheck.

🤖 Generated with Claude Code

The web server never exits, so end-of-session reflection had no trigger:
reflectOnSession was only wired to CLI --reflect and the channel router,
and the POST /reflect endpoint had no caller. Result: desires never
changed from web conversations — the "wish never changes" bug.

Add a dedicated, debounced reflection scheduler in startWebServer. After
5 min of quiet (LISA_REFLECT_DEBOUNCE_MS) it reflects once on the
accumulated history, provided the user actually added a new message since
the last reflection. Counting user messages (not total) keeps Lisa's own
idle "[while you were away]" notes from triggering a reflection on her own
monologue. It reuses the process-wide idle watcher purely as an activity
clock, is independent of the 60-min "dream" idle (works with --no-idle),
advances its marker only on success (a failed reflect retries), and
unref()s its timer so it never keeps the process alive.

Also lands docs/PLAN_DESIRE_EVOLUTION_v1.0.md — the full plan (root-cause
analysis, pro/con debates, PR1-3 breakdown) that this is the first PR of.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oratis
oratis force-pushed the claude/desire-web-reflect branch from 13ed3ce to 4cf33fa Compare July 14, 2026 17:23
@oratis

oratis commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Review — merge-ready ✅

Walked the concurrency + debounce truth table against the code:

  • --no-idle really works (the central claim) — /chat calls getIdleWatcher(...).tick() unconditionally, so the scheduler's activity clock advances even when the 60-min idle block is skipped. The scheduler only uses idleFor() + its own interval; it never needs the watcher's idle event.
  • History snapshot + snapshotUserCount + reflecting = true are set in one synchronous block (no await between), and the marker advances to the snapshot count only on success — so a message arriving mid-reflect is counted exactly once next tick, never lost or double-counted.
  • reflecting is set synchronously before the async IIFE and reset in finally (even on a sync throw), so two reflects can't overlap; a failed reflect retries without advancing the marker.
  • Soul writes serialize on the cross-process file locks and session-log appends are O_APPEND atomic, so a reflect + the dream can't corrupt shared state.

Sound — no blockers. Two small optional hardenings I'll fold in while merging:

  1. Clamp LISA_REFLECT_DEBOUNCE_MS to > 0. Number(env) || DEFAULT correctly rejects NaN/""/"0", but a negative value is truthy → debounceMs = -1 makes idleMs < debounceMs always false, so it reflects ~60s into any conversation. Number.isFinite(n) && n > 0 ? n : DEFAULT.
  2. Have the 60-min dream also respect reflecting. The scheduler's inFlight: reflecting || idleRunning blocks reflect-during-dream, but the dream's guard is only if (idleRunning) return — so under a short --idle (both firing on the same quiet trigger) a dream can start mid-reflect and its own [while you were away] note can bleed into the reflected history — the exact thing PLAN §3 wants to avoid. Adding || reflecting to the dream guard closes the asymmetry.

Neither blocks the fix; merging after those.

…ve reflect

Two low-severity hardenings on the reflection scheduler:

- Clamp LISA_REFLECT_DEBOUNCE_MS to a positive finite value. `Number(env) ||
  DEFAULT` rejects NaN/""/"0" but a negative value is truthy → debounceMs<0
  makes `idleMs < debounceMs` always false, reflecting ~60s into a live
  conversation. Now `Number.isFinite(n) && n > 0 ? n : DEFAULT`.
- The scheduler's `inFlight: reflecting || idleRunning` already blocks
  reflect-during-dream, but the dream's own guard was only `if (idleRunning)`,
  so a dream could start mid-reflect and its "[while you were away]" note could
  bleed into the reflected history (PLAN §3 wants reflect first). Hoist
  `reflecting` up beside `idleRunning` and guard the dream on both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oratis
oratis merged commit 8db4a57 into main Jul 14, 2026
1 check passed
@oratis
oratis deleted the claude/desire-web-reflect branch July 14, 2026 17:59
oratis added a commit that referenced this pull request Jul 15, 2026
Per review of #249: wrap reviseDesire and closeDesire's read-modify-write of the
desire file in withSoulLock, matching the other soul RMW paths — now that web
idle-reflect (#242) can race a CLI reflect on the same slug, an unlocked
list→write loses one field edit (last-writer-wins). writeDesire doesn't
self-lock so this doesn't nest; closeDesire's progress/journal appends
self-lock, so they stay outside the block (nesting would deadlock the
non-reentrant file lock).

Also fix the misleading comment: writeDesire only serializes
heartbeatPrompt/pursuit while actionable, so closing drops them from disk (git
history retains the last actionable version for re-open).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant