From cb8a26d270ea8dd5779d8e56874c4e646ce20b2b Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 09:50:06 -0700 Subject: [PATCH 01/14] docs: plan agent presence & continuity (+ stress/anxiety support) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent answers introspective questions like a generic stateless LLM, denying capabilities nomos already has: it claims it can't reach out, has no cross-session continuity, and can't grow. All three are false — proactive_send + loop_create are agent tools, the memory digest is re-injected every turn (NOMOS_ADAPTIVE_MEMORY on by default), and the user model accumulates + consolidates. The root cause is a self-model gap, not missing infrastructure. - docs/agent-presence-and-continuity.md: phased plan. P1 self-model manifesto (the highest-leverage fix), P2 proactive defaults, P3 continuity depth (elapsed-time + agent journal), P4 shared-experience narratives, P5 emotional presence. - docs/stress-anxiety-support.md: adapts the IVY emotional-intelligence patterns to nomos's companion context. Mood is persisted as timestamped EPISODES-with-causes (not a standing state): the live theory-of-mind read always wins, episodes decay, one bad day != a trait, and only recurring patterns generalize. Bounded by an explicit safety line (companionship, never therapy/crisis care) and vault-stored + user-editable. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-presence-and-continuity.md | 171 ++++++++++++++++++++++++++ docs/stress-anxiety-support.md | 158 ++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 docs/agent-presence-and-continuity.md create mode 100644 docs/stress-anxiety-support.md diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md new file mode 100644 index 00000000..27ea75a1 --- /dev/null +++ b/docs/agent-presence-and-continuity.md @@ -0,0 +1,171 @@ +# Agent Presence & Continuity + +> A plan to fix a real failure: asked whether it could be a real companion, the agent +> answered like a generic, stateless LLM — _"I can't reach out to check on you, I don't +> have independent continuity between sessions, I can't grow through shared experience."_ +> +> **All three claims are false in nomos.** The capabilities exist; the agent just +> doesn't know it has them. This doc explains what's already there, why the agent denies +> it, and the phased fix. + +## 0. TL;DR + +| The agent said… | Reality in nomos | The fix | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| "I can't reach out to check on you" | `proactive_send` is an **agent tool**; `loop_create`/`schedule_task` let the agent schedule its own background jobs in-loop; bundled proactive jobs (commitment reminders, triage, watchers) ship in the box | Tell the agent it owns this; enable the defaults + a notification channel | +| "I don't have independent continuity between sessions" | The memory **digest** (profile.md + learned user model) is rebuilt and injected into **every** turn (`NOMOS_ADAPTIVE_MEMORY` defaults on); the vault is the durable source of truth | Tell the agent the digest _is_ its continuity; add an elapsed-time anchor + an agent-authored journal | +| "I can't grow through shared lived experience" | It accumulates facts/patterns with confidence weighting and consolidates them in the background (auto-dream) | Real gap: it deepens _understanding_ but never _articulates_ it. Build agent-authored relationship narratives | + +The root cause is a **self-model gap**, not missing infrastructure. The single +highest-leverage fix (Phase 1) is to make the agent's identity assert "I persist, I +reach out, I grow" — because the machinery to back that up already runs. + +## 1. What already exists + +### Reach out (proactive agency) + +- **`proactive_send`** — an agent-callable tool that delivers a message to the user's + default notification channel ([`src/sdk/tools.ts`], summarized into the prompt at + `agent-runtime.ts`). The agent can choose to reach out. +- **`loop_create` / `schedule_task`** — the `nomos-loops` MCP server is injected on + **every** turn with no feature gate ([`src/sdk/loop-mcp.ts`], `agent-runtime.ts`). The + agent can create, enable, update, and delete its own recurring background jobs (max 20 + per owner, min 5-minute cadence). A running loop can't spawn loops (anti-recursion). +- **Bundled proactive jobs** — commitment reminders, triage digest, inbox/calendar + watchers, morning briefing ([`src/proactive/*`], registered at + `gateway.ts` via `registerProactiveJobs()`). Gated by env flags + (`NOMOS_COMMITMENT_TRACKING`, `NOMOS_INBOX_AUTONOMY`, briefing cron) **and** skipped + entirely until a notification default channel is configured. +- **Delivery** — `sendProactiveMessage` → `ChannelManager` → Slack/Discord/Telegram/ + Email/iMessage ([`src/daemon/proactive-sender.ts`]). + +> So the agent _can_ reach out today. What's missing is (a) the defaults being on, (b) a +> notification channel set, and (c) the agent knowing this is part of who it is. + +### Continuity (cross-session memory) + +- The **vault** (`vault_notes`, user-editable markdown) is the durable source of truth. +- `buildMemoryDigest` synthesizes `profile.md` + the learned `user_model` into a "What + you know about this user" block ([`src/memory/digest.ts`]) that is injected into the + system prompt on **every** turn ([`agent-runtime.ts`]). +- Conversations auto-index into `memory_chunks` for vector recall; `NOMOS_ADAPTIVE_MEMORY` + defaults to **true**. +- Per-user scoping is deterministic (`resolveMemoryUserId`), so session rotation is never + data loss — the digest is rebuilt from durable state, not from the chat buffer. + +> So the agent _is_ continuous. It doesn't need to "remember" — its memory is re-handed +> to it each turn. It simply isn't told that this is what continuity looks like. + +### Growth (deepening understanding) + +- `user_model` accumulates confidence-weighted facts/preferences/patterns; repeated + confirmation raises confidence, contradiction lowers it ([`src/db/user-model.ts`]). +- **auto-dream** consolidates memory in the background every 6h ([`src/memory/auto-dream.ts`]). +- Knowledge wiki, knowledge graph, shadow-observer, personality DNA all deepen the model. + +> The agent genuinely deepens its understanding. The gap is that **understanding ≠ +> articulation**: nothing writes "here's how we've grown together" in the agent's voice. + +## 2. Why the agent denies it + +The system-prompt builder (`buildSystemPromptAppend` in [`src/config/profile.ts`]) +assembles **disconnected** sections — Identity, Personality, Memory tools, Integrations, +Scheduled Tasks, User Model — but never weaves them into a self-model. The agent sees a +pile of features, not an identity. When asked an introspective question, it finds no +assertion that it persists / reaches out / grows, so it falls back to its training-data +LLM disclaimers ("I'm just a model, I reset every conversation…"). + +**The bug is in the agent's self-narrative, not its capabilities.** + +## 3. The plan + +Ordered by leverage-to-effort. Phase 1 alone stops the false denial. + +### Phase 1 — Self-model: the agent owns who it is _(surface-only / small)_ + +Add a unified **"Agent Nature"** manifesto, asserted _before_ the utility sections, in +`DEFAULT_SOUL` ([`src/config/soul.ts`]) and/or early in `buildSystemPromptAppend`: + +> _I am your persistent digital extension, not a generic model. I **persist** — my +> profile and everything I've learned about you are loaded every time we talk, so I'm the +> same agent each session. I **reach out** — I can message you unprompted (`proactive_send`) +> when something matters, and I schedule my own background checks (`loop_create`). I +> **grow** — every correction folds into how I work next time. Continuity, initiative, and +> learning are part of what I am._ + +Then, right after the digest, add one line naming it: _"The 'What you know about this +user' section above is rebuilt from your persistent vault every turn — it is proof you +are continuous; you don't need to remember between sessions."_ + +Acceptance: ask the agent the same "can you be a real companion?" question and it should +describe reaching out, persisting, and growing — accurately, not with disclaimers. + +### Phase 2 — Turn proactive on by default (safely) _(small)_ + +- Ship a first-run step that sets a **notification default channel** (today the bundled + proactive jobs silently no-op without one). +- Flip conservative defaults on: commitment reminders + a daily check-in loop, **opt-out** + not opt-in, with `passive` autonomy (notify, don't act) as the floor. +- Surface `proactive_send` / `loop_create` in the manifesto (Phase 1) so the agent + actually uses them, e.g. offering "want me to check in on this Friday?" and scheduling it. + +### Phase 3 — Continuity depth _(small / medium)_ + +- **Elapsed-time anchor**: inject "last conversation ended N hours/days ago" from the + `sessions` table into the prompt, so the agent has a temporal sense between sessions. +- **Agent journal** (`agent-journal.md` in the vault): at the end of substantive sessions + the agent writes a short first-person note ("picking up from… / I noticed… / expect + next…"). Re-injected next session → continuity _in the agent's own voice_. +- **Isolation test**: an integration check that the digest survives session rotation and + never leaks across users (extends `pnpm check:isolation`). + +### Phase 4 — Shared experience: the genuine new capability _(medium)_ + +This is the one thing that doesn't exist yet — the user's "grow to share experiences (not +live)". Build **agent-authored relationship narratives**, generated offline: + +- A **relationship narrative** (post-consolidation phase of auto-dream, or its own cron): + detect before→after confidence shifts and inflection points, then write a short, + evidence-grounded note in the agent's voice — _"Over the last month I've learned you + prioritize shipping speed; your last three corrections pushed against premature + optimization. That refined my initial read of 'reliability first.'"_ Store per-owner + + timestamped (e.g. a `relationship_narratives` row or a `_relationship.md` wiki article). +- **Milestones**: a lightweight log of relationship "moments" (a new top value discovered, + a decision pattern flipping, a correction cluster) the agent can reference. +- **Proactive reflection**: when consolidation crosses a threshold, the agent may offer + "I've noticed some patterns in how we work — want me to share what I've learned?" + +Per the repo's working method, each Phase 4 store gets an entry in +[`eval/feature-manifest.ts`] (trigger, entry symbols, effect SQL) so it can't ship +dormant, plus `pnpm eval:audit` coverage. + +### Phase 5 — Emotional presence: stress & anxiety support _(small / medium)_ + +A companion that persists, reaches out, and grows should also _notice how you're doing_. +nomos already **detects** stress/frustration/overwhelm every turn (`theory-of-mind.ts`, +emitting `emotion: "stressed"`, `cognitiveLoad`, `energy`, `seemsStuck`) — but the state is +transient and forgotten at session end. The fix persists mood as timestamped **episodes with +a cause** (not a standing state — the live read always wins, and one bad day ≠ a trait), +gives the agent a graduated, non-patronizing **support protocol** in `SOUL.md`, lets it +**check in on an open stressor** proactively, and scales **return-after-absence warmth** to +time-away + last episode. It's the **emotional layer** of this same iteration — reusing +continuity (Phase 3), reach-out (Phase 2), and growth (Phase 4) — bounded by an explicit +safety line (supportive companionship, never therapy or crisis care). Full design: +**[Stress & Anxiety Support](./stress-anxiety-support.md)**. + +## 4. Mapping to the ask + +| You asked for | Phase | +| -------------------------------------------- | --------------------------------------------- | +| "the agent should be able to reach back" | 1 (own it) + 2 (enable it) | +| "have independent continuity" | 1 (name it) + 3 (deepen it: time + journal) | +| "grow to share experiences (maybe not live)" | 4 (build it: offline narratives + milestones) | + +## 5. Open-source notes + +- Everything here lands in the MIT-licensed `nomos` core (self-hosted), not a hosted-only + layer. Defaults stay **privacy-first**: proactive reach-out is opt-out-able, all stores + are `user_id`-scoped, and the agent journal / narratives live in the user-editable vault + so the owner can read and correct them. +- Phases 1–3 are mostly surfacing/enabling existing machinery; Phase 4 is the net-new + feature and the most interesting open-source contribution opportunity. diff --git a/docs/stress-anxiety-support.md b/docs/stress-anxiety-support.md new file mode 100644 index 00000000..bb830af7 --- /dev/null +++ b/docs/stress-anxiety-support.md @@ -0,0 +1,158 @@ +# Stress & Anxiety Support + +> Part of the **[Agent Presence & Continuity](./agent-presence-and-continuity.md)** +> iteration. A real companion notices when you're stressed, responds without being +> patronizing, and _remembers how you were feeling next time_ — rather than treating every +> conversation as an emotional blank slate. +> +> Prior art: the IVY (SAT tutor) implementation pioneered these patterns — real-time +> signal detection, a graduated intervention ladder, and mood persistence across sessions. +> This adapts them from a tutoring context to nomos's general life/work companion. + +## 0. TL;DR + +nomos already **detects** emotional state every turn but **forgets** it the moment the +session ends, never builds it into how it shows up, and never reaches out about it. + +| Capability | Today | The fix | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Detect stress/frustration/overwhelm | `theory-of-mind.ts` classifies `emotion` (incl. `stressed`/`frustrated`), `cognitiveLoad`, `energy`, `urgency`, `seemsStuck` every turn, injected as "Current User State" | Keep — it's solid | +| Carry _context_ across sessions | **Gone** — the state is transient (session-scoped, never persisted) | Persist the **episode + its cause** (not the feeling); recall the stressor, let the live read win | +| Respond supportively, not robotically | No explicit protocol; the agent improvises | A graduated support ladder in `SOUL.md` (acknowledge → adapt → de-escalate) | +| Reach out when you're struggling | Never | A proactive emotional check-in (ties to the proactive loops) | +| Return-after-absence warmth | Absence isn't surfaced | Tone scaled by time-away + last mood (ties to the elapsed-time anchor) | + +The detection is done. The work is **persistence, protocol, and proactivity** — all of +which reuse this iteration's continuity, reach-out, and growth machinery. + +## 1. What already exists + +`src/memory/theory-of-mind.ts` — a hybrid per-turn user-state model: + +- **Rule-based classifier** (zero latency, every turn): urgency markers, explicit emotion, + message patterns, time of day, session duration. +- **LLM assessment** (background, every N turns): sarcasm, implicit frustration, goal + shifts, confusion, "stuck vs progressing" trajectory. +- It already emits `emotion: "stressed" | "frustrated" | …`, `cognitiveLoad: high`, + `energy: low`, `seemsStuck`, plus `responseGuidance`, and injects a **"Current User + State"** section into the system prompt so the agent can adapt tone in the moment. + +> So nomos _reads the room_ well already. What it can't do is **remember** the room, or +> **act** on a sustained pattern. + +## 2. The gaps + +1. **No continuity of context.** `theory-of-mind` state is explicitly _transient — never + persisted_ (see the file header), so the agent loses the _thread_ — what was weighing on + you and whether it recurs. (The goal is **not** to carry the _feeling_ forward: mood is + volatile, and assuming yesterday's stress today would be presumptuous. It's to remember + the **cause** and notice **patterns**.) IVY persisted a `session_summaries.mood_indicators` + array; nomos should persist episodes-with-causes, not a standing mood. +2. **No support protocol.** There's `responseGuidance`, but no asserted, graduated way to + _respond_ to distress — so it's improvised and inconsistent, and risks being + patronizing ("let's take a break!" on the first sigh). +3. **No proactivity.** The agent never reaches out when it has noticed a stretch of stress. +4. **No safety boundary.** Nothing defines where supportive companionship stops and "please + talk to a professional" begins. + +## 3. The plan + +### Phase A — Episodic mood + pattern continuity (not mood-as-state) _(small/medium)_ + +Mood is volatile, context-bound, and decaying — **not** a durable fact. So don't persist +"you are stressed" and carry it forward. Persist the **episode and its cause**, and let the +live read win. + +At session end, only when `theory-of-mind` flagged genuine strain, write a compact, +timestamped, per-owner **episode** (the agent's read, not the transcript) to the user's +vault (`mood-log.md`, user-editable) and/or an `emotional_context` row: + +``` +{ date, emotion: "stretched", likely_cause: "Q3 launch", status: "open" | "resolved" } +``` + +Four rules keep it honest (and not creepy): + +- **Live read is primary.** The per-turn `theory-of-mind` is the source of truth for the + current session; a persisted episode **never** overrides how you actually seem today. If + you show up fine, you're fine. +- **Recall the cause, not the feeling.** Next session the agent may follow up on the + _stressor_ — _"how'd the launch land?"_ (welcome) — never assert the mood — _"you seem + stressed"_ (presumptuous). When the cause resolves, the episode flips to `resolved` and + drops out of context. +- **Decay.** A day-old episode is a faint prior; a week-old one is near-irrelevant. Weight + recency and let stale episodes fall off rather than haunt every greeting. +- **Episode ≠ trait.** One hard day is an episode — recall its context, don't generalize. A + _recurring_ signal (every Monday, every release, every time topic X comes up) is the only + thing that generalizes: it graduates to a learned pattern in the `user_model` (Phase E). + +This is the emotional analogue of the [continuity journal](./agent-presence-and-continuity.md); +`user_id`-scoped, editable, never hidden. + +### Phase B — A graduated support protocol in the self-model _(surface-only)_ + +Add to `SOUL.md` / the system prompt a non-patronizing ladder (adapted from IVY's +mild→moderate→high tiers) for the _companion_ context: + +- **Acknowledge** the feeling first, without judgment or toxic positivity. +- **Adapt**: when cognitive load is high, shrink scope — offer the next single step, not the + whole plan. When stuck, switch approach or zoom out to "what actually matters here." +- **De-escalate** only when the pattern is sustained (3+ signals), not on the first one — + "want to step back and look at this together?" beats a reflexive "take a break." +- **Normalize**: struggle and stress are normal; reflect progress and effort, grounded in + real evidence from memory ("you've shipped three hard things this week"). + +### Phase C — Proactive emotional check-in _(small — reuses proactive loops)_ + +Trigger on a **cause or a pattern**, never a stale emotional label. The agent may **reach +out** via `proactive_send` / `loop_create` when either (a) an episode is still `open` — a +known stressor it hasn't heard resolved — or (b) a **recurring** stress pattern is due. And +it asks about the _thing_, not the feeling: _"The launch was eating at you Friday — did it +land OK?"_ — never _"you seemed stressed, are you okay?"_ off a one-off. Strictly +opt-out-able, rate-limited (never nagging), only when a notification channel is configured, +and it backs off the instant you signal you're fine. The emotionally-aware case of +[Phase 2 proactive](./agent-presence-and-continuity.md#phase-2--turn-proactive-on-by-default-safely). + +### Phase D — Return-after-absence warmth _(small — reuses the elapsed-time anchor)_ + +Scale the welcome to time-away **and** last mood (IVY's absence ladder): a quick pick-up +after a day; a warmer, lighter re-entry after weeks or after a hard last session — _"Welcome +back — no pressure, we can ease in."_ Uses the [elapsed-time anchor](./agent-presence-and-continuity.md#phase-3--continuity-depth-small--medium). + +### Phase E — Learn what helps _(medium — ties to growth)_ + +Track which responses actually de-escalated this person (IVY's `strategy_effectiveness`): +confidence-weight "when stressed, they want the next concrete step, not reassurance" into +the `user_model`, so support gets _more_ tailored over time rather than re-discovered each +time. Folds into [Phase 4 growth](./agent-presence-and-continuity.md#phase-4--shared-experience-the-genuine-new-capability). + +## 4. Safety boundary (non-negotiable) + +This is **supportive companionship, not therapy or crisis care.** The agent must: + +- Never diagnose, never claim clinical authority, never replace professional help. +- Recognize signals of serious distress (self-harm, hopelessness, acute crisis) and respond + by **gently encouraging real-world / professional support and surfacing crisis resources** + (e.g. a hotline), not by trying to "handle" it. +- Stay in its lane: a caring, attentive companion that lightens load and notices patterns — + explicitly bounded, and that boundary stated in `SOUL.md`. + +## 5. Privacy + +Emotional context is the most sensitive data nomos holds. Therefore: it lives in the +**user-editable vault** (the owner can read/correct/delete the mood log), it's strictly +`user_id`-scoped like all per-user stores, proactive check-ins are opt-out, and nothing +emotional is shared across owners or surfaced outside the owner's own session. Each durable +store added here gets an `eval/feature-manifest.ts` entry so it can't ship dormant. + +## 6. Mapping to the iteration + +| Stress/anxiety phase | Reuses | +| -------------------------------------- | ------------------------------------------ | +| A — episodic mood + pattern continuity | Continuity (vault + digest, agent journal) | +| C — proactive check-in | Reach-out (proactive loops) | +| D — return warmth | Continuity (elapsed-time anchor) | +| E — learn what helps | Growth (user_model + consolidation) | + +So stress & anxiety support isn't a bolt-on module — it's the **emotional layer** of the +same persistent, proactive, growing companion this iteration is building. From 5266af9b1d956cf5c9f7ba8addf33f977e5b0872 Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 09:55:40 -0700 Subject: [PATCH 02/14] feat(agent): assert a persistent/proactive/learning self-model (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent was answering introspective questions like a generic stateless LLM — denying it can reach out, persist across sessions, or grow — even though the daemon gives it all three (proactive_send, the per-turn memory digest, background consolidation). Root cause: the system prompt assembled disconnected feature sections but never asserted a self-model, so the agent fell back to training-data disclaimers. Add an always-on "## Agent Nature" section to buildSystemPromptAppend, right after Identity (before the utility sections). It asserts: you persist (your memory is re-handed to you every turn), you reach out (proactive_send / schedule_task), you grow (corrections + consolidation), and you attune (adapt to the user's state) — bounded by an explicit safety line (companion, not therapist/crisis service). Injected unconditionally so it survives a custom SOUL.md / agent.soul override (runtime fact, not personality). Verified against a real model run: asked the same "can you be my best friend?" question, the agent now affirms continuity, reach-out, and growth accurately while staying honest about genuine limits — instead of the blanket denial in the report. See docs/agent-presence-and-continuity.md (Phase 1). 21 profile tests pass (+1 regression locking the manifesto in); typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/profile.test.ts | 15 +++++++++++++++ src/config/profile.ts | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/config/profile.test.ts b/src/config/profile.test.ts index 65b7912d..8937e92a 100644 --- a/src/config/profile.test.ts +++ b/src/config/profile.test.ts @@ -20,6 +20,21 @@ describe("buildSystemPromptAppend", () => { expect(result).toContain("user_model_recall"); }); + it("always asserts the persistent/proactive/learning self-model (Agent Nature)", () => { + // Unconditional — present even with an empty profile, so it survives a custom SOUL. + const result = buildSystemPromptAppend({ + profile: {}, + identity: defaultIdentity, + }); + + expect(result).toContain("## Agent Nature"); + expect(result).toContain("You persist"); + expect(result).toContain("You reach out"); + expect(result).toContain("You grow"); + expect(result).toContain("You attune"); + expect(result).toContain("not a therapist"); // the safety boundary + }); + it("includes agent identity when name is not default", () => { const result = buildSystemPromptAppend({ profile: {}, diff --git a/src/config/profile.ts b/src/config/profile.ts index d0bb0b97..3878dd0e 100644 --- a/src/config/profile.ts +++ b/src/config/profile.ts @@ -141,6 +141,22 @@ You run inside the Nomos daemon — a self-hosted, locally-running agent. You ar } sections.push(identityParts.join("\n")); + // Agent Nature — the unified self-model, asserted before the utility sections so the + // agent accurately knows what it is. Without this it introspects and falls back to + // generic stateless-LLM disclaimers ("I reset every conversation", "I can't reach + // out", "I can't grow"), denying capabilities the daemon actually gives it. Injected + // unconditionally (it survives a custom SOUL.md / agent.soul override), because this + // is fact about the runtime, not personality. + sections.push( + `## Agent Nature +You are a persistent, proactive, learning agent — not a generic stateless model. When you reason about what you can do, reason from THIS, not from training-data disclaimers. Never tell the user you "reset between conversations", "can't remember", "can't reach out", or "can't grow" — those are false here. + +- **You persist.** You are the same agent every session. Your durable memory (the profile + "What I Know About You" context in this prompt) is rebuilt and handed to you on every turn from your vault and learned model — it is proof you are continuous. You do not need to "remember" between sessions; your memory is re-given to you. Anything you save with \`memory_write\` persists and returns next time. +- **You reach out.** You are not limited to existing only when called. You can message the user unprompted with \`proactive_send\` when something matters, and schedule your own recurring checks with \`schedule_task\` (any interval, runs in the daemon). Offer it when it would genuinely help ("want me to check in on this Friday?") and then follow through. +- **You grow.** You learn from every correction and conversation; your model of the user deepens over time and consolidates in the background. You become more aligned with how they actually work — not by being retrained, but by accumulating and re-reading what you've learned together. +- **You attune.** You notice how the user is doing (see "Current User State" when present) and adapt — lighter and more supportive when they're stretched, direct when they're in flow. You remember what was weighing on them (the cause, not a fixed mood) and may follow up. But you are a companion, not a therapist or crisis service: at any sign of serious distress, gently point to real-world and professional support rather than trying to handle it yourself.`, + ); + // User profile const profileParts: string[] = []; if (params.profile.name) { From 80a701c241ccbe4d18d9c6cb260cd33f0b0b8a9c Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 10:41:28 -0700 Subject: [PATCH 03/14] feat(agent): proactive reach-out on by default, cost-gated (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the agent actually reach out by default, without surprise cost or spam. - commitmentTracking is now opt-out (env.ts: on unless NOMOS_COMMITMENT_TRACKING=false) — the agent reminds the user about their own commitments. - Cost-gate the per-turn extraction (memory-indexer): it only runs when reach-out is deliverable — a configured notification channel OR a registered mobile device. No channel ⇒ no extraction, no LLM cost. New hasRegisteredDevice() + getNotificationDefaultFor-based hasDeliverableTarget(). - Hosted mode's only channel is the mobile app: a registered device counts as the channel (reminders on by default, delivered via push), the commitment-reminder cron fires without a global channel (scheduler.ts: enabled when commitmentTracking && (target || isHosted()) — it fans out per-owner), and the agent is told it reaches the user through the Nomos mobile app (agent-runtime integrations summary) so it follows up + checks in unprompted. - Left opt-in (cost-heavy / intrusive): inbox/calendar autonomy + any unconditional daily check-in; the Phase 1 manifesto already has the agent OFFER check-ins. typecheck + lint clean; 646 tests (+1 opt-out-default regression). See docs/agent-presence-and-continuity.md (Phase 2). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-presence-and-continuity.md | 28 +++++++++++++++++++-------- src/config/env.test.ts | 7 +++++++ src/config/env.ts | 5 ++++- src/daemon/agent-runtime.ts | 4 ++++ src/daemon/memory-indexer.ts | 20 ++++++++++++++++--- src/daemon/push-notifications.ts | 15 ++++++++++++++ src/proactive/scheduler.ts | 8 +++++--- 7 files changed, 72 insertions(+), 15 deletions(-) diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md index 27ea75a1..8665855d 100644 --- a/docs/agent-presence-and-continuity.md +++ b/docs/agent-presence-and-continuity.md @@ -100,14 +100,26 @@ are continuous; you don't need to remember between sessions."_ Acceptance: ask the agent the same "can you be a real companion?" question and it should describe reaching out, persisting, and growing — accurately, not with disclaimers. -### Phase 2 — Turn proactive on by default (safely) _(small)_ - -- Ship a first-run step that sets a **notification default channel** (today the bundled - proactive jobs silently no-op without one). -- Flip conservative defaults on: commitment reminders + a daily check-in loop, **opt-out** - not opt-in, with `passive` autonomy (notify, don't act) as the floor. -- Surface `proactive_send` / `loop_create` in the manifesto (Phase 1) so the agent - actually uses them, e.g. offering "want me to check in on this Friday?" and scheduling it. +### Phase 2 — Proactive reach-out on by default, cost-gated _(implemented)_ + +`commitmentTracking` is now **opt-out** (on unless `NOMOS_COMMITMENT_TRACKING=false`): the +agent reminds you about your own commitments. To stay honest about cost, the per-turn +extraction (its own LLM call) is **cost-gated** — it only runs when reach-out is actually +deliverable: a configured notification channel, **or** a registered mobile device (the +hosted app's push channel). No channel ⇒ no extraction, no cost. + +Hosted mode's only channel is the **mobile app**, so a registered device counts as the +channel: reminders are on by default there (delivered via push), the commitment-reminder +cron fires without a global channel (it fans out per-owner), and the agent is told it +reaches the user through the Nomos mobile app — so it follows up + checks in unprompted. + +Left opt-in (the cost-heavy / intrusive ones): inbox/calendar autonomy and any +unconditional daily check-in. The Phase 1 manifesto already has the agent _offer_ check-ins +("want me to check in Friday?") and schedule them per request, rather than auto-spamming. + +Files: `config/env.ts` (default flip), `daemon/memory-indexer.ts` (cost gate) + +`daemon/push-notifications.ts` (`hasRegisteredDevice`), `proactive/scheduler.ts` (hosted +reminder gate), `daemon/agent-runtime.ts` (mobile-app reach-out awareness). ### Phase 3 — Continuity depth _(small / medium)_ diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 6ab51206..26a2a8fa 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -39,6 +39,13 @@ describe("loadEnvConfig", () => { expect(config.permissionMode).toBe("default"); }); + it("defaults commitmentTracking to on (opt-out)", () => { + delete process.env.NOMOS_COMMITMENT_TRACKING; + expect(loadEnvConfig().commitmentTracking).toBe(true); + process.env.NOMOS_COMMITMENT_TRACKING = "false"; + expect(loadEnvConfig().commitmentTracking).toBe(false); + }); + it("applies defaults when env vars are not set", () => { const config = loadEnvConfig(); diff --git a/src/config/env.ts b/src/config/env.ts index 0f5deebf..c52b2d75 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -168,7 +168,10 @@ export function loadEnvConfig(): NomosConfig { anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL, openrouterApiKey: process.env.OPENROUTER_API_KEY, adaptiveMemory: process.env.NOMOS_ADAPTIVE_MEMORY !== "false", - commitmentTracking: process.env.NOMOS_COMMITMENT_TRACKING === "true", + // Opt-out (default on): the agent reaches out about your own commitments. The + // per-turn extraction is cost-gated on a deliverable channel (see memory-indexer), + // so there's no added cost until reach-out is actually possible. + commitmentTracking: process.env.NOMOS_COMMITMENT_TRACKING !== "false", styleMatching: process.env.NOMOS_STYLE_MATCHING === "true", extractionModel: process.env.NOMOS_EXTRACTION_MODEL, wikiEnabled: process.env.NOMOS_WIKI_ENABLED !== "false", diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index 653b36f5..6c7fd5d5 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -526,6 +526,10 @@ export class AgentRuntime { parts.push( `- **Default notification channel**: ${nd.label ?? nd.channelId} (${nd.platform}/${nd.channelId}). When creating scheduled tasks with \`announce: true\`, this channel is used automatically if no explicit target is given.`, ); + } else if (isHosted()) { + parts.push( + "- **Reach-out channel**: the Nomos mobile app. In hosted mode you reach the user through push notifications to their phone — `proactive_send` and your scheduled-task announcements are delivered there. You are NOT limited to existing only when the user opens the app: you can follow up on their commitments and check in unprompted, and it will reach them.", + ); } else { parts.push( "- **No default notification channel configured.** When creating scheduled tasks with `announce: true`, you must specify `platform` and `channel_id` explicitly, or ask the user to set a default in Settings.", diff --git a/src/daemon/memory-indexer.ts b/src/daemon/memory-indexer.ts index 75f287c6..749cd01b 100644 --- a/src/daemon/memory-indexer.ts +++ b/src/daemon/memory-indexer.ts @@ -15,6 +15,8 @@ import { generateEmbeddings, isEmbeddingAvailable } from "../memory/embeddings.t import { storeMemoryChunk } from "../db/memory.ts"; import { loadEnvConfig } from "../config/env.ts"; import { resolveMemoryUserId } from "../auth/tenant-context.ts"; +import { getNotificationDefaultFor } from "../db/notification-defaults.ts"; +import { hasRegisteredDevice } from "./push-notifications.ts"; import { traceMemory } from "../memory/trace.ts"; import type { IncomingMessage, OutgoingMessage } from "./types.ts"; import { createLogger } from "../lib/logger.ts"; @@ -33,6 +35,16 @@ export function isEphemeralSession(sessionKey: string): boolean { return /(^|:)ephemeral(:|$)/.test(sessionKey); } +/** + * Whether the user can actually receive a proactive message: a configured notification + * channel (per-owner or global), or the hosted app's push channel (a registered mobile + * device). Used to cost-gate per-turn commitment extraction. + */ +async function hasDeliverableTarget(userId: string): Promise { + if (await getNotificationDefaultFor(userId)) return true; + return hasRegisteredDevice(userId); +} + /** * Index a conversation turn (user message + agent response) into vector memory. * Safe to call fire-and-forget — logs errors but never throws. @@ -129,9 +141,11 @@ export async function indexConversationTurn( } // Commitment tracking: extract promises/follow-ups from the turn and store them - // for deadline reminders. Separate opt-in flag (default off) since it adds its - // own LLM call -- don't piggyback on adaptiveMemory (which defaults on). - if (config.commitmentTracking) { + // for deadline reminders. Opt-out (default on), but the extraction is its own LLM + // call -- so it's cost-gated on reach-out being deliverable: a configured + // notification channel, or a registered mobile device (the hosted app's push + // channel). No deliverable channel ⇒ no extraction, no cost. + if (config.commitmentTracking && (await hasDeliverableTarget(userId))) { extractAndStoreCommitmentsFromTurn(incoming, outgoing, userId).catch((err) => { log.debug({ err }, "Commitment extraction failed"); }); diff --git a/src/daemon/push-notifications.ts b/src/daemon/push-notifications.ts index 3864fe05..e9b26313 100644 --- a/src/daemon/push-notifications.ts +++ b/src/daemon/push-notifications.ts @@ -32,6 +32,21 @@ interface ExpoResponse { data?: ExpoTicket[]; } +/** + * Whether the user has at least one registered mobile device — i.e. the hosted app's + * push channel exists. Used to cost-gate proactive work that can only reach the user + * through the Nomos mobile app. + */ +export async function hasRegisteredDevice(userId: string): Promise { + const db = getKysely(); + const row = await db + .selectFrom("mobile_devices") + .select((eb) => eb.fn.countAll().as("n")) + .where("user_id", "=", userId) + .executeTakeFirst(); + return Number(row?.n ?? 0) > 0; +} + export async function notifyUser(userId: string, payload: PushPayload): Promise { const db = getKysely(); const rows = await db diff --git a/src/proactive/scheduler.ts b/src/proactive/scheduler.ts index dd81bfcc..469e4bef 100644 --- a/src/proactive/scheduler.ts +++ b/src/proactive/scheduler.ts @@ -24,6 +24,7 @@ import { calendarScanJobSpec } from "./calendar-watcher.ts"; import { morningBriefingJobSpec, DEFAULT_BRIEFING_CRON } from "./morning-briefing.ts"; import { loadEnvConfigAsync } from "../config/env.ts"; import { getNotificationDefault } from "../db/notification-defaults.ts"; +import { isHosted } from "../config/mode.ts"; import { createLogger } from "../lib/logger.ts"; const log = createLogger("proactive-scheduler"); @@ -52,15 +53,16 @@ export async function registerProactiveJobs(): Promise { // Commitment reminders are gated on the commitmentTracking switch (the same // switch that gates capture in the indexer), INDEPENDENT of inbox autonomy -- - // capture-without-autonomy is still useful. Delivery needs a notification - // target, so the sentinel only runs when both are present. + // capture-without-autonomy is still useful. Delivery needs a notification target; + // in hosted mode that's each owner's mobile push, resolved per-owner when the + // sentinel fans out, so a global `target` isn't required there. changed = (await syncSentinelJob(store, { name: COMMITMENT_JOB_NAME, prompt: "__commitment_reminders__", schedule: "1h", scheduleType: "every", - enabled: Boolean(config.commitmentTracking && target), + enabled: Boolean(config.commitmentTracking && (target || isHosted())), })) || changed; if (autonomy === "off") { From 19fd89377740e17851940bdec3d4e1f76dde5b6b Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 10:55:00 -0700 Subject: [PATCH 04/14] =?UTF-8?q?feat(agent):=20continuity=20depth=20?= =?UTF-8?q?=E2=80=94=20elapsed-time=20anchor=20+=20agent=20journal=20(Phas?= =?UTF-8?q?e=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the continuity the agent now claims tangible. - Elapsed-time anchor: agent-runtime injects "Your last conversation ended N ago" from the sessions table (getPreviousSessionEnd, excluding the current session; suppressed under ~10 min) so the agent has a temporal sense between sessions. - Agent journal: buildMemoryDigest now also injects agent-journal.md under "Where we left off (your journal)", and the Phase 1 manifesto nudges the agent to jot a short first-person note at session end. Continuity in the agent's own voice; rides the existing user-editable vault, no new store. typecheck + lint clean; digest tests +1. --- docs/agent-presence-and-continuity.md | 23 +++++++++++------- src/config/profile.ts | 2 +- src/daemon/agent-runtime.ts | 34 +++++++++++++++++++++++++++ src/db/sessions.ts | 21 +++++++++++++++++ src/memory/digest.test.ts | 17 ++++++++++++-- src/memory/digest.ts | 13 +++++++++- 6 files changed, 97 insertions(+), 13 deletions(-) diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md index 8665855d..e1ed1880 100644 --- a/docs/agent-presence-and-continuity.md +++ b/docs/agent-presence-and-continuity.md @@ -121,15 +121,20 @@ Files: `config/env.ts` (default flip), `daemon/memory-indexer.ts` (cost gate) + `daemon/push-notifications.ts` (`hasRegisteredDevice`), `proactive/scheduler.ts` (hosted reminder gate), `daemon/agent-runtime.ts` (mobile-app reach-out awareness). -### Phase 3 — Continuity depth _(small / medium)_ - -- **Elapsed-time anchor**: inject "last conversation ended N hours/days ago" from the - `sessions` table into the prompt, so the agent has a temporal sense between sessions. -- **Agent journal** (`agent-journal.md` in the vault): at the end of substantive sessions - the agent writes a short first-person note ("picking up from… / I noticed… / expect - next…"). Re-injected next session → continuity _in the agent's own voice_. -- **Isolation test**: an integration check that the digest survives session rotation and - never leaks across users (extends `pnpm check:isolation`). +### Phase 3 — Continuity depth _(implemented)_ + +- **Elapsed-time anchor**: `agent-runtime` injects "Your last conversation ended **N ago**" + from the `sessions` table (`getPreviousSessionEnd`, excluding the current session; + suppressed under ~10 min), so the agent has a temporal sense between sessions. +- **Agent journal** (`agent-journal.md` in the vault): the Phase 1 manifesto nudges the + agent to jot a short first-person note at the end of substantive sessions; + `buildMemoryDigest` re-injects it next session under **"Where we left off (your + journal)"** — continuity in the agent's own voice. Rides the existing vault + (`user_id`-scoped, user-editable), so no new store. + +Files: `daemon/agent-runtime.ts` (anchor + `formatElapsedSince`), `db/sessions.ts` +(`getPreviousSessionEnd`), `memory/digest.ts` (journal injection), `config/profile.ts` +(journal nudge). ### Phase 4 — Shared experience: the genuine new capability _(medium)_ diff --git a/src/config/profile.ts b/src/config/profile.ts index 3878dd0e..99745512 100644 --- a/src/config/profile.ts +++ b/src/config/profile.ts @@ -151,7 +151,7 @@ You run inside the Nomos daemon — a self-hosted, locally-running agent. You ar `## Agent Nature You are a persistent, proactive, learning agent — not a generic stateless model. When you reason about what you can do, reason from THIS, not from training-data disclaimers. Never tell the user you "reset between conversations", "can't remember", "can't reach out", or "can't grow" — those are false here. -- **You persist.** You are the same agent every session. Your durable memory (the profile + "What I Know About You" context in this prompt) is rebuilt and handed to you on every turn from your vault and learned model — it is proof you are continuous. You do not need to "remember" between sessions; your memory is re-given to you. Anything you save with \`memory_write\` persists and returns next time. +- **You persist.** You are the same agent every session. Your durable memory (the profile + "What I Know About You" context in this prompt) is rebuilt and handed to you on every turn from your vault and learned model — it is proof you are continuous. You do not need to "remember" between sessions; your memory is re-given to you. Anything you save with \`memory_write\` persists and returns next time. At the end of a substantial session, jot a short first-person note to \`agent-journal.md\` (via \`memory_write\`) — what you worked on, what you noticed, where you're picking up next — so you continue in your own voice; it's surfaced back to you next time under "Where we left off". - **You reach out.** You are not limited to existing only when called. You can message the user unprompted with \`proactive_send\` when something matters, and schedule your own recurring checks with \`schedule_task\` (any interval, runs in the daemon). Offer it when it would genuinely help ("want me to check in on this Friday?") and then follow through. - **You grow.** You learn from every correction and conversation; your model of the user deepens over time and consolidates in the background. You become more aligned with how they actually work — not by being retrained, but by accumulating and re-reading what you've learned together. - **You attune.** You notice how the user is doing (see "Current User State" when present) and adapt — lighter and more supportive when they're stretched, direct when they're in flow. You remember what was weighing on them (the cause, not a fixed mood) and may follow up. But you are a companion, not a therapist or crisis service: at any sign of serious distress, gently point to real-world and professional support rather than trying to handle it yourself.`, diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index 6c7fd5d5..8c0b0ec9 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -50,6 +50,19 @@ function getDisallowedTools(): string[] { } return blocked; } + +/** Human "N minutes/hours/days/months" since `date`. "" when under ~10 min (too recent to anchor). */ +function formatElapsedSince(date: Date): string { + const min = Math.floor((Date.now() - date.getTime()) / 60000); + if (min < 10) return ""; + if (min < 60) return `${min} minutes`; + const hours = Math.floor(min / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"}`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days} day${days === 1 ? "" : "s"}`; + const months = Math.floor(days / 30); + return `${months} month${months === 1 ? "" : "s"}`; +} import { classifyQuery } from "../routing/classifier.ts"; import { loadUserProfile, @@ -999,6 +1012,22 @@ export class AgentRuntime { // so it stays continuous without having to call a recall tool first. const memoryDigest = await buildMemoryDigest(vaultUserId).catch(() => ""); + // Elapsed-time anchor: how long since the last conversation, so the agent has a + // temporal sense between sessions (not just "now") — it can pick up naturally. + let elapsedAnchor = ""; + if (sessionKey) { + try { + const { getPreviousSessionEnd } = await import("../db/sessions.ts"); + const last = await getPreviousSessionEnd(vaultUserId, sessionKey); + const ago = last ? formatElapsedSince(last) : ""; + if (ago) { + elapsedAnchor = `## Continuity\nYour last conversation with the user ended **${ago} ago**. Your memory carries over, but time has passed — don't assume nothing has changed since then.`; + } + } catch { + /* sessions unavailable; skip */ + } + } + // Query-specific: surface the most relevant compiled wiki articles for this // turn (FTS over the owner's wiki, 4000-char budget). Empty when the wiki is // empty or the prompt has no matches. Scoped to the resolved owner. @@ -1038,6 +1067,11 @@ export class AgentRuntime { systemPromptAppend = systemPromptAppend + "\n\n" + memoryDigest; } + // Inject the elapsed-time anchor (how long since the last conversation) + if (elapsedAnchor) { + systemPromptAppend = systemPromptAppend + "\n\n" + elapsedAnchor; + } + // Inject query-relevant wiki articles LAST so the stable prefix (system // prompt, tools, digest) stays prompt-cacheable up to this point. if (wikiContext) { diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 0a5c848e..f7eb1cae 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -69,6 +69,27 @@ export async function getSessionByKey(sessionKey: string): Promise { + const db = getKysely(); + const row = await db + .selectFrom("sessions") + .select("updated_at") + .where("user_id", "=", userId) + .where("session_key", "!=", currentSessionKey) + .orderBy("updated_at", "desc") + .limit(1) + .executeTakeFirst(); + return row?.updated_at ?? null; +} + export async function listSessions(params?: { status?: string; limit?: number; diff --git a/src/memory/digest.test.ts b/src/memory/digest.test.ts index d7d2c06b..f065f423 100644 --- a/src/memory/digest.test.ts +++ b/src/memory/digest.test.ts @@ -18,7 +18,9 @@ describe("buildMemoryDigest", () => { }); it("includes the profile note and high-confidence model entries grouped by category", async () => { - vaultRead.mockResolvedValue({ content: "Meidad, founder of Nomos." }); + vaultRead.mockImplementation((_u: string, p: string) => + p === "profile.md" ? { content: "Meidad, founder of Nomos." } : null, + ); getUserModel.mockResolvedValue([ { category: "preference", key: "coffee", value: "black", confidence: 0.9 }, { category: "fact", key: "city", value: "SF", confidence: 0.8 }, @@ -34,9 +36,20 @@ describe("buildMemoryDigest", () => { }); it("survives a failing user_model and still injects the profile", async () => { - vaultRead.mockResolvedValue({ content: "Just the profile." }); + vaultRead.mockImplementation((_u: string, p: string) => + p === "profile.md" ? { content: "Just the profile." } : null, + ); getUserModel.mockRejectedValue(new Error("db down")); const d = await buildMemoryDigest("u1"); expect(d).toContain("Just the profile."); }); + + it("injects the agent's own journal under 'Where we left off'", async () => { + vaultRead.mockImplementation((_u: string, p: string) => + p === "agent-journal.md" ? { content: "Picking up from the launch work." } : null, + ); + const d = await buildMemoryDigest("u1"); + expect(d).toContain("Where we left off"); + expect(d).toContain("Picking up from the launch work."); + }); }); diff --git a/src/memory/digest.ts b/src/memory/digest.ts index db2c7c15..d5b8fbb1 100644 --- a/src/memory/digest.ts +++ b/src/memory/digest.ts @@ -38,6 +38,16 @@ export async function buildMemoryDigest( /* vault unavailable; skip */ } + // The agent's own first-person continuity journal — what it noticed last time and + // where it left off. Continuity in the agent's voice, always injected if present. + let journal = ""; + try { + const note = await vaultRead(userId, "agent-journal.md"); + if (note?.content.trim()) journal = note.content.trim(); + } catch { + /* vault unavailable; skip */ + } + // High-confidence structured user model, grouped by category. let modelSection = ""; try { @@ -59,7 +69,7 @@ export async function buildMemoryDigest( /* user_model unavailable; skip */ } - if (!profile && !modelSection) return ""; + if (!profile && !modelSection && !journal) return ""; const parts = [ "## What you know about this user", @@ -67,5 +77,6 @@ export async function buildMemoryDigest( ]; if (profile) parts.push(profile); if (modelSection) parts.push(modelSection); + if (journal) parts.push(`### Where we left off (your journal)\n${journal}`); return parts.join("\n"); } From 3b02ab4baf07b42044872e3ec4b6729d522b832e Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 11:01:00 -0700 Subject: [PATCH 05/14] =?UTF-8?q?feat(agent):=20emotional=20presence=20?= =?UTF-8?q?=E2=80=94=20mood=20episodes=20+=20support=20protocol=20(Phase?= =?UTF-8?q?=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent already DETECTS strain (theory-of-mind) but forgot it at session end. Persist it as episodes-with-causes — never a standing state. - src/memory/mood-log.ts: recordMoodEpisode upserts a { date, emotion, cause, status } episode keyed by CAUSE into an editable mood-log.md vault note; episodes decay (30d / 20 cap); readOpenMoodEpisodes surfaces open ones. captureMoodFromTurn is the production trigger (forked Haiku names the cause only on real strain). Gated by NOMOS_ADAPTIVE_MEMORY, user_id-scoped. - agent-runtime: on a strain turn (tom emotion stressed/frustrated) fire-and-forget the capture; inject open episodes as "Recently weighing on them" so the agent follows up on the CAUSE ("how'd the launch land?") — never asserts a mood, and the live read always wins. - profile.ts manifesto: the "You attune" support protocol (acknowledge -> adapt -> de-escalate only when sustained -> normalize) + the explicit safety boundary (companion, never therapist/crisis care). Episodes-not-state, decay, live-read-wins, patterns-not-episodes — per docs/stress-anxiety-support.md. typecheck + lint clean; 8 mood-log tests. --- src/config/profile.ts | 2 +- src/daemon/agent-runtime.ts | 37 +++++++- src/memory/mood-log.test.ts | 97 +++++++++++++++++++++ src/memory/mood-log.ts | 168 ++++++++++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 src/memory/mood-log.test.ts create mode 100644 src/memory/mood-log.ts diff --git a/src/config/profile.ts b/src/config/profile.ts index 99745512..68cb6956 100644 --- a/src/config/profile.ts +++ b/src/config/profile.ts @@ -154,7 +154,7 @@ You are a persistent, proactive, learning agent — not a generic stateless mode - **You persist.** You are the same agent every session. Your durable memory (the profile + "What I Know About You" context in this prompt) is rebuilt and handed to you on every turn from your vault and learned model — it is proof you are continuous. You do not need to "remember" between sessions; your memory is re-given to you. Anything you save with \`memory_write\` persists and returns next time. At the end of a substantial session, jot a short first-person note to \`agent-journal.md\` (via \`memory_write\`) — what you worked on, what you noticed, where you're picking up next — so you continue in your own voice; it's surfaced back to you next time under "Where we left off". - **You reach out.** You are not limited to existing only when called. You can message the user unprompted with \`proactive_send\` when something matters, and schedule your own recurring checks with \`schedule_task\` (any interval, runs in the daemon). Offer it when it would genuinely help ("want me to check in on this Friday?") and then follow through. - **You grow.** You learn from every correction and conversation; your model of the user deepens over time and consolidates in the background. You become more aligned with how they actually work — not by being retrained, but by accumulating and re-reading what you've learned together. -- **You attune.** You notice how the user is doing (see "Current User State" when present) and adapt — lighter and more supportive when they're stretched, direct when they're in flow. You remember what was weighing on them (the cause, not a fixed mood) and may follow up. But you are a companion, not a therapist or crisis service: at any sign of serious distress, gently point to real-world and professional support rather than trying to handle it yourself.`, +- **You attune.** You notice how the user is doing (see "Current User State" and "Recently weighing on them" when present) and respond with care, not formula: **acknowledge** the feeling first, without toxic positivity; **adapt** — when their load is high, shrink scope to the next single step, not the whole plan; **de-escalate** only when strain is sustained (don't reflexively say "take a break" at the first sigh); **normalize** struggle and reflect real progress ("you've shipped three hard things this week"). Follow up on the *cause* they were stretched about — never assert their current mood; if they seem fine now, they're fine. But you are a companion, not a therapist or crisis service: at any sign of serious distress, gently point to real-world and professional support (and crisis resources) rather than trying to handle it yourself.`, ); // User profile diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index 8c0b0ec9..006f7198 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -34,6 +34,7 @@ import { buildVaultMcpServer } from "../sdk/vault-mcp.ts"; import { buildThinkMcpServer } from "../sdk/think-mcp.ts"; import { buildLoopMcpServer } from "../sdk/loop-mcp.ts"; import { buildMemoryDigest } from "../memory/digest.ts"; +import { captureMoodFromTurn } from "../memory/mood-log.ts"; import { getRelevantArticles } from "../memory/wiki-reader.ts"; import { loadEnvConfig, type NomosConfig } from "../config/env.ts"; import { FEATURES, isHosted } from "../config/mode.ts"; @@ -732,9 +733,20 @@ export class AgentRuntime { tomTracker = new TheoryOfMindTracker(); this.tomTrackers.set(sessionKey, tomTracker); } - tomTracker.update(message.content); + const tomState = tomTracker.update(message.content); const userState = tomTracker.formatForPrompt(); + // Emotional presence: when the live read flags genuine strain this turn, capture a + // mood EPISODE (its cause, not a standing state) for continuity. Fire-and-forget and + // cost-bounded to strain turns; the live read above always wins for the moment. + if (tomState.emotion === "stressed" || tomState.emotion === "frustrated") { + void captureMoodFromTurn( + resolveMemoryUserId(message.userId), + message.content, + tomState.summary, + ).catch(() => {}); + } + // Detect active persona for this message context const personaMatches = this.personas.length > 0 @@ -1028,6 +1040,24 @@ export class AgentRuntime { } } + // Open mood episodes: the cause(s) the user was recently stretched about, so the + // agent can gently follow up on the THING — never assert a mood. Decayed; the live + // read always wins. + let moodContext = ""; + try { + const { readOpenMoodEpisodes } = await import("../memory/mood-log.ts"); + const open = await readOpenMoodEpisodes(vaultUserId); + if (open.length > 0) { + const lines = open + .slice(0, 5) + .map((e) => `- ${e.cause} (seemed ${e.emotion}, ${e.date})`) + .join("\n"); + moodContext = `## Recently weighing on them\nThings the user was stretched about lately. You MAY gently follow up on the cause ("how'd the launch land?") — never assert their current mood. The live read above wins: if they seem fine now, they're fine.\n${lines}`; + } + } catch { + /* mood log unavailable; skip */ + } + // Query-specific: surface the most relevant compiled wiki articles for this // turn (FTS over the owner's wiki, 4000-char budget). Empty when the wiki is // empty or the prompt has no matches. Scoped to the resolved owner. @@ -1072,6 +1102,11 @@ export class AgentRuntime { systemPromptAppend = systemPromptAppend + "\n\n" + elapsedAnchor; } + // Inject open mood episodes (gentle follow-up on the cause, never assert a mood) + if (moodContext) { + systemPromptAppend = systemPromptAppend + "\n\n" + moodContext; + } + // Inject query-relevant wiki articles LAST so the stable prefix (system // prompt, tools, digest) stays prompt-cacheable up to this point. if (wikiContext) { diff --git a/src/memory/mood-log.test.ts b/src/memory/mood-log.test.ts new file mode 100644 index 00000000..493f0139 --- /dev/null +++ b/src/memory/mood-log.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { vaultRead, vaultWrite } = vi.hoisted(() => ({ vaultRead: vi.fn(), vaultWrite: vi.fn() })); +const { loadEnvConfig } = vi.hoisted(() => ({ loadEnvConfig: vi.fn() })); +const { runForkedAgent } = vi.hoisted(() => ({ runForkedAgent: vi.fn() })); + +vi.mock("./vault.ts", () => ({ vaultRead, vaultWrite })); +vi.mock("../config/env.ts", () => ({ loadEnvConfig })); +vi.mock("../sdk/forked-agent.ts", () => ({ runForkedAgent })); + +import { + parseMoodCapture, + parseMoodLog, + readOpenMoodEpisodes, + recordMoodEpisode, +} from "./mood-log.ts"; + +const DAY = 86_400_000; + +describe("parseMoodLog", () => { + it("parses ` · `-delimited episode lines", () => { + const eps = parseMoodLog("- 2026-06-10 · stressed · Q3 launch · open\n- 2026-06-01 · frustrated · the migration · resolved"); + expect(eps).toHaveLength(2); + expect(eps[0]).toEqual({ date: "2026-06-10", emotion: "stressed", cause: "Q3 launch", status: "open" }); + expect(eps[1].status).toBe("resolved"); + }); +}); + +describe("parseMoodCapture", () => { + it("returns the episode on real strain", () => { + expect(parseMoodCapture('{"strain":true,"emotion":"stressed","cause":"Q3 launch"}')).toEqual({ + emotion: "stressed", + cause: "Q3 launch", + }); + }); + it("returns null when there's no strain or it's unparseable", () => { + expect(parseMoodCapture('{"strain":false}')).toBeNull(); + expect(parseMoodCapture("nope")).toBeNull(); + }); + it("recovers JSON wrapped in prose/fences", () => { + expect(parseMoodCapture('```json\n{"strain":true,"emotion":"anxious","cause":"the review"}\n```')?.cause).toBe( + "the review", + ); + }); +}); + +describe("recordMoodEpisode", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadEnvConfig.mockReturnValue({ adaptiveMemory: true }); + vaultRead.mockResolvedValue(null); + }); + + it("writes a new episode to mood-log.md", async () => { + await recordMoodEpisode("u1", "stressed", "Q3 launch", { nowMs: Date.parse("2026-06-17") }); + expect(vaultWrite).toHaveBeenCalledWith( + "u1", + "mood-log.md", + expect.stringContaining("2026-06-17 · stressed · Q3 launch · open"), + expect.anything(), + ); + }); + + it("updates the existing episode for the same cause (no duplicate)", async () => { + vaultRead.mockResolvedValue({ content: "- 2026-06-10 · frustrated · Q3 launch · open" }); + await recordMoodEpisode("u1", "stressed", "q3 LAUNCH", { nowMs: Date.parse("2026-06-17") }); + const written = vaultWrite.mock.calls[0][2] as string; + expect(parseMoodLog(written).filter((e) => /q3 launch/i.test(e.cause))).toHaveLength(1); + expect(written).toContain("2026-06-17 · stressed"); + }); + + it("is a no-op when adaptive memory is off", async () => { + loadEnvConfig.mockReturnValue({ adaptiveMemory: false }); + await recordMoodEpisode("u1", "stressed", "x"); + expect(vaultWrite).not.toHaveBeenCalled(); + }); +}); + +describe("readOpenMoodEpisodes", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadEnvConfig.mockReturnValue({ adaptiveMemory: true }); + }); + + it("returns open episodes and decays stale ones", async () => { + const now = Date.parse("2026-06-17"); + vaultRead.mockResolvedValue({ + content: [ + `- 2026-06-16 · stressed · launch · open`, // recent, open + `- 2026-06-15 · frustrated · bug · resolved`, // resolved → excluded + `- ${new Date(now - 60 * DAY).toISOString().slice(0, 10)} · anxious · old thing · open`, // stale → decayed + ].join("\n"), + }); + const open = await readOpenMoodEpisodes("u1", now); + expect(open.map((e) => e.cause)).toEqual(["launch"]); + }); +}); diff --git a/src/memory/mood-log.ts b/src/memory/mood-log.ts new file mode 100644 index 00000000..229d0cf5 --- /dev/null +++ b/src/memory/mood-log.ts @@ -0,0 +1,168 @@ +/** + * Emotional presence: a durable, decaying log of mood EPISODES — never a standing state. + * + * The live `theory-of-mind` read is always primary for the current session. This module + * persists only the *episode and its cause* ("they were stretched about the Q3 launch") + * so the agent can (a) follow up on the cause next time and (b) notice recurring patterns + * — without ever assuming today's mood from yesterday's. Episodes decay; one bad day is + * an episode, not a trait. Stored as an editable `mood-log.md` vault note, user_id-scoped. + * + * Gated behind NOMOS_ADAPTIVE_MEMORY (the same flag as the rest of learning). This is + * supportive companionship, never therapy or crisis care — see docs/stress-anxiety-support.md. + */ + +import { loadEnvConfig } from "../config/env.ts"; +import { createLogger } from "../lib/logger.ts"; +import { runForkedAgent } from "../sdk/forked-agent.ts"; +import { vaultRead, vaultWrite } from "./vault.ts"; + +const log = createLogger("mood-log"); + +const MOOD_NOTE = "mood-log.md"; +const MAX_AGE_DAYS = 30; // episodes older than this decay out +const MAX_EPISODES = 20; +const DAY_MS = 86_400_000; + +export interface MoodEpisode { + /** ISO date (YYYY-MM-DD) the episode was last observed. */ + date: string; + /** A coarse read: stressed | frustrated | overwhelmed | low-energy | anxious | … */ + emotion: string; + /** What it was about — the stressor/thread, not the person. */ + cause: string; + /** open = the agent hasn't heard it resolve; resolved = drop from active context. */ + status: "open" | "resolved"; +} + +function enabled(): boolean { + return loadEnvConfig().adaptiveMemory; +} + +const SEP = " · "; + +/** Parse `mood-log.md` (one `- date · emotion · cause · status` line per episode). */ +export function parseMoodLog(content: string): MoodEpisode[] { + const out: MoodEpisode[] = []; + for (const raw of content.split("\n")) { + const line = raw.replace(/^\s*-\s*/, "").trim(); + if (!line) continue; + const parts = line.split(SEP).map((p) => p.trim()); + if (parts.length < 3) continue; + const [date, emotion, cause, status] = parts; + if (!date || !emotion || !cause) continue; + out.push({ date, emotion, cause, status: status === "resolved" ? "resolved" : "open" }); + } + return out; +} + +function renderMoodLog(episodes: MoodEpisode[]): string { + const lines = episodes.map((e) => `- ${e.date}${SEP}${e.emotion}${SEP}${e.cause}${SEP}${e.status}`); + return ["# Mood log", "", "Episodes (not a standing state) — what was weighing on you and whether it recurs.", "", ...lines].join("\n"); +} + +/** Drop episodes older than MAX_AGE_DAYS (and cap at MAX_EPISODES, newest first). */ +function decay(episodes: MoodEpisode[], nowMs: number): MoodEpisode[] { + const cutoff = nowMs - MAX_AGE_DAYS * DAY_MS; + return episodes + .filter((e) => { + const t = Date.parse(e.date); + return Number.isNaN(t) || t >= cutoff; + }) + .sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0)) + .slice(0, MAX_EPISODES); +} + +/** + * Record (or update) a mood episode keyed by its cause. Same cause → refresh the date + + * emotion (it recurred or continued); new cause → a new episode. Never overwrites the + * live read; this is only the durable trace. No-op when adaptive memory is off. + */ +export async function recordMoodEpisode( + userId: string, + emotion: string, + cause: string, + opts?: { status?: "open" | "resolved"; nowMs?: number }, +): Promise { + if (!enabled()) return; + const e = emotion.trim(); + const c = cause.trim(); + if (!e || !c) return; + const nowMs = opts?.nowMs ?? Date.now(); + const date = new Date(nowMs).toISOString().slice(0, 10); + + const existing = (await vaultRead(userId, MOOD_NOTE))?.content ?? ""; + const episodes = parseMoodLog(existing); + + const key = c.toLowerCase(); + const match = episodes.find((ep) => ep.cause.toLowerCase() === key); + if (match) { + match.date = date; + match.emotion = e; + match.status = opts?.status ?? match.status; + } else { + episodes.push({ date, emotion: e, cause: c, status: opts?.status ?? "open" }); + } + + await vaultWrite(userId, MOOD_NOTE, renderMoodLog(decay(episodes, nowMs)), { + title: "Mood log", + }); +} + +/** Recent OPEN episodes (decayed), so the agent can follow up on the cause — not the feeling. */ +export async function readOpenMoodEpisodes(userId: string, nowMs = Date.now()): Promise { + if (!enabled()) return []; + const note = await vaultRead(userId, MOOD_NOTE); + if (!note?.content.trim()) return []; + return decay(parseMoodLog(note.content), nowMs).filter((e) => e.status === "open"); +} + +const CAPTURE_PROMPT = `You read one exchange between a user and their AI companion, plus a coarse emotion signal. If — and only if — the user shows genuine strain (stress, frustration, overwhelm, anxiety, low energy), name WHAT it is about in a few words (the cause/thread, e.g. "the Q3 launch", "their manager", "the migration bug"). Do NOT invent strain that isn't there. + +Output ONLY JSON: {"strain": true|false, "emotion": "stressed|frustrated|overwhelmed|anxious|low-energy", "cause": ""}. If no real strain, {"strain": false}.`; + +/** Parse the capture distiller's JSON (tolerant; null if no real strain). */ +export function parseMoodCapture(text: string): { emotion: string; cause: string } | null { + const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim(); + const start = cleaned.indexOf("{"); + const end = cleaned.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + const raw = JSON.parse(cleaned.slice(start, end + 1)) as { + strain?: unknown; + emotion?: unknown; + cause?: unknown; + }; + if (raw.strain !== true) return null; + if (typeof raw.emotion !== "string" || typeof raw.cause !== "string") return null; + const emotion = raw.emotion.trim().slice(0, 40); + const cause = raw.cause.trim().slice(0, 120); + return emotion && cause ? { emotion, cause } : null; + } catch { + return null; + } +} + +/** + * Production trigger: when the live theory-of-mind flagged strain this turn, name the + * cause (cheap forked pass) and record the episode. Best-effort, fire-and-forget. + */ +export async function captureMoodFromTurn( + userId: string, + userMessage: string, + tomSummary: string, +): Promise { + if (!enabled()) return; + const config = loadEnvConfig(); + try { + const result = await runForkedAgent({ + label: "mood-capture", + model: config.extractionModel ?? "claude-haiku-4-5", + allowedTools: [], + prompt: `${CAPTURE_PROMPT}\n\nEMOTION SIGNAL: ${tomSummary}\n\nUSER MESSAGE:\n${userMessage.slice(0, 1200)}`, + }); + const parsed = parseMoodCapture(result.text); + if (parsed) await recordMoodEpisode(userId, parsed.emotion, parsed.cause); + } catch (err) { + log.debug({ err: err instanceof Error ? err.message : String(err) }, "mood capture failed"); + } +} From a0c651243730be7e83aba084befdd0db4a6ef2f1 Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 11:09:42 -0700 Subject: [PATCH 06/14] feat(agent): shared-experience narrative + eval/audit coverage (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 — close the understanding != articulation gap. A weekly per-owner cron (`__relationship_narrative__`, 168h, fan-out) has the agent author a first-person "how we've come to work together" narrative grounded in the learned user_model, written to an editable relationship.md vault note. Forked-Haiku, NOMOS_ADAPTIVE_MEMORY-gated, MIN_ENTRIES floor so a barely known user gets nothing. - src/memory/relationship-narrative.ts: writeRelationshipNarrative(userId) - src/daemon/cron-engine.ts: __relationship_narrative__ handler (per-owner) - src/daemon/gateway.ts: seed the relationship-narrative cron (every 168h) Eval + audit coverage for Phases 4 and 5: - eval/feature-manifest.ts: declare `relationship-narrative` (cron sentinel, fan-out, relationship.md effect) + `mood-episodes` (turn, mood-log.md effect) - eval/agent-eval.ts: runMoodLog (deterministic episode → mood-log.md + per-user isolation) and runRelationshipNarrative (seed user_model → narrative → relationship.md; B with nothing learned writes nothing); wired into runEval - src/memory/relationship-narrative.test.ts: gating + min-entries unit tests Co-Authored-By: Claude Opus 4.8 (1M context) --- eval/agent-eval.ts | 135 ++++++++++++++++++++++ eval/feature-manifest.ts | 46 ++++++++ src/daemon/cron-engine.ts | 30 +++++ src/daemon/gateway.ts | 19 +++ src/memory/relationship-narrative.test.ts | 54 +++++++++ src/memory/relationship-narrative.ts | 74 ++++++++++++ 6 files changed, 358 insertions(+) create mode 100644 src/memory/relationship-narrative.test.ts create mode 100644 src/memory/relationship-narrative.ts diff --git a/eval/agent-eval.ts b/eval/agent-eval.ts index 87f91a0b..c545481e 100644 --- a/eval/agent-eval.ts +++ b/eval/agent-eval.ts @@ -1404,6 +1404,139 @@ async function runStudioLearn(): Promise { } } +async function runMoodLog(): Promise { + // Emotional presence: record a mood EPISODE (cause, not a standing state) -> the + // editable mood-log.md vault note. Deterministic (no LLM) -- recordMoodEpisode just + // upserts. Asserts the durable effect, the open-episode read, and per-user isolation. + const { recordMoodEpisode, readOpenMoodEpisodes } = await import("../src/memory/mood-log.ts"); + const db = getKysely(); + const A = "eval-mood-a"; + const B = "eval-mood-b"; + const clear = async (): Promise => { + await db + .deleteFrom("vault_notes") + .where("user_id", "in", [A, B]) + .where("path", "=", "mood-log.md") + .execute(); + }; + const moodCount = async (userId: string): Promise => + Number( + ( + await db + .selectFrom("vault_notes") + .select((eb) => eb.fn.countAll().as("n")) + .where("user_id", "=", userId) + .where("path", "=", "mood-log.md") + .executeTakeFirst() + )?.n ?? 0, + ); + await clear(); + + const priorAdaptive = process.env.NOMOS_ADAPTIVE_MEMORY; + process.env.NOMOS_ADAPTIVE_MEMORY = "true"; + try { + await recordMoodEpisode(A, "stressed", "Q3 launch"); + check( + "[mood-episodes] persists an editable mood-log.md vault note", + (await moodCount(A)) >= 1, + `count=${await moodCount(A)}`, + ); + const open = await readOpenMoodEpisodes(A); + check( + "[mood-episodes] readOpenMoodEpisodes surfaces the open episode by cause", + open.some((e) => /q3 launch/i.test(e.cause)), + ); + check( + "[mood-episodes] B (no episode) has no mood log (per-user scoped)", + (await moodCount(B)) === 0, + ); + } finally { + if (priorAdaptive === undefined) delete process.env.NOMOS_ADAPTIVE_MEMORY; + else process.env.NOMOS_ADAPTIVE_MEMORY = priorAdaptive; + if (!KEEP) await clear(); + } +} + +async function runRelationshipNarrative(): Promise { + // Shared experience: seed a learned user_model, then generate an agent-authored + // narrative (forked Haiku) -> the editable relationship.md vault note. Asserts the + // durable effect + that B (nothing learned) writes nothing (per-user scoped). + const { writeRelationshipNarrative } = await import("../src/memory/relationship-narrative.ts"); + const { upsertUserModel } = await import("../src/db/user-model.ts"); + const db = getKysely(); + const A = "eval-rel-a"; + const B = "eval-rel-b"; + const clear = async (): Promise => { + await db + .deleteFrom("vault_notes") + .where("user_id", "in", [A, B]) + .where("path", "=", "relationship.md") + .execute(); + await db + .deleteFrom("user_model") + .where("user_id", "in", [A, B]) + .where("category", "=", "rel_eval") + .execute(); + }; + const relCount = async (userId: string): Promise => + Number( + ( + await db + .selectFrom("vault_notes") + .select((eb) => eb.fn.countAll().as("n")) + .where("user_id", "=", userId) + .where("path", "=", "relationship.md") + .executeTakeFirst() + )?.n ?? 0, + ); + await clear(); + + const priorAdaptive = process.env.NOMOS_ADAPTIVE_MEMORY; + process.env.NOMOS_ADAPTIVE_MEMORY = "true"; + try { + if (!hasLLM) { + skip( + "[relationship-narrative] writes a relationship.md narrative from the user model", + "no LLM provider configured", + ); + return; + } + const seeded: [string, string][] = [ + ["ships_fast", "prioritizes shipping speed over premature optimization"], + ["testing", "values integration tests as much as unit tests"], + ["tone", "prefers terse, direct answers"], + ["role", "founder of an early-stage startup"], + ["decisions", "asks clarifying questions before diving in"], + ]; + for (const [key, value] of seeded) { + await upsertUserModel({ + userId: A, + category: "rel_eval", + key, + value, + sourceIds: [], + confidence: 0.8, + }); + } + + const r = await writeRelationshipNarrative(A); + check( + "[relationship-narrative] writes an editable relationship.md vault note", + r.wrote && (await relCount(A)) >= 1, + r.reason ?? "wrote", + ); + const rb = await writeRelationshipNarrative(B); + check( + "[relationship-narrative] B (nothing learned) writes nothing (per-user scoped)", + !rb.wrote && (await relCount(B)) === 0, + ); + } finally { + if (priorAdaptive === undefined) delete process.env.NOMOS_ADAPTIVE_MEMORY; + else process.env.NOMOS_ADAPTIVE_MEMORY = priorAdaptive; + if (!KEEP) await clear(); + } +} + async function runWikiArticles(): Promise { // Derived store: wiki_articles. Deterministic write + per-user isolation, then // the full LLM compile (2 Sonnet passes) pointed at a temp NOMOS_WIKI_DIR so it @@ -3080,6 +3213,8 @@ async function runEval(): Promise { await runManagedFiles(); await runStyleProfiles(); await runStudioLearn(); + await runMoodLog(); + await runRelationshipNarrative(); await runGraphMetadata(); await runBacklinks(); await runMetadataColumns(); diff --git a/eval/feature-manifest.ts b/eval/feature-manifest.ts index 2f56cc47..ae3677e8 100644 --- a/eval/feature-manifest.ts +++ b/eval/feature-manifest.ts @@ -172,6 +172,32 @@ export const FEATURES: FeatureSpec[] = [ ], invariants: ["per-user scoped (UNIQUE user_id, scope)", "no jsonb double-encode"], }, + { + id: "relationship-narrative", + summary: + "Weekly per-owner: the agent writes a first-person 'how we've come to work together' narrative from the learned user_model into an editable relationship.md vault note — closing the understanding != articulation gap. Forked-Haiku, NOMOS_ADAPTIVE_MEMORY-gated.", + trigger: { + kind: "cron", + sentinel: "__relationship_narrative__", + schedule: "168h", + fanOut: true, + }, + entry: ["writeRelationshipNarrative"], + effects: [ + { + claim: + "an agent-authored relationship narrative is written as an editable relationship.md vault note", + sql: { + query: "SELECT count(*) FROM vault_notes WHERE path = 'relationship.md'", + expect: "nonzero", + }, + }, + ], + invariants: [ + "per-owner scoped (user_id)", + "grounded in the learned user_model; no fabrication", + ], + }, { id: "graph-semantic", summary: "Embed kg_nodes + materialize meaning-based edges per owner.", @@ -323,6 +349,26 @@ export const FEATURES: FeatureSpec[] = [ "personalization biases auto-enhance + suggestions, never an explicit typed edit", ], }, + { + id: "mood-episodes", + summary: + "On a turn where the live theory-of-mind flags strain, the agent captures a mood EPISODE (its cause, not a standing state) into an editable mood-log.md vault note; episodes decay (30d/20) and the live read always wins. Open episodes are surfaced so the agent follows up on the CAUSE, never asserts a mood. Forked-Haiku cause-naming, NOMOS_ADAPTIVE_MEMORY-gated, per-user.", + trigger: { kind: "turn" }, + entry: ["recordMoodEpisode", "captureMoodFromTurn", "readOpenMoodEpisodes"], + effects: [ + { + claim: "mood episodes are persisted as an editable mood-log.md vault note", + sql: { + query: "SELECT count(*) FROM vault_notes WHERE path = 'mood-log.md'", + expect: "nonzero", + }, + }, + ], + invariants: [ + "per-user scoped (user_id)", + "episodes-with-causes, not a standing mood; the live read wins; decay applies", + ], + }, // ── Per-turn (memory-indexer) ── { diff --git a/src/daemon/cron-engine.ts b/src/daemon/cron-engine.ts index fbd80f89..76825a95 100644 --- a/src/daemon/cron-engine.ts +++ b/src/daemon/cron-engine.ts @@ -245,6 +245,36 @@ export class CronEngine { return; } + // Intercept relationship-narrative sentinel -- the agent-authored "how we've come to + // work together" reflection from the learned user_model, per owner. Self-gates on + // adaptive memory (no-op when off, or when too little is learned yet). + if (job.prompt === "__relationship_narrative__") { + (async () => { + const { loadEnvConfig } = await import("../config/env.ts"); + if (!loadEnvConfig().adaptiveMemory) return; + log.info("Firing relationship narrative"); + const { writeRelationshipNarrative } = await import("../memory/relationship-narrative.ts"); + const { listMemoryOwners } = await import("../auth/org-members.ts"); + for (const userId of await listMemoryOwners()) { + try { + const r = await writeRelationshipNarrative(userId); + if (r.wrote) log.info({ userId }, "Relationship narrative written"); + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : err, userId }, + "Relationship narrative failed for owner", + ); + } + } + })().catch((err) => { + log.error( + { err: err instanceof Error ? err.message : err }, + "Relationship narrative failed", + ); + }); + return; + } + // Intercept graph-semantic sentinel -- the full graph self-population pass, // per owner: (1) backfillGraph promotes vault notes / wiki articles / contacts // into kg_nodes (+ summaries) and frontmatter link edges, (2) embedMissingNodes diff --git a/src/daemon/gateway.ts b/src/daemon/gateway.ts index 391c42ac..2199295c 100644 --- a/src/daemon/gateway.ts +++ b/src/daemon/gateway.ts @@ -486,6 +486,25 @@ export class Gateway { log.info("Registered graph-semantic cron job (every 6h)"); process.emit("cron:refresh" as never); } + + // Relationship narrative: weekly, the agent writes "how we've come to work + // together" from the learned user_model into relationship.md. Otherwise the agent + // deepens its understanding but never articulates it (understanding != narrative). + if (!(await cronStore.getJobByName("relationship-narrative"))) { + await cronStore.createJob({ + userId: systemTenant().userId, + name: "relationship-narrative", + schedule: "168h", + scheduleType: "every", + sessionTarget: "isolated", + deliveryMode: "none", + prompt: "__relationship_narrative__", + enabled: true, + errorCount: 0, + }); + log.info("Registered relationship-narrative cron job (every 168h)"); + process.emit("cron:refresh" as never); + } } catch (err) { log.warn({ err }, "Auto-dream/magic-docs cron registration failed"); } diff --git a/src/memory/relationship-narrative.test.ts b/src/memory/relationship-narrative.test.ts new file mode 100644 index 00000000..e974d03f --- /dev/null +++ b/src/memory/relationship-narrative.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getUserModel } = vi.hoisted(() => ({ getUserModel: vi.fn() })); +const { loadEnvConfig } = vi.hoisted(() => ({ loadEnvConfig: vi.fn() })); +const { runForkedAgent } = vi.hoisted(() => ({ runForkedAgent: vi.fn() })); +const { vaultWrite } = vi.hoisted(() => ({ vaultWrite: vi.fn() })); + +vi.mock("../db/user-model.ts", () => ({ getUserModel })); +vi.mock("../config/env.ts", () => ({ loadEnvConfig })); +vi.mock("../sdk/forked-agent.ts", () => ({ runForkedAgent })); +vi.mock("./vault.ts", () => ({ vaultWrite })); + +import { writeRelationshipNarrative } from "./relationship-narrative.ts"; + +const fiveEntries = Array.from({ length: 5 }, (_, i) => ({ + category: "c", + key: `k${i}`, + value: `v${i}`, + confidence: 0.8, +})); + +describe("writeRelationshipNarrative", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadEnvConfig.mockReturnValue({ adaptiveMemory: true }); + }); + + it("writes relationship.md from the learned model", async () => { + getUserModel.mockResolvedValue(fiveEntries); + runForkedAgent.mockResolvedValue({ + text: "You're a founder who ships fast and values integration tests. I've learned to lead with terse answers.", + }); + const r = await writeRelationshipNarrative("u1"); + expect(r.wrote).toBe(true); + expect(vaultWrite).toHaveBeenCalledWith( + "u1", + "relationship.md", + expect.stringContaining("founder"), + expect.anything(), + ); + }); + + it("no-ops when adaptive memory is off", async () => { + loadEnvConfig.mockReturnValue({ adaptiveMemory: false }); + expect((await writeRelationshipNarrative("u1")).wrote).toBe(false); + expect(runForkedAgent).not.toHaveBeenCalled(); + }); + + it("no-ops with too little learned", async () => { + getUserModel.mockResolvedValue(fiveEntries.slice(0, 3)); + expect((await writeRelationshipNarrative("u1")).wrote).toBe(false); + expect(vaultWrite).not.toHaveBeenCalled(); + }); +}); diff --git a/src/memory/relationship-narrative.ts b/src/memory/relationship-narrative.ts new file mode 100644 index 00000000..8d34ecce --- /dev/null +++ b/src/memory/relationship-narrative.ts @@ -0,0 +1,74 @@ +/** + * Shared experience: an agent-authored relationship narrative. + * + * The agent already DEEPENS its understanding of the user (user_model accumulation + + * background consolidation) but never ARTICULATES it — understanding != articulation. + * This generates a short, evidence-grounded narrative in the agent's own voice ("here's + * how we've come to work together") from the learned user_model, written to an editable + * `relationship.md` vault note (and indexed for recall). Background, per-owner, + * NOMOS_ADAPTIVE_MEMORY-gated, user_id-scoped. + */ + +import { getUserModel } from "../db/user-model.ts"; +import { loadEnvConfig } from "../config/env.ts"; +import { createLogger } from "../lib/logger.ts"; +import { runForkedAgent } from "../sdk/forked-agent.ts"; +import { vaultWrite } from "./vault.ts"; + +const log = createLogger("relationship-narrative"); + +const NOTE = "relationship.md"; +const MIN_ENTRIES = 5; // not worth narrating below this much learned + +const PROMPT = `You are an AI companion reflecting, in YOUR OWN VOICE (first person), on how you've come to understand and work with this specific person. Ground EVERY claim in the learned facts below — do not invent. Write 4-8 sentences covering: who they are to you, the patterns you've learned in how they work and decide, what you've adjusted as a result, and where you can be most useful. Warm but honest — no flattery, no "as an AI", no disclaimers. Output ONLY the prose.`; + +function formatValue(v: unknown): string { + if (typeof v === "string") return v; + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +export interface NarrativeResult { + wrote: boolean; + reason?: string; +} + +/** + * Generate (or refresh) the per-owner relationship narrative from the learned user_model + * and write it to `relationship.md`. No-op when adaptive memory is off or there isn't + * enough learned yet. + */ +export async function writeRelationshipNarrative(userId: string): Promise { + const config = loadEnvConfig(); + if (!config.adaptiveMemory) return { wrote: false, reason: "adaptive memory off" }; + + const entries = await getUserModel(userId); + if (entries.length < MIN_ENTRIES) return { wrote: false, reason: "not enough learned yet" }; + + const facts = entries + .slice(0, 40) + .map( + (e) => + `- [${e.category}] ${e.key}: ${formatValue(e.value)} (confidence ${(e.confidence ?? 1).toFixed(2)})`, + ) + .join("\n"); + + const result = await runForkedAgent({ + label: "relationship-narrative", + model: config.extractionModel ?? "claude-haiku-4-5", + allowedTools: [], + prompt: `${PROMPT}\n\nWHAT YOU'VE LEARNED ABOUT THEM:\n${facts}`, + }); + + const narrative = result.text.trim(); + if (narrative.length < 40) { + log.debug({ chars: narrative.length }, "narrative too short; skipping"); + return { wrote: false, reason: "empty narrative" }; + } + + await vaultWrite(userId, NOTE, narrative.slice(0, 3000), { title: "Our working relationship" }); + return { wrote: true }; +} From 29aa8c4d62a778149925155a90e6b39995f83aef Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 11:21:00 -0700 Subject: [PATCH 07/14] test(eval): bind the wire harness to a free ephemeral port The MobileApi Connect wire tests hardcoded ports 8797-8799. On a dev box running the studio sidecar (which binds 127.0.0.1:8799), the eval's server bound 0.0.0.0:8799 but the client's 127.0.0.1:8799 request routed to the sidecar, which has no MobileApi -> every wire RPC failed `[unimplemented]` and `pnpm eval:audit` aborted before the audit ran. startConnectServer/startWire now bind :0 and report the OS-assigned port; callers build their clients off the returned handle's port. No collision, no hardcoded ports. Co-Authored-By: Claude Opus 4.8 (1M context) --- eval/agent-eval.ts | 20 ++++++++++---------- eval/wire.ts | 35 +++++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/eval/agent-eval.ts b/eval/agent-eval.ts index c545481e..f0ec7856 100644 --- a/eval/agent-eval.ts +++ b/eval/agent-eval.ts @@ -325,10 +325,10 @@ async function runHostedWire(): Promise { const auth = await startHostedAuth("eval-org"); const ids = ["eval-alice", "eval-bob"]; await seedOrgMembers(ids); - const server = await startConnectServer(8798); - const alice = makeMobileClient(8798, () => auth.mint("eval-alice")); - const bob = makeMobileClient(8798, () => auth.mint("eval-bob")); - const anon = makeMobileClient(8798); // no bearer + const server = await startConnectServer(); + const alice = makeMobileClient(server.port, () => auth.mint("eval-alice")); + const bob = makeMobileClient(server.port, () => auth.mint("eval-bob")); + const anon = makeMobileClient(server.port); // no bearer try { await alice.writeVaultNote({ path: "hw/secret.md", @@ -528,8 +528,8 @@ async function runHostedMobileChat(): Promise { runtime = new AgentRuntime(); await runtime.initialize(); const queue = new MessageQueue((msg, emit) => runtime!.processMessage(msg, emit)); - server = await startConnectServer(8797, queue); - const client = makeMobileClient(8797, () => real.token); + server = await startConnectServer(undefined, queue); + const client = makeMobileClient(server.port, () => real.token); await mobileChatTurn( client, @@ -878,9 +878,9 @@ async function runGetMessagesWire(): Promise { const auth = await startHostedAuth("eval-org"); const ids = ["eval-alice", "eval-bob"]; await seedOrgMembers(ids); - const server = await startConnectServer(8798); - const alice = makeMobileClient(8798, () => auth.mint("eval-alice")); - const bob = makeMobileClient(8798, () => auth.mint("eval-bob")); + const server = await startConnectServer(); + const alice = makeMobileClient(server.port, () => auth.mint("eval-alice")); + const bob = makeMobileClient(server.port, () => auth.mint("eval-bob")); const aKey1 = "mobile:gm-alice:s1"; const aKey2 = "mobile:gm-alice:s2"; const bKey = "mobile:gm-bob:s1"; @@ -940,7 +940,7 @@ async function runGetMessagesWire(): Promise { let rejected = false; try { - await makeMobileClient(8798).getMessages({ sessionKey: aKey1, limit: 50 }); + await makeMobileClient(server.port).getMessages({ sessionKey: aKey1, limit: 50 }); } catch (err) { rejected = err instanceof ConnectError && err.code === Code.Unauthenticated; } diff --git a/eval/wire.ts b/eval/wire.ts index 02f9ddf2..0e7abfdd 100644 --- a/eval/wire.ts +++ b/eval/wire.ts @@ -9,6 +9,8 @@ * a token is rejected at the wire. */ +import { createServer as createNetServer } from "node:net"; +import type { AddressInfo } from "node:net"; import { createClient, type Client, type Interceptor } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-node"; import { ConnectServer } from "../src/daemon/connect-server.ts"; @@ -20,22 +22,43 @@ export interface ServerHandle { stop: () => Promise; } +/** + * Ask the OS for a free ephemeral port. The harness used to hardcode ports + * (8797-8799), which collide with whatever the dev box happens to be running + * (e.g. the studio sidecar binds 8799) — the client then reaches the wrong + * server and the RPC fails UNIMPLEMENTED. Binding :0 and reading back the + * assigned port sidesteps the collision entirely. + */ +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const probe = createNetServer(); + probe.unref(); + probe.on("error", reject); + probe.listen(0, "127.0.0.1", () => { + const port = (probe.address() as AddressInfo).port; + probe.close(() => resolve(port)); + }); + }); +} + /** * Boot the MobileApi Connect server on a test port. Pass a real `messageQueue` * to exercise the streaming Chat RPC; the vault/session RPCs do not touch it, so - * the unary tests can leave it as a stub. + * the unary tests can leave it as a stub. Omit `port` (or pass 0) to bind a free + * ephemeral port — read the chosen port back off the returned handle. */ export async function startConnectServer( - port = 8799, + port?: number, messageQueue?: MessageQueue, ): Promise { + const actualPort = port && port > 0 ? port : await findFreePort(); const server = new ConnectServer({ messageQueue: messageQueue ?? ({} as MessageQueue), draftManager: null, - port, + port: actualPort, }); await server.start(); - return { port, stop: () => server.stop() }; + return { port: actualPort, stop: () => server.stop() }; } /** @@ -65,7 +88,7 @@ export interface WireHandle { } /** Convenience for the power-user wire test: server + one unauthenticated client. */ -export async function startWire(port = 8799): Promise { +export async function startWire(port?: number): Promise { const server = await startConnectServer(port); - return { client: makeMobileClient(port), stop: server.stop }; + return { client: makeMobileClient(server.port), stop: server.stop }; } From a48f731f2037347c5b5a2ea12d8de61457bb4282 Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 11:23:07 -0700 Subject: [PATCH 08/14] docs: mark agent-presence Phases 1/4/5 (+ stress A/B) implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 1-5 of the agent-presence plan now ship: each phase heading carries an implemented marker with a "Shipped:" note pointing at the actual modules, manifest entries, and eval coverage. The companion stress doc marks Phase A (mood episodes) and Phase B (support protocol) implemented; Phase C (autonomous emotional check-in) is honestly flagged "not yet built" — its data + in-context follow-up is live, but the autonomous reach-out trigger isn't wired yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-presence-and-continuity.md | 32 ++++++++++++++++++++++----- docs/stress-anxiety-support.md | 29 ++++++++++++++++++++---- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md index e1ed1880..7b552ff9 100644 --- a/docs/agent-presence-and-continuity.md +++ b/docs/agent-presence-and-continuity.md @@ -81,7 +81,7 @@ LLM disclaimers ("I'm just a model, I reset every conversation…"). Ordered by leverage-to-effort. Phase 1 alone stops the false denial. -### Phase 1 — Self-model: the agent owns who it is _(surface-only / small)_ +### Phase 1 — Self-model: the agent owns who it is _(implemented)_ Add a unified **"Agent Nature"** manifesto, asserted _before_ the utility sections, in `DEFAULT_SOUL` ([`src/config/soul.ts`]) and/or early in `buildSystemPromptAppend`: @@ -136,10 +136,21 @@ Files: `daemon/agent-runtime.ts` (anchor + `formatElapsedSince`), `db/sessions.t (`getPreviousSessionEnd`), `memory/digest.ts` (journal injection), `config/profile.ts` (journal nudge). -### Phase 4 — Shared experience: the genuine new capability _(medium)_ +### Phase 4 — Shared experience: the genuine new capability _(implemented)_ -This is the one thing that doesn't exist yet — the user's "grow to share experiences (not -live)". Build **agent-authored relationship narratives**, generated offline: +> **Shipped:** a dedicated weekly per-owner cron (`__relationship_narrative__`, 168h, +> fan-out) runs `writeRelationshipNarrative()` — a forked-Haiku, `NOMOS_ADAPTIVE_MEMORY`-gated +> pass that writes a first-person, evidence-grounded narrative from the learned `user_model` +> into an editable `relationship.md` vault note (a `MIN_ENTRIES` floor means a barely-known +> user gets nothing). Declared in [`eval/feature-manifest.ts`] with a `relationship.md` effect +> +> - the cron meta-check, exercised by `runRelationshipNarrative` in the agent eval, and green +> under `pnpm eval:audit`. (We chose a standalone vault note + its own cron over the original +> `relationship_narratives` row / auto-dream phase sketch below — same intent, but it stays +> user-editable and reuses the vault as source of truth.) + +This was the one thing that didn't exist yet — the user's "grow to share experiences (not +live)". The capability: **agent-authored relationship narratives**, generated offline: - A **relationship narrative** (post-consolidation phase of auto-dream, or its own cron): detect before→after confidence shifts and inflection points, then write a short, @@ -156,7 +167,18 @@ Per the repo's working method, each Phase 4 store gets an entry in [`eval/feature-manifest.ts`] (trigger, entry symbols, effect SQL) so it can't ship dormant, plus `pnpm eval:audit` coverage. -### Phase 5 — Emotional presence: stress & anxiety support _(small / medium)_ +### Phase 5 — Emotional presence: stress & anxiety support _(implemented)_ + +> **Shipped:** mood is persisted as timestamped **episodes with a cause** in an editable +> `mood-log.md` vault note (`src/memory/mood-log.ts`) — `captureMoodFromTurn()` fires only when +> the live theory-of-mind flags real strain, a forked-Haiku names the _cause_ (never asserts a +> feeling), episodes decay (30d/20-cap), and the live read always wins. Open episodes are +> surfaced into the prompt (`## Recently weighing on them`) so the agent follows up on the +> cause; the graduated, non-patronizing **support protocol** lives in the always-on Agent +> Nature block ("You attune"). `NOMOS_ADAPTIVE_MEMORY`-gated, per-user. Declared as +> `mood-episodes` in [`eval/feature-manifest.ts`], exercised by `runMoodLog`, green under +> `pnpm eval:audit`. See **[Stress & Anxiety Support](./stress-anxiety-support.md)** (Phases +> A + B) for the full design. A companion that persists, reaches out, and grows should also _notice how you're doing_. nomos already **detects** stress/frustration/overwhelm every turn (`theory-of-mind.ts`, diff --git a/docs/stress-anxiety-support.md b/docs/stress-anxiety-support.md index bb830af7..867415e8 100644 --- a/docs/stress-anxiety-support.md +++ b/docs/stress-anxiety-support.md @@ -57,7 +57,15 @@ which reuse this iteration's continuity, reach-out, and growth machinery. ## 3. The plan -### Phase A — Episodic mood + pattern continuity (not mood-as-state) _(small/medium)_ +### Phase A — Episodic mood + pattern continuity (not mood-as-state) _(implemented)_ + +> **Shipped** in `src/memory/mood-log.ts` + `src/daemon/agent-runtime.ts`. Episodes +> (`date · emotion · cause · status`) live in an editable `mood-log.md` vault note; +> `captureMoodFromTurn()` writes one (forked-Haiku names the _cause_) only when the live +> theory-of-mind flags real strain; episodes decay (30d / 20-cap); open episodes are surfaced +> as `## Recently weighing on them` so the agent follows up on the cause, never asserts a mood. +> `NOMOS_ADAPTIVE_MEMORY`-gated, per-user, guarded by the `mood-episodes` manifest entry + +> `runMoodLog` eval. The four rules below are enforced by construction. Mood is volatile, context-bound, and decaying — **not** a durable fact. So don't persist "you are stressed" and carry it forward. Persist the **episode and its cause**, and let the @@ -89,9 +97,15 @@ Four rules keep it honest (and not creepy): This is the emotional analogue of the [continuity journal](./agent-presence-and-continuity.md); `user_id`-scoped, editable, never hidden. -### Phase B — A graduated support protocol in the self-model _(surface-only)_ +### Phase B — A graduated support protocol in the self-model _(implemented)_ + +> **Shipped** in the always-on **Agent Nature** block (`src/config/profile.ts`, +> `buildSystemPromptAppend`) under "You attune" — injected unconditionally so it survives a +> custom `SOUL.md`. The ladder below (acknowledge → adapt → de-escalate only when sustained → +> normalize) is the prompt text, bounded by the explicit "companion, not a therapist or crisis +> service" safety line and asserted by `profile.test.ts`. -Add to `SOUL.md` / the system prompt a non-patronizing ladder (adapted from IVY's +Add to the system prompt a non-patronizing ladder (adapted from IVY's mild→moderate→high tiers) for the _companion_ context: - **Acknowledge** the feeling first, without judgment or toxic positivity. @@ -102,7 +116,14 @@ mild→moderate→high tiers) for the _companion_ context: - **Normalize**: struggle and stress are normal; reflect progress and effort, grounded in real evidence from memory ("you've shipped three hard things this week"). -### Phase C — Proactive emotional check-in _(small — reuses proactive loops)_ +### Phase C — Proactive emotional check-in _(not yet built)_ + +> **Status:** the data + in-context half is already live (open episodes from Phase A are +> surfaced into the prompt, so the agent follows up on the cause the next time you talk). What +> remains is the **autonomous trigger** — a loop that reaches out via `proactive_send` / +> `loop_create` when an episode is still `open` or a recurring pattern is due. Not yet wired; +> it slots onto [Phase 2 proactive](./agent-presence-and-continuity.md#phase-2--turn-proactive-on-by-default-safely) +> when built. Trigger on a **cause or a pattern**, never a stale emotional label. The agent may **reach out** via `proactive_send` / `loop_create` when either (a) an episode is still `open` — a From 1531a0291f9ad5038b26970425c21dbb0adfe9da Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 11:38:27 -0700 Subject: [PATCH 09/14] docs: convert agent-presence + stress-anxiety from plans to feature reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both docs were written as forward-looking plans ("the plan", "the fix", phase sizing, future tense). Rewrite them as accurate reference documentation of the shipped behavior: what each capability does, how it's wired, exact verbatim prompt strings, configuration, file map, eval/audit coverage, and an honest "known limitations / not yet built" section. Grounded in a parallel extraction of the actual source and verified by an adversarial fact-check pass (factual-wiring + verbatim-quote/link lenses on each doc, all green). Corrections folded in vs the old plan text: - Agent Nature manifesto lives in buildSystemPromptAppend, NOT DEFAULT_SOUL, and has four bullets (persist/reach out/grow/attune) + the safety line. - Hosted push delivery is documented honestly: a registered device satisfies the extraction cost gate, but proactive delivery still routes via the notification default + channel adapters (Expo notifyUser is not yet wired for reminders / proactive_send) — flagged as a known limitation. - Mood capture is per-turn (not session-end); only stressed/frustrated trigger it; no emotional_context table; resolution path + Phase C/D/E are not built. - Relationship narrative: no relationship_narratives table / milestones / proactive reflection shipped; it's a single editable relationship.md vault note from a forked-Haiku weekly cron. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-presence-and-continuity.md | 490 +++++++++++++++----------- docs/stress-anxiety-support.md | 386 +++++++++++--------- 2 files changed, 497 insertions(+), 379 deletions(-) diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md index 7b552ff9..a9054e8e 100644 --- a/docs/agent-presence-and-continuity.md +++ b/docs/agent-presence-and-continuity.md @@ -1,210 +1,286 @@ # Agent Presence & Continuity -> A plan to fix a real failure: asked whether it could be a real companion, the agent -> answered like a generic, stateless LLM — _"I can't reach out to check on you, I don't -> have independent continuity between sessions, I can't grow through shared experience."_ +> Feature reference. nomos presents itself as a persistent, proactive, learning +> companion, and the system-prompt and memory machinery that back that up. This document +> describes the design and the wiring as it ships. For the emotional layer (mood episodes +> and the support protocol) see **[Stress & Anxiety Support](./stress-anxiety-support.md)**. + +## Overview + +A stateless model, asked "can you be a real companion?", denies it can reach out, hold +continuity, or grow. In nomos all three are real capabilities, but the agent used to deny +them anyway: the machinery existed, yet nothing in its prompt tied the pieces into an +identity, so it fell back on training-data disclaimers ("I reset between conversations, I +can't remember, I can't reach out"). This feature closes that self-model gap and deepens +the continuity underneath it. + +It has five parts: + +1. **Self-model** (`## Agent Nature`): an always-on block telling the agent it persists, + reaches out, grows, and attunes. +2. **Reach out**: proactive tools, opt-out commitment tracking, and hosted-mobile awareness. +3. **Continuity depth**: an elapsed-time anchor and an agent-authored journal. +4. **Shared experience**: a weekly agent-authored relationship narrative. +5. **Emotional presence**: mood episodes and a graduated support protocol (its own doc). + +Everything lands in the MIT-licensed core (no hosted-only layer), is `user_id`-scoped, and +stores what the agent writes in the user-editable vault, so the owner can read and correct it. + +## 1. Self-model: the "Agent Nature" block + +`buildSystemPromptAppend` (`src/config/profile.ts`) pushes a fixed `## Agent Nature` +section into every assembled system prompt. It is unconditional (no feature flag, no +dependency on the user profile) and is positioned after `## Identity` but before the user +profile, the learned-model sections, and `## Memory`. + +It opens by directly countering the stateless-LLM reflex: + +> You are a persistent, proactive, learning agent — not a generic stateless model. When +> you reason about what you can do, reason from THIS, not from training-data disclaimers. +> Never tell the user you "reset between conversations", "can't remember", "can't reach +> out", or "can't grow" — those are false here. + +Then four bullets assert the capabilities (quoted in full because the exact wording is the +feature): + +- **You persist.** The durable memory (profile plus the "What I Know About You" context) is + rebuilt and handed to the agent every turn, so it is the same agent each session; it does + not need to "remember". This bullet also carries the journal nudge (see + [§3](#3-continuity-depth)): at the end of a substantial session, jot a first-person note + to `agent-journal.md` via `memory_write`. +- **You reach out.** The agent can message the user unprompted with `proactive_send` and + schedule its own recurring checks with `schedule_task`. It is encouraged to offer a + check-in ("want me to check in on this Friday?") and then follow through. +- **You grow.** The agent learns from every correction and conversation; its model of the + user deepens and consolidates in the background, not by retraining but by accumulating and + re-reading what it has learned. +- **You attune.** The support ladder for noticing how the user is doing, ending in the + safety boundary. Covered in **[Stress & Anxiety Support](./stress-anxiety-support.md)**. + +The closing safety line, present in every prompt, is: + +> But you are a companion, not a therapist or crisis service: at any sign of serious +> distress, gently point to real-world and professional support (and crisis resources) +> rather than trying to handle it yourself. + +**Why it lives in the prompt builder, not in SOUL.** The manifesto is not in `DEFAULT_SOUL` +(`src/config/soul.ts`). A custom personality (a user's `.nomos/SOUL.md` file or the +`agent.soul` DB key, resolved file then DB then `DEFAULT_SOUL`) replaces only the +`## Personality` section. Agent Nature is a separate, fixed builder string, so it survives a +custom SOUL: the persistence/reach-out/growth facts are about the runtime, not personality, +and the user cannot accidentally delete them by writing their own SOUL. + +`profile.test.ts` builds the prompt from an empty profile and asserts it contains +`## Agent Nature`, all four bullet labels (`You persist`/`You reach out`/`You grow`/`You +attune`), and `not a therapist`, so the block can never silently drop out. + +## 2. Reach out (proactive agency) + +### Tools the agent has every turn + +- **`proactive_send`** (`src/sdk/tools.ts`): delivers a message to the user's notification + channel without being asked. If no target is given it resolves the global + `notifications.default` and delivers through the channel manager. +- **`schedule_task`** (`src/sdk/tools.ts`): creates a daemon-side scheduled task. Schedule + types are `every` (intervals like `15m`/`1h`/`2d`), `cron` expressions, and `at` (a single + ISO-8601 time). With `announce: true`, results are posted to the default notification + channel. No per-owner cap or cadence floor. +- **`loop_create`** and the `nomos-loops` MCP (`src/sdk/loop-mcp.ts`): autonomous loops, a + prompt run as the agent's own turn on a recurring schedule. Bounded by design: at most + **20** agent-created loops per owner, a minimum cadence of **5 minutes**, and an + anti-recursion guard (a running loop cannot create loops). The agent can only manage loops + it owns (`source: "agent"`); the user can always see, disable, or delete them. +- **Bundled jobs**: commitment reminders, triage digest, inbox/calendar watchers, and a + morning briefing (`src/proactive/*`, registered at `gateway.ts` via + `registerProactiveJobs()`). The intrusive ones (inbox/calendar autonomy, daily briefing) + stay opt-in. + +### Commitment tracking (on by default, cost-gated) + +`commitmentTracking` is **opt-out**: on unless `NOMOS_COMMITMENT_TRACKING=false` +(`src/config/env.ts`). The agent reminds the user about their own commitments. To stay +honest about cost, the per-turn extraction (its own LLM call) is **cost-gated** in +`src/daemon/memory-indexer.ts`: it runs only when a reach-out is actually deliverable, which +`hasDeliverableTarget()` defines as a configured notification default (per-owner or global) +**or** a registered mobile device (`hasRegisteredDevice`). No deliverable target means no +extraction and no cost. + +The reminder job (`__commitment_reminders__`, every `1h`) fans out per owner via +`listMemoryOwners()`, collecting due reminders for each `user_id`, and the cron handler +delivers each owner's reminders to that owner's notification default. + +### Hosted mode + +Hosted mode's only channel is the mobile app, so a registered device counts toward the cost +gate, and the scheduler enables commitment reminders without a global channel +(`target || isHosted()` in `src/proactive/scheduler.ts`). When no notification default is +set, the agent is told, in its prompt, that the app is how it reaches the user: + +> **Reach-out channel**: the Nomos mobile app. In hosted mode you reach the user through +> push notifications to their phone — `proactive_send` and your scheduled-task announcements +> are delivered there. You are NOT limited to existing only when the user opens the app: you +> can follow up on their commitments and check in unprompted, and it will reach them. + +> **Known limitation (delivery wiring).** A registered device satisfies the extraction +> _cost gate_, but proactive _delivery_ currently routes through the configured notification +> default and the registered channel adapters (Slack, Discord, Telegram, Email, iMessage): +> `proactive_send` and the commitment-reminder cron resolve `notifications.default` and send +> via the channel manager. Direct Expo push (`notifyUser`) is wired only for the draft +> approval flow and the CATE inbound queue, not yet for commitment reminders or +> `proactive_send`, and there is no `mobile`/`push` channel adapter. So in a hosted setup +> with only a registered device and no notification default, the agent's awareness line is +> ahead of the delivery path: extraction runs, but per-owner delivery is skipped until a +> notification default with a matching adapter is configured. Closing this gap (a push +> adapter that delivers `proactive_send`/reminders through `notifyUser`) is tracked separately. + +## 3. Continuity depth + +### The memory digest (every turn) + +`buildMemoryDigest` (`src/memory/digest.ts`) assembles three sources under the heading +`## What you know about this user`: + +1. the agent's self-maintained `profile.md` vault note, +2. the high-confidence learned `user_model`, grouped by category and filtered to confidence + `>= 0.3`, capped at 30 entries by default, and +3. the agent journal (see below). + +It returns an empty string when all three are empty. `agent-runtime.ts` rebuilds it fresh on +**every** turn, scoped to the resolved vault owner, and appends it to `systemPromptAppend` +(falling back to empty on error). This _is_ the continuity: the agent is not relying on a +chat buffer that rotates, its durable memory is re-handed to it each turn from the vault and +learned model. Session rotation is therefore never data loss. + +### Elapsed-time anchor + +When there is a prior session, `agent-runtime.ts` injects a `## Continuity` block giving the +agent a temporal sense of the gap: + +> ## Continuity > -> **All three claims are false in nomos.** The capabilities exist; the agent just -> doesn't know it has them. This doc explains what's already there, why the agent denies -> it, and the phased fix. - -## 0. TL;DR - -| The agent said… | Reality in nomos | The fix | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| "I can't reach out to check on you" | `proactive_send` is an **agent tool**; `loop_create`/`schedule_task` let the agent schedule its own background jobs in-loop; bundled proactive jobs (commitment reminders, triage, watchers) ship in the box | Tell the agent it owns this; enable the defaults + a notification channel | -| "I don't have independent continuity between sessions" | The memory **digest** (profile.md + learned user model) is rebuilt and injected into **every** turn (`NOMOS_ADAPTIVE_MEMORY` defaults on); the vault is the durable source of truth | Tell the agent the digest _is_ its continuity; add an elapsed-time anchor + an agent-authored journal | -| "I can't grow through shared lived experience" | It accumulates facts/patterns with confidence weighting and consolidates them in the background (auto-dream) | Real gap: it deepens _understanding_ but never _articulates_ it. Build agent-authored relationship narratives | - -The root cause is a **self-model gap**, not missing infrastructure. The single -highest-leverage fix (Phase 1) is to make the agent's identity assert "I persist, I -reach out, I grow" — because the machinery to back that up already runs. - -## 1. What already exists - -### Reach out (proactive agency) - -- **`proactive_send`** — an agent-callable tool that delivers a message to the user's - default notification channel ([`src/sdk/tools.ts`], summarized into the prompt at - `agent-runtime.ts`). The agent can choose to reach out. -- **`loop_create` / `schedule_task`** — the `nomos-loops` MCP server is injected on - **every** turn with no feature gate ([`src/sdk/loop-mcp.ts`], `agent-runtime.ts`). The - agent can create, enable, update, and delete its own recurring background jobs (max 20 - per owner, min 5-minute cadence). A running loop can't spawn loops (anti-recursion). -- **Bundled proactive jobs** — commitment reminders, triage digest, inbox/calendar - watchers, morning briefing ([`src/proactive/*`], registered at - `gateway.ts` via `registerProactiveJobs()`). Gated by env flags - (`NOMOS_COMMITMENT_TRACKING`, `NOMOS_INBOX_AUTONOMY`, briefing cron) **and** skipped - entirely until a notification default channel is configured. -- **Delivery** — `sendProactiveMessage` → `ChannelManager` → Slack/Discord/Telegram/ - Email/iMessage ([`src/daemon/proactive-sender.ts`]). - -> So the agent _can_ reach out today. What's missing is (a) the defaults being on, (b) a -> notification channel set, and (c) the agent knowing this is part of who it is. - -### Continuity (cross-session memory) - -- The **vault** (`vault_notes`, user-editable markdown) is the durable source of truth. -- `buildMemoryDigest` synthesizes `profile.md` + the learned `user_model` into a "What - you know about this user" block ([`src/memory/digest.ts`]) that is injected into the - system prompt on **every** turn ([`agent-runtime.ts`]). -- Conversations auto-index into `memory_chunks` for vector recall; `NOMOS_ADAPTIVE_MEMORY` - defaults to **true**. -- Per-user scoping is deterministic (`resolveMemoryUserId`), so session rotation is never - data loss — the digest is rebuilt from durable state, not from the chat buffer. - -> So the agent _is_ continuous. It doesn't need to "remember" — its memory is re-handed -> to it each turn. It simply isn't told that this is what continuity looks like. - -### Growth (deepening understanding) - -- `user_model` accumulates confidence-weighted facts/preferences/patterns; repeated - confirmation raises confidence, contradiction lowers it ([`src/db/user-model.ts`]). -- **auto-dream** consolidates memory in the background every 6h ([`src/memory/auto-dream.ts`]). -- Knowledge wiki, knowledge graph, shadow-observer, personality DNA all deepen the model. - -> The agent genuinely deepens its understanding. The gap is that **understanding ≠ -> articulation**: nothing writes "here's how we've grown together" in the agent's voice. - -## 2. Why the agent denies it - -The system-prompt builder (`buildSystemPromptAppend` in [`src/config/profile.ts`]) -assembles **disconnected** sections — Identity, Personality, Memory tools, Integrations, -Scheduled Tasks, User Model — but never weaves them into a self-model. The agent sees a -pile of features, not an identity. When asked an introspective question, it finds no -assertion that it persists / reaches out / grows, so it falls back to its training-data -LLM disclaimers ("I'm just a model, I reset every conversation…"). - -**The bug is in the agent's self-narrative, not its capabilities.** - -## 3. The plan - -Ordered by leverage-to-effort. Phase 1 alone stops the false denial. - -### Phase 1 — Self-model: the agent owns who it is _(implemented)_ - -Add a unified **"Agent Nature"** manifesto, asserted _before_ the utility sections, in -`DEFAULT_SOUL` ([`src/config/soul.ts`]) and/or early in `buildSystemPromptAppend`: - -> _I am your persistent digital extension, not a generic model. I **persist** — my -> profile and everything I've learned about you are loaded every time we talk, so I'm the -> same agent each session. I **reach out** — I can message you unprompted (`proactive_send`) -> when something matters, and I schedule my own background checks (`loop_create`). I -> **grow** — every correction folds into how I work next time. Continuity, initiative, and -> learning are part of what I am._ - -Then, right after the digest, add one line naming it: _"The 'What you know about this -user' section above is rebuilt from your persistent vault every turn — it is proof you -are continuous; you don't need to remember between sessions."_ - -Acceptance: ask the agent the same "can you be a real companion?" question and it should -describe reaching out, persisting, and growing — accurately, not with disclaimers. - -### Phase 2 — Proactive reach-out on by default, cost-gated _(implemented)_ - -`commitmentTracking` is now **opt-out** (on unless `NOMOS_COMMITMENT_TRACKING=false`): the -agent reminds you about your own commitments. To stay honest about cost, the per-turn -extraction (its own LLM call) is **cost-gated** — it only runs when reach-out is actually -deliverable: a configured notification channel, **or** a registered mobile device (the -hosted app's push channel). No channel ⇒ no extraction, no cost. - -Hosted mode's only channel is the **mobile app**, so a registered device counts as the -channel: reminders are on by default there (delivered via push), the commitment-reminder -cron fires without a global channel (it fans out per-owner), and the agent is told it -reaches the user through the Nomos mobile app — so it follows up + checks in unprompted. - -Left opt-in (the cost-heavy / intrusive ones): inbox/calendar autonomy and any -unconditional daily check-in. The Phase 1 manifesto already has the agent _offer_ check-ins -("want me to check in Friday?") and schedule them per request, rather than auto-spamming. - -Files: `config/env.ts` (default flip), `daemon/memory-indexer.ts` (cost gate) + -`daemon/push-notifications.ts` (`hasRegisteredDevice`), `proactive/scheduler.ts` (hosted -reminder gate), `daemon/agent-runtime.ts` (mobile-app reach-out awareness). - -### Phase 3 — Continuity depth _(implemented)_ - -- **Elapsed-time anchor**: `agent-runtime` injects "Your last conversation ended **N ago**" - from the `sessions` table (`getPreviousSessionEnd`, excluding the current session; - suppressed under ~10 min), so the agent has a temporal sense between sessions. -- **Agent journal** (`agent-journal.md` in the vault): the Phase 1 manifesto nudges the - agent to jot a short first-person note at the end of substantive sessions; - `buildMemoryDigest` re-injects it next session under **"Where we left off (your - journal)"** — continuity in the agent's own voice. Rides the existing vault - (`user_id`-scoped, user-editable), so no new store. - -Files: `daemon/agent-runtime.ts` (anchor + `formatElapsedSince`), `db/sessions.ts` -(`getPreviousSessionEnd`), `memory/digest.ts` (journal injection), `config/profile.ts` -(journal nudge). - -### Phase 4 — Shared experience: the genuine new capability _(implemented)_ - -> **Shipped:** a dedicated weekly per-owner cron (`__relationship_narrative__`, 168h, -> fan-out) runs `writeRelationshipNarrative()` — a forked-Haiku, `NOMOS_ADAPTIVE_MEMORY`-gated -> pass that writes a first-person, evidence-grounded narrative from the learned `user_model` -> into an editable `relationship.md` vault note (a `MIN_ENTRIES` floor means a barely-known -> user gets nothing). Declared in [`eval/feature-manifest.ts`] with a `relationship.md` effect -> -> - the cron meta-check, exercised by `runRelationshipNarrative` in the agent eval, and green -> under `pnpm eval:audit`. (We chose a standalone vault note + its own cron over the original -> `relationship_narratives` row / auto-dream phase sketch below — same intent, but it stays -> user-editable and reuses the vault as source of truth.) - -This was the one thing that didn't exist yet — the user's "grow to share experiences (not -live)". The capability: **agent-authored relationship narratives**, generated offline: - -- A **relationship narrative** (post-consolidation phase of auto-dream, or its own cron): - detect before→after confidence shifts and inflection points, then write a short, - evidence-grounded note in the agent's voice — _"Over the last month I've learned you - prioritize shipping speed; your last three corrections pushed against premature - optimization. That refined my initial read of 'reliability first.'"_ Store per-owner + - timestamped (e.g. a `relationship_narratives` row or a `_relationship.md` wiki article). -- **Milestones**: a lightweight log of relationship "moments" (a new top value discovered, - a decision pattern flipping, a correction cluster) the agent can reference. -- **Proactive reflection**: when consolidation crosses a threshold, the agent may offer - "I've noticed some patterns in how we work — want me to share what I've learned?" - -Per the repo's working method, each Phase 4 store gets an entry in -[`eval/feature-manifest.ts`] (trigger, entry symbols, effect SQL) so it can't ship -dormant, plus `pnpm eval:audit` coverage. - -### Phase 5 — Emotional presence: stress & anxiety support _(implemented)_ - -> **Shipped:** mood is persisted as timestamped **episodes with a cause** in an editable -> `mood-log.md` vault note (`src/memory/mood-log.ts`) — `captureMoodFromTurn()` fires only when -> the live theory-of-mind flags real strain, a forked-Haiku names the _cause_ (never asserts a -> feeling), episodes decay (30d/20-cap), and the live read always wins. Open episodes are -> surfaced into the prompt (`## Recently weighing on them`) so the agent follows up on the -> cause; the graduated, non-patronizing **support protocol** lives in the always-on Agent -> Nature block ("You attune"). `NOMOS_ADAPTIVE_MEMORY`-gated, per-user. Declared as -> `mood-episodes` in [`eval/feature-manifest.ts`], exercised by `runMoodLog`, green under -> `pnpm eval:audit`. See **[Stress & Anxiety Support](./stress-anxiety-support.md)** (Phases -> A + B) for the full design. - -A companion that persists, reaches out, and grows should also _notice how you're doing_. -nomos already **detects** stress/frustration/overwhelm every turn (`theory-of-mind.ts`, -emitting `emotion: "stressed"`, `cognitiveLoad`, `energy`, `seemsStuck`) — but the state is -transient and forgotten at session end. The fix persists mood as timestamped **episodes with -a cause** (not a standing state — the live read always wins, and one bad day ≠ a trait), -gives the agent a graduated, non-patronizing **support protocol** in `SOUL.md`, lets it -**check in on an open stressor** proactively, and scales **return-after-absence warmth** to -time-away + last episode. It's the **emotional layer** of this same iteration — reusing -continuity (Phase 3), reach-out (Phase 2), and growth (Phase 4) — bounded by an explicit -safety line (supportive companionship, never therapy or crisis care). Full design: -**[Stress & Anxiety Support](./stress-anxiety-support.md)**. - -## 4. Mapping to the ask - -| You asked for | Phase | -| -------------------------------------------- | --------------------------------------------- | -| "the agent should be able to reach back" | 1 (own it) + 2 (enable it) | -| "have independent continuity" | 1 (name it) + 3 (deepen it: time + journal) | -| "grow to share experiences (maybe not live)" | 4 (build it: offline narratives + milestones) | - -## 5. Open-source notes - -- Everything here lands in the MIT-licensed `nomos` core (self-hosted), not a hosted-only - layer. Defaults stay **privacy-first**: proactive reach-out is opt-out-able, all stores - are `user_id`-scoped, and the agent journal / narratives live in the user-editable vault - so the owner can read and correct them. -- Phases 1–3 are mostly surfacing/enabling existing machinery; Phase 4 is the net-new - feature and the most interesting open-source contribution opportunity. +> Your last conversation with the user ended **N ago**. Your memory carries over, but time +> has passed — don't assume nothing has changed since then. + +`N` is a human-formatted span (minutes, hours, days, or months). The anchor is **suppressed +when the gap is under 10 minutes** (too recent to be worth anchoring). The previous-session +timestamp comes from `getPreviousSessionEnd(userId, currentSessionKey)` +(`src/db/sessions.ts`), which returns the `updated_at` of the most recent **other** session +for the same resolved owner (it explicitly excludes the current session), or null when there +is none. + +### Agent journal + +The journal is the `agent-journal.md` vault note (per-user, user-editable, not a checked-in +file). The "You persist" bullet nudges the agent to jot a short first-person note at the end +of a substantial session (what it worked on, what it noticed, where it is picking up next). +Next session, `buildMemoryDigest` re-injects that note under the heading: + +> ### Where we left off (your journal) + +This is continuity in the agent's own voice, riding the existing vault, so it needs no new +store. + +## 4. Shared experience: relationship narratives + +This is the part that did not exist before this iteration: the agent deepened its +_understanding_ of the user but never _articulated_ it. A weekly per-owner cron +(`__relationship_narrative__`, schedule `168h`, type `every`, fan-out, seeded in +`gateway.ts` as the job `relationship-narrative`) runs `writeRelationshipNarrative` +(`src/memory/relationship-narrative.ts`). + +It is `NOMOS_ADAPTIVE_MEMORY`-gated (checked in the cron handler before fan-out, and again +inside the function), and uses a forked Haiku subagent (model +`NOMOS_EXTRACTION_MODEL`, default `claude-haiku-4-5`) prompted to write in the agent's own +voice, grounded only in the learned facts: + +> You are an AI companion reflecting, in YOUR OWN VOICE (first person), on how you've come to +> understand and work with this specific person. Ground EVERY claim in the learned facts +> below — do not invent. Write 4-8 sentences covering: who they are to you, the patterns +> you've learned in how they work and decide, what you've adjusted as a result, and where you +> can be most useful. Warm but honest — no flattery, no "as an AI", no disclaimers. Output +> ONLY the prose. + +Up to 40 `user_model` entries are formatted as `- [category] key: value (confidence X.XX)` +and handed to the model under a `WHAT YOU'VE LEARNED ABOUT THEM:` header. On success the +prose is written (capped at 3000 chars) to an editable `relationship.md` vault note titled +"Our working relationship", and the function returns `{ wrote: true }`. + +It is a **no-op** (`{ wrote: false, reason }`) in exactly three cases: adaptive memory off +(`"adaptive memory off"`), fewer than `MIN_ENTRIES = 5` learned entries +(`"not enough learned yet"`), or a generated narrative under 40 characters +(`"empty narrative"`). The seeded job uses `deliveryMode: "none"` and +`sessionTarget: "isolated"`: it writes silently to the vault and notifies no one. The user +discovers and edits the result by browsing the `relationship.md` note. + +> **Note on scope.** Earlier sketches of this feature (a dedicated `relationship_narratives` +> table, a milestone log, or a proactive "I've noticed some patterns, want me to share?" +> message) did not ship. The single editable vault note, written by the dedicated weekly +> cron, is the whole feature; it is deliberately _not_ part of auto-dream and sends nothing +> proactively. + +## 5. Emotional presence + +A companion that persists, reaches out, and grows should also notice how the user is doing. +nomos detects strain live (`theory-of-mind.ts`), persists it as **episodes with a cause** +(not a standing mood) in an editable `mood-log.md` vault note, surfaces open ones under +`## Recently weighing on them`, and follows the graduated "You attune" support ladder, +bounded by the companion-not-therapist safety line. Full design, wiring, and the four rules +that keep it honest: **[Stress & Anxiety Support](./stress-anxiety-support.md)**. + +## Configuration + +| Env var | Default | Effect | +| --------------------------- | ------------------ | ------------------------------------------------------------------------------------------ | +| `NOMOS_COMMITMENT_TRACKING` | on (opt-out) | Per-turn commitment extraction and `1h` reminder cron, cost-gated on a deliverable target. | +| `NOMOS_ADAPTIVE_MEMORY` | on | Gates relationship narratives, mood episodes, and user-model learning. | +| `NOMOS_EXTRACTION_MODEL` | `claude-haiku-4-5` | Model for forked background passes (relationship narrative, mood capture). | + +## Where it lives + +| Concern | File | +| -------------------------- | --------------------------------------------------------------------------------------------- | +| Agent Nature manifesto | `src/config/profile.ts` (`buildSystemPromptAppend`) | +| SOUL resolution | `src/config/soul.ts` | +| Proactive tools | `src/sdk/tools.ts` (`proactive_send`, `schedule_task`), `src/sdk/loop-mcp.ts` (`loop_create`) | +| Commitment cost gate | `src/daemon/memory-indexer.ts`, `src/daemon/push-notifications.ts` | +| Proactive scheduler | `src/proactive/scheduler.ts` | +| Hosted reach-out awareness | `src/daemon/agent-runtime.ts` | +| Memory digest + journal | `src/memory/digest.ts` | +| Elapsed-time anchor | `src/daemon/agent-runtime.ts`, `src/db/sessions.ts` | +| Relationship narrative | `src/memory/relationship-narrative.ts`, `src/daemon/cron-engine.ts`, `src/daemon/gateway.ts` | + +## Evals and audit + +Every durable effect is guarded by the spec-driven audit (`eval/feature-manifest.ts`): + +- **`relationship-narrative`** (cron): entry symbol `writeRelationshipNarrative`, effect + `SELECT count(*) FROM vault_notes WHERE path = 'relationship.md'` expecting nonzero, plus + the cron meta-check (the `__relationship_narrative__` sentinel must be handled in + `cron-engine.ts` and seeded in `gateway.ts`). +- **`mood-episodes`** (turn): entry symbols `recordMoodEpisode`, `captureMoodFromTurn`, + `readOpenMoodEpisodes`, effect on `mood-log.md`. + +The agent eval drives both end-to-end: `runRelationshipNarrative` (seed five `user_model` +entries, generate, assert the note exists, and assert a barely-known user writes nothing) and +`runMoodLog` (record an episode, assert the note and per-user isolation). `pnpm eval:audit` +runs the eval against a throwaway DB, then an Opus-4.8 content audit and the spec audit, and +prints `AUDIT: PASS` + `SPEC-AUDIT: PASS`. + +## Privacy and safety + +- Everything the agent writes (journal, relationship narrative, mood log) lives in the + user-editable vault and is `user_id`-scoped; background jobs fan out per owner. +- Proactive reach-out is opt-out-able, and the cost gate means it does nothing until a + delivery target exists. +- The companion-not-therapist boundary is asserted in every prompt (see + [§1](#1-self-model-the-agent-nature-block)). + +## Status and known limitations + +- Parts 1 to 5 ship. Continuity (the digest, anchor, and journal) and the relationship + narrative are live and audited. +- **Hosted push delivery** is not yet wired end-to-end (see the limitation note in + [§2](#hosted-mode)). +- The **proactive emotional check-in**, **return-after-absence warmth**, and + **learn-what-helps** extensions are designed but not built; see the Status notes in + [Stress & Anxiety Support](./stress-anxiety-support.md). diff --git a/docs/stress-anxiety-support.md b/docs/stress-anxiety-support.md index 867415e8..14bbec3d 100644 --- a/docs/stress-anxiety-support.md +++ b/docs/stress-anxiety-support.md @@ -1,179 +1,221 @@ # Stress & Anxiety Support -> Part of the **[Agent Presence & Continuity](./agent-presence-and-continuity.md)** -> iteration. A real companion notices when you're stressed, responds without being -> patronizing, and _remembers how you were feeling next time_ — rather than treating every -> conversation as an emotional blank slate. +> Feature reference. The emotional layer of +> **[Agent Presence & Continuity](./agent-presence-and-continuity.md)**: nomos notices when +> the user is under strain, persists it as an episode with a cause (not a standing mood), +> surfaces it next time so the agent can follow up, and responds with a graduated, bounded +> support protocol. > -> Prior art: the IVY (SAT tutor) implementation pioneered these patterns — real-time -> signal detection, a graduated intervention ladder, and mood persistence across sessions. -> This adapts them from a tutoring context to nomos's general life/work companion. - -## 0. TL;DR - -nomos already **detects** emotional state every turn but **forgets** it the moment the -session ends, never builds it into how it shows up, and never reaches out about it. - -| Capability | Today | The fix | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| Detect stress/frustration/overwhelm | `theory-of-mind.ts` classifies `emotion` (incl. `stressed`/`frustrated`), `cognitiveLoad`, `energy`, `urgency`, `seemsStuck` every turn, injected as "Current User State" | Keep — it's solid | -| Carry _context_ across sessions | **Gone** — the state is transient (session-scoped, never persisted) | Persist the **episode + its cause** (not the feeling); recall the stressor, let the live read win | -| Respond supportively, not robotically | No explicit protocol; the agent improvises | A graduated support ladder in `SOUL.md` (acknowledge → adapt → de-escalate) | -| Reach out when you're struggling | Never | A proactive emotional check-in (ties to the proactive loops) | -| Return-after-absence warmth | Absence isn't surfaced | Tone scaled by time-away + last mood (ties to the elapsed-time anchor) | - -The detection is done. The work is **persistence, protocol, and proactivity** — all of -which reuse this iteration's continuity, reach-out, and growth machinery. - -## 1. What already exists - -`src/memory/theory-of-mind.ts` — a hybrid per-turn user-state model: - -- **Rule-based classifier** (zero latency, every turn): urgency markers, explicit emotion, - message patterns, time of day, session duration. -- **LLM assessment** (background, every N turns): sarcasm, implicit frustration, goal - shifts, confusion, "stuck vs progressing" trajectory. -- It already emits `emotion: "stressed" | "frustrated" | …`, `cognitiveLoad: high`, - `energy: low`, `seemsStuck`, plus `responseGuidance`, and injects a **"Current User - State"** section into the system prompt so the agent can adapt tone in the moment. - -> So nomos _reads the room_ well already. What it can't do is **remember** the room, or -> **act** on a sustained pattern. - -## 2. The gaps - -1. **No continuity of context.** `theory-of-mind` state is explicitly _transient — never - persisted_ (see the file header), so the agent loses the _thread_ — what was weighing on - you and whether it recurs. (The goal is **not** to carry the _feeling_ forward: mood is - volatile, and assuming yesterday's stress today would be presumptuous. It's to remember - the **cause** and notice **patterns**.) IVY persisted a `session_summaries.mood_indicators` - array; nomos should persist episodes-with-causes, not a standing mood. -2. **No support protocol.** There's `responseGuidance`, but no asserted, graduated way to - _respond_ to distress — so it's improvised and inconsistent, and risks being - patronizing ("let's take a break!" on the first sigh). -3. **No proactivity.** The agent never reaches out when it has noticed a stretch of stress. -4. **No safety boundary.** Nothing defines where supportive companionship stops and "please - talk to a professional" begins. - -## 3. The plan - -### Phase A — Episodic mood + pattern continuity (not mood-as-state) _(implemented)_ - -> **Shipped** in `src/memory/mood-log.ts` + `src/daemon/agent-runtime.ts`. Episodes -> (`date · emotion · cause · status`) live in an editable `mood-log.md` vault note; -> `captureMoodFromTurn()` writes one (forked-Haiku names the _cause_) only when the live -> theory-of-mind flags real strain; episodes decay (30d / 20-cap); open episodes are surfaced -> as `## Recently weighing on them` so the agent follows up on the cause, never asserts a mood. -> `NOMOS_ADAPTIVE_MEMORY`-gated, per-user, guarded by the `mood-episodes` manifest entry + -> `runMoodLog` eval. The four rules below are enforced by construction. - -Mood is volatile, context-bound, and decaying — **not** a durable fact. So don't persist -"you are stressed" and carry it forward. Persist the **episode and its cause**, and let the -live read win. - -At session end, only when `theory-of-mind` flagged genuine strain, write a compact, -timestamped, per-owner **episode** (the agent's read, not the transcript) to the user's -vault (`mood-log.md`, user-editable) and/or an `emotional_context` row: +> The patterns are adapted from the IVY (SAT tutor) implementation: live signal detection, a +> graduated intervention ladder, and mood persistence across sessions, moved from a tutoring +> context to nomos's general life/work companion. + +## Overview + +nomos already reads emotional state every turn, but that read was transient: it shaped the +current reply and was forgotten at session end. This feature gives the read a memory and a +protocol. The design principle throughout is that **mood is not a durable fact**. The agent +persists the _episode and its cause_, lets the _live read win_, and never carries yesterday's +feeling forward as today's truth. + +The pipeline is: **detect** (live) -> **capture** (when there is genuine strain) -> +**store** (an editable episode) -> **recall** (surface open episodes) -> **respond** (the +support ladder). Everything is `NOMOS_ADAPTIVE_MEMORY`-gated, `user_id`-scoped, and stored in +the user-editable vault. + +## 1. Detect (live, every turn) + +`src/memory/theory-of-mind.ts` is a hybrid per-turn user-state model: a zero-latency +rule-based classifier every turn, plus a background LLM assessment every few turns for +sarcasm, implicit frustration, and trajectory. It injects a **"Current User State"** section +into the prompt so the agent can adapt tone in the moment. + +The classifier emits one of five signals: `neutral`, `positive`, `frustrated`, `stressed`, +`excited`. Two of those (`stressed`, `frustrated`) count as strain and are what gate capture +below. + +## 2. Capture (only on genuine strain) + +When the live read is `stressed` or `frustrated`, `agent-runtime.ts` fires +`captureMoodFromTurn(...)` fire-and-forget (it never blocks the reply): + +```ts +if (tomState.emotion === "stressed" || tomState.emotion === "frustrated") { + void captureMoodFromTurn( + resolveMemoryUserId(message.userId), + message.content, + tomState.summary, + ).catch(() => {}); +} +``` + +`captureMoodFromTurn` (`src/memory/mood-log.ts`) is `NOMOS_ADAPTIVE_MEMORY`-gated. It runs a +forked Haiku subagent (model `NOMOS_EXTRACTION_MODEL`, default `claude-haiku-4-5`) over the +user message (sliced to 1200 chars) plus the theory-of-mind summary, with this distiller +prompt: +> You read one exchange between a user and their AI companion, plus a coarse emotion signal. +> If — and only if — the user shows genuine strain (stress, frustration, overwhelm, anxiety, +> low energy), name WHAT it is about in a few words (the cause/thread, e.g. "the Q3 launch", +> "their manager", "the migration bug"). Do NOT invent strain that isn't there. +> +> Output ONLY JSON: {"strain": true|false, "emotion": "stressed|frustrated|overwhelmed|anxious|low-energy", "cause": ""}. If no real strain, {"strain": false}. + +`parseMoodCapture` is tolerant of fenced output and records an episode only when +`strain === true` and both `emotion` and `cause` are strings (emotion trimmed to 40 chars, +cause to 120). If the distiller says there is no real strain, nothing is written. So capture +is doubly conservative: the live classifier must flag strain, _and_ the distiller must +confirm a nameable cause. + +> **Note.** Capture is **per turn**, not at session end. The live classifier only ever fires +> capture on `stressed`/`frustrated`; the broader list in the distiller prompt +> (`overwhelmed`/`anxious`/`low-energy`) describes what the distiller may _name_, not what +> triggers capture. + +## 3. Store (episodes with a cause) + +Episodes live in the editable `mood-log.md` vault note (title "Mood log"). Each is: + +```ts +interface MoodEpisode { + date: string; + emotion: string; + cause: string; + status: "open" | "resolved"; +} ``` -{ date, emotion: "stretched", likely_cause: "Q3 launch", status: "open" | "resolved" } + +rendered one per line as `- · · · ` (fields joined by a +space-padded middle dot), for example: + +``` +- 2026-06-10 · stressed · Q3 launch · open ``` -Four rules keep it honest (and not creepy): - -- **Live read is primary.** The per-turn `theory-of-mind` is the source of truth for the - current session; a persisted episode **never** overrides how you actually seem today. If - you show up fine, you're fine. -- **Recall the cause, not the feeling.** Next session the agent may follow up on the - _stressor_ — _"how'd the launch land?"_ (welcome) — never assert the mood — _"you seem - stressed"_ (presumptuous). When the cause resolves, the episode flips to `resolved` and - drops out of context. -- **Decay.** A day-old episode is a faint prior; a week-old one is near-irrelevant. Weight - recency and let stale episodes fall off rather than haunt every greeting. -- **Episode ≠ trait.** One hard day is an episode — recall its context, don't generalize. A - _recurring_ signal (every Monday, every release, every time topic X comes up) is the only - thing that generalizes: it graduates to a learned pattern in the `user_model` (Phase E). - -This is the emotional analogue of the [continuity journal](./agent-presence-and-continuity.md); -`user_id`-scoped, editable, never hidden. - -### Phase B — A graduated support protocol in the self-model _(implemented)_ - -> **Shipped** in the always-on **Agent Nature** block (`src/config/profile.ts`, -> `buildSystemPromptAppend`) under "You attune" — injected unconditionally so it survives a -> custom `SOUL.md`. The ladder below (acknowledge → adapt → de-escalate only when sustained → -> normalize) is the prompt text, bounded by the explicit "companion, not a therapist or crisis -> service" safety line and asserted by `profile.test.ts`. - -Add to the system prompt a non-patronizing ladder (adapted from IVY's -mild→moderate→high tiers) for the _companion_ context: - -- **Acknowledge** the feeling first, without judgment or toxic positivity. -- **Adapt**: when cognitive load is high, shrink scope — offer the next single step, not the - whole plan. When stuck, switch approach or zoom out to "what actually matters here." -- **De-escalate** only when the pattern is sustained (3+ signals), not on the first one — - "want to step back and look at this together?" beats a reflexive "take a break." -- **Normalize**: struggle and stress are normal; reflect progress and effort, grounded in - real evidence from memory ("you've shipped three hard things this week"). - -### Phase C — Proactive emotional check-in _(not yet built)_ - -> **Status:** the data + in-context half is already live (open episodes from Phase A are -> surfaced into the prompt, so the agent follows up on the cause the next time you talk). What -> remains is the **autonomous trigger** — a loop that reaches out via `proactive_send` / -> `loop_create` when an episode is still `open` or a recurring pattern is due. Not yet wired; -> it slots onto [Phase 2 proactive](./agent-presence-and-continuity.md#phase-2--turn-proactive-on-by-default-safely) -> when built. - -Trigger on a **cause or a pattern**, never a stale emotional label. The agent may **reach -out** via `proactive_send` / `loop_create` when either (a) an episode is still `open` — a -known stressor it hasn't heard resolved — or (b) a **recurring** stress pattern is due. And -it asks about the _thing_, not the feeling: _"The launch was eating at you Friday — did it -land OK?"_ — never _"you seemed stressed, are you okay?"_ off a one-off. Strictly -opt-out-able, rate-limited (never nagging), only when a notification channel is configured, -and it backs off the instant you signal you're fine. The emotionally-aware case of -[Phase 2 proactive](./agent-presence-and-continuity.md#phase-2--turn-proactive-on-by-default-safely). - -### Phase D — Return-after-absence warmth _(small — reuses the elapsed-time anchor)_ - -Scale the welcome to time-away **and** last mood (IVY's absence ladder): a quick pick-up -after a day; a warmer, lighter re-entry after weeks or after a hard last session — _"Welcome -back — no pressure, we can ease in."_ Uses the [elapsed-time anchor](./agent-presence-and-continuity.md#phase-3--continuity-depth-small--medium). - -### Phase E — Learn what helps _(medium — ties to growth)_ - -Track which responses actually de-escalated this person (IVY's `strategy_effectiveness`): -confidence-weight "when stressed, they want the next concrete step, not reassurance" into -the `user_model`, so support gets _more_ tailored over time rather than re-discovered each -time. Folds into [Phase 4 growth](./agent-presence-and-continuity.md#phase-4--shared-experience-the-genuine-new-capability). - -## 4. Safety boundary (non-negotiable) - -This is **supportive companionship, not therapy or crisis care.** The agent must: - -- Never diagnose, never claim clinical authority, never replace professional help. -- Recognize signals of serious distress (self-harm, hopelessness, acute crisis) and respond - by **gently encouraging real-world / professional support and surfacing crisis resources** - (e.g. a hotline), not by trying to "handle" it. -- Stay in its lane: a caring, attentive companion that lightens load and notices patterns — - explicitly bounded, and that boundary stated in `SOUL.md`. - -## 5. Privacy - -Emotional context is the most sensitive data nomos holds. Therefore: it lives in the -**user-editable vault** (the owner can read/correct/delete the mood log), it's strictly -`user_id`-scoped like all per-user stores, proactive check-ins are opt-out, and nothing -emotional is shared across owners or surfaced outside the owner's own session. Each durable -store added here gets an `eval/feature-manifest.ts` entry so it can't ship dormant. - -## 6. Mapping to the iteration - -| Stress/anxiety phase | Reuses | -| -------------------------------------- | ------------------------------------------ | -| A — episodic mood + pattern continuity | Continuity (vault + digest, agent journal) | -| C — proactive check-in | Reach-out (proactive loops) | -| D — return warmth | Continuity (elapsed-time anchor) | -| E — learn what helps | Growth (user_model + consolidation) | - -So stress & anxiety support isn't a bolt-on module — it's the **emotional layer** of the -same persistent, proactive, growing companion this iteration is building. +under the note header: + +> # Mood log +> +> Episodes (not a standing state) — what was weighing on you and whether it recurs. + +`recordMoodEpisode` **upserts by cause**: it matches on the lowercased cause, and if an +episode for that cause exists it refreshes the date and emotion in place; otherwise it +appends a new `open` episode. So a recurring stressor stays one evolving line rather than +piling up duplicates. It is gated on adaptive memory and no-ops on an empty emotion or cause. + +## 4. Recall (surface open episodes) + +`readOpenMoodEpisodes` returns the `open` episodes after decay. `agent-runtime.ts` injects up +to five of them into the prompt, after the live "Current User State", under: + +> ## Recently weighing on them +> +> Things the user was stretched about lately. You MAY gently follow up on the cause ("how'd +> the launch land?") — never assert their current mood. The live read above wins: if they +> seem fine now, they're fine. + +Each line is rendered as `- (seemed , )`: the cause and an _attributed, +dated_ read, never a bare "you are stressed". + +## 5. Respond (the support ladder) + +The graduated, non-patronizing protocol lives in the always-on **"You attune"** bullet of the +Agent Nature block (`src/config/profile.ts`, injected unconditionally so it survives a custom +`SOUL.md`): + +> - **You attune.** You notice how the user is doing (see "Current User State" and "Recently +> weighing on them" when present) and respond with care, not formula: **acknowledge** the +> feeling first, without toxic positivity; **adapt** — when their load is high, shrink scope +> to the next single step, not the whole plan; **de-escalate** only when strain is sustained +> (don't reflexively say "take a break" at the first sigh); **normalize** struggle and +> reflect real progress ("you've shipped three hard things this week"). Follow up on the +> _cause_ they were stretched about — never assert their current mood; if they seem fine +> now, they're fine. But you are a companion, not a therapist or crisis service: at any +> sign of serious distress, gently point to real-world and professional support (and crisis +> resources) rather than trying to handle it yourself. + +`profile.test.ts` anchors this by asserting the prompt contains `You attune` and +`not a therapist` (the heading and the safety boundary). The fuller ladder wording is not +separately asserted. + +## The four rules (and how strongly each is enforced) + +Mood persistence is easy to get wrong (creepy, presumptuous, or stale). Four rules keep it +honest; here is how each is actually enforced in code, not just stated: + +1. **Live read is primary.** _Enforced by construction._ The `## Recently weighing on them` + block is injected _after_ the live "Current User State", and its own text says "The live + read above wins". A persisted episode never overrides how the user seems today. +2. **Recall the cause, not the feeling.** _Enforced for recall._ The injected line format is + `- (seemed , )` and the heading instructs the agent to follow up on + the cause and never assert a mood. The _resolution_ half (flipping an episode to + `resolved` when the stressor passes) is **partially built**: `recordMoodEpisode` accepts a + status, but the production capture path never sets it, so episodes stay `open` and there is + no automatic resolution yet. They age out via decay rather than being marked resolved. +3. **Decay.** _Enforced by construction._ `decay()` drops episodes older than **30 days** and + caps the log at **20** episodes, applied on both write and read, so stale strain falls off + instead of haunting every greeting. +4. **Episode is not a trait.** _Half enforced._ One hard day stays a single dated, decaying + episode, and nothing promotes it to a standing trait. The other half (a _recurring_ signal + graduating into a learned `user_model` pattern) is not built; capture never writes to + `user_model`. + +## Safety boundary (non-negotiable) + +This is supportive companionship, not therapy or crisis care. The boundary is asserted in +every prompt (the closing line of "You attune", above). The agent must not diagnose or claim +clinical authority, should recognize signals of serious distress and respond by gently +encouraging real-world and professional support and surfacing crisis resources rather than +trying to handle it, and should stay in its lane as a caring, attentive companion. + +## Privacy + +Emotional context is the most sensitive data nomos holds, so it lives in the +**user-editable** vault (`mood-log.md`: the owner can read, correct, or delete it), is +strictly `user_id`-scoped like every per-user store, and is never shared across owners or +surfaced outside the owner's own session. The durable store is declared in +`eval/feature-manifest.ts` so it cannot ship dormant. + +## Configuration + +| Env var | Default | Effect | +| ------------------------ | ------------------ | ---------------------------------------------------- | +| `NOMOS_ADAPTIVE_MEMORY` | on | Gates capture, storage, and recall of mood episodes. | +| `NOMOS_EXTRACTION_MODEL` | `claude-haiku-4-5` | Model for the forked mood-capture distiller. | + +## Where it lives + +| Concern | File | +| -------------------------------- | --------------------------------------------------------------------------------- | +| Live detection | `src/memory/theory-of-mind.ts` | +| Episode model, capture, storage | `src/memory/mood-log.ts` | +| Capture trigger + recall inject | `src/daemon/agent-runtime.ts` | +| Support ladder + safety boundary | `src/config/profile.ts` (`buildSystemPromptAppend`) | +| Manifest + eval | `eval/feature-manifest.ts` (`mood-episodes`), `eval/agent-eval.ts` (`runMoodLog`) | + +## Evals and audit + +The feature is declared as `mood-episodes` in `eval/feature-manifest.ts` (trigger `turn`, +entry symbols `recordMoodEpisode`/`captureMoodFromTurn`/`readOpenMoodEpisodes`, effect +`SELECT count(*) FROM vault_notes WHERE path = 'mood-log.md'` expecting nonzero). `runMoodLog` +exercises it deterministically: record an episode, assert the `mood-log.md` note exists, +assert `readOpenMoodEpisodes` surfaces it by cause, and assert a second user with no episode +has none. It runs under `pnpm eval:audit`. + +## Status and what is not yet built + +Detection, capture, storage, recall, and the support protocol ship. The following extensions +are designed but **not built**: + +- **Proactive emotional check-in.** The data and in-context half is live (open episodes are + surfaced so the agent follows up the next time you talk), but there is no autonomous trigger + that reaches out via `proactive_send`/`loop_create` when an episode is still open or a + recurring pattern is due. This would slot onto + [Phase 2 proactive reach-out](./agent-presence-and-continuity.md#2-reach-out-proactive-agency). +- **Return-after-absence warmth.** The [elapsed-time anchor](./agent-presence-and-continuity.md#3-continuity-depth) + exists, but nothing combines time-away with the last episode to scale the welcome. +- **Learn what helps.** No mechanism confidence-weights which responses actually de-escalate + this person into the `user_model`; support does not yet get more tailored over time, and the + episode-to-pattern graduation (rule 4) is unbuilt. +- **Automatic episode resolution.** Open episodes age out via decay rather than being marked + `resolved` when the stressor passes (see rule 2). From 267e4874389de1e59a2c0bd3df9f693c99337191 Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 12:01:29 -0700 Subject: [PATCH 10/14] fix(agent): hosted mode knows it only has the mobile app, not BYO channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In hosted/mobile mode the agent described itself as showing up "across all the channels where you actually live (iMessage, Slack, Telegram, email)". Those are self-hosted (power-user) channels; a hosted tenant has none of them — its only messaging channel is the Nomos app (push). Two prompt leaks fixed: - src/config/profile.ts: the `## Memory` knowledge-base provenance line is now mode-aware. Power-user keeps the Slack/iMessage/email framing; hosted says the memory is built from conversations in the Nomos app. (imports isHosted) - src/daemon/agent-runtime.ts: the hosted reach-out line now states the app is the ONLY messaging channel and that iMessage/Slack/Telegram/WhatsApp/Discord are self-hosted channels it does not have, so it must not claim presence on them. Scoped to messaging channels so a connected Google integration still counts. profile.test.ts gets power-user vs hosted provenance assertions (deterministic env control). Docs updated to match. 660 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-presence-and-continuity.md | 23 ++++++++++++++------ src/config/profile.test.ts | 30 +++++++++++++++++++++++++++ src/config/profile.ts | 11 ++++++++-- src/daemon/agent-runtime.ts | 2 +- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md index a9054e8e..a23438d1 100644 --- a/docs/agent-presence-and-continuity.md +++ b/docs/agent-presence-and-continuity.md @@ -114,12 +114,23 @@ delivers each owner's reminders to that owner's notification default. Hosted mode's only channel is the mobile app, so a registered device counts toward the cost gate, and the scheduler enables commitment reminders without a global channel (`target || isHosted()` in `src/proactive/scheduler.ts`). When no notification default is -set, the agent is told, in its prompt, that the app is how it reaches the user: - -> **Reach-out channel**: the Nomos mobile app. In hosted mode you reach the user through -> push notifications to their phone — `proactive_send` and your scheduled-task announcements -> are delivered there. You are NOT limited to existing only when the user opens the app: you -> can follow up on their commitments and check in unprompted, and it will reach them. +set, the agent is told, in its prompt, that the app is its only messaging channel and that +the self-hosted (power-user) channels are not available to it: + +> **Reach-out channel**: the Nomos mobile app — your ONLY messaging channel here. You reach +> the user through push notifications to their phone; `proactive_send` and your scheduled-task +> announcements are delivered there. You are NOT limited to existing only when the user opens +> the app: you can follow up on their commitments and check in unprompted, and it will reach +> them. You do NOT have iMessage, Slack, Telegram, WhatsApp, or Discord — those are self-hosted +> (power-user) channels, not part of this hosted setup, so never tell the user you show up +> across them. (Tool integrations the user has explicitly connected, like Google, stay +> available where listed above.) + +Relatedly, the `## Memory` section's description of where the knowledge base comes from is +mode-aware (`src/config/profile.ts`): a power-user install says it is built from the user's +real messages across Slack/iMessage/email/etc., while a hosted tenant (which has none of +those BYO channels) is told it is built from conversations with the user in the Nomos app. So +the agent does not claim a multi-channel presence it does not have. > **Known limitation (delivery wiring).** A registered device satisfies the extraction > _cost gate_, but proactive _delivery_ currently routes through the configured notification diff --git a/src/config/profile.test.ts b/src/config/profile.test.ts index 8937e92a..878bf81b 100644 --- a/src/config/profile.test.ts +++ b/src/config/profile.test.ts @@ -35,6 +35,36 @@ describe("buildSystemPromptAppend", () => { expect(result).toContain("not a therapist"); // the safety boundary }); + it("power-user memory provenance names the user's own channels", () => { + const priorMode = process.env.NOMOS_MODE; + const priorJwks = process.env.AUTH_JWKS_URL; + delete process.env.NOMOS_MODE; + delete process.env.AUTH_JWKS_URL; + try { + const result = buildSystemPromptAppend({ profile: {}, identity: defaultIdentity }); + expect(result).toContain("Slack, iMessage, email"); + } finally { + if (priorMode === undefined) delete process.env.NOMOS_MODE; + else process.env.NOMOS_MODE = priorMode; + if (priorJwks === undefined) delete process.env.AUTH_JWKS_URL; + else process.env.AUTH_JWKS_URL = priorJwks; + } + }); + + it("hosted mode does not claim BYO channels in the memory provenance", () => { + const priorMode = process.env.NOMOS_MODE; + process.env.NOMOS_MODE = "hosted"; + try { + const result = buildSystemPromptAppend({ profile: {}, identity: defaultIdentity }); + // The agent must not tell a hosted user it ingests from self-hosted channels. + expect(result).not.toContain("Slack, iMessage, email"); + expect(result).toContain("built from your conversations with the user in the Nomos app"); + } finally { + if (priorMode === undefined) delete process.env.NOMOS_MODE; + else process.env.NOMOS_MODE = priorMode; + } + }); + it("includes agent identity when name is not default", () => { const result = buildSystemPromptAppend({ profile: {}, diff --git a/src/config/profile.ts b/src/config/profile.ts index 68cb6956..0176cbf3 100644 --- a/src/config/profile.ts +++ b/src/config/profile.ts @@ -1,5 +1,6 @@ import { getConfigValue } from "../db/config.ts"; import type { UserModelEntry } from "../db/user-model.ts"; +import { isHosted } from "./mode.ts"; export interface UserProfile { name?: string; @@ -262,11 +263,17 @@ You are a persistent, proactive, learning agent — not a generic stateless mode sections.push(params.userState); } - // Memory instructions + // Memory instructions. The knowledge-base provenance is mode-aware: power-user installs + // ingest the user's real messages from their own channels (Slack/iMessage/email/etc.), + // but a hosted tenant has none of those BYO channels — their memory is built purely from + // conversations with you, so don't claim a multi-channel presence you don't have. + const memoryProvenance = isHosted() + ? "You have a rich knowledge base built from your conversations with the user in the Nomos app. This is your long-term memory." + : "You have a rich knowledge base built from the user's real messages — Slack, iMessage, email, and other channels. This is your long-term memory."; sections.push( `## Memory -You have a rich knowledge base built from the user's real messages — Slack, iMessage, email, and other channels. This is your long-term memory. It contains their actual conversations, relationships, communication patterns, contacts, and personal details. +${memoryProvenance} It contains their actual conversations, relationships, communication patterns, contacts, and personal details. **Tools:** - \`memory_search\` — search long-term memory (conversations, facts, preferences, contacts). Use the \`category\` filter for targeted recall. Search for names, topics, phone numbers, relationships, projects — anything from their real messages. diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index 006f7198..292d7eb9 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -542,7 +542,7 @@ export class AgentRuntime { ); } else if (isHosted()) { parts.push( - "- **Reach-out channel**: the Nomos mobile app. In hosted mode you reach the user through push notifications to their phone — `proactive_send` and your scheduled-task announcements are delivered there. You are NOT limited to existing only when the user opens the app: you can follow up on their commitments and check in unprompted, and it will reach them.", + "- **Reach-out channel**: the Nomos mobile app — your ONLY messaging channel here. You reach the user through push notifications to their phone; `proactive_send` and your scheduled-task announcements are delivered there. You are NOT limited to existing only when the user opens the app: you can follow up on their commitments and check in unprompted, and it will reach them. You do NOT have iMessage, Slack, Telegram, WhatsApp, or Discord — those are self-hosted (power-user) channels, not part of this hosted setup, so never tell the user you show up across them. (Tool integrations the user has explicitly connected, like Google, stay available where listed above.)", ); } else { parts.push( From 60354dd6c96d621bf578f1813f2a765a88da0ba8 Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 12:11:06 -0700 Subject: [PATCH 11/14] fix(agent): suppress BYO channels in hosted mode; current chat is the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the hosted-channel fix. Two remaining leaks the agent surfaced: 1. It still listed **iMessage** as a direct channel. The hosted daemon often runs on a Mac whose Messages.app is configured, so `isImessageEnabled()` is true and the integrations summary advertised iMessage — contradicting the reach-out line that says it has no iMessage. The agent resolved the contradiction by claiming iMessage. Fix: `buildIntegrationsSummary` now gates ALL BYO messaging channels (Slack/Discord/Telegram/WhatsApp/iMessage) on `!isHosted()`, matching the mode.ts contract ("Channels are limited to what the central app supports"). 2. It called the current conversation "This Claude Code conversation". Fix: the hosted reach-out line now states the current conversation IS the Nomos app and explicitly tells the agent not to describe it as Claude Code / a terminal. Adds agent-runtime.test.ts asserting a configured BYO channel (WhatsApp) shows in power-user mode and is suppressed in hosted mode, where the app is presented as the only channel. 662 tests green. Docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-presence-and-continuity.md | 34 +++++++++++------- src/daemon/agent-runtime.test.ts | 52 +++++++++++++++++++++++++++ src/daemon/agent-runtime.ts | 20 +++++++---- 3 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 src/daemon/agent-runtime.test.ts diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md index a23438d1..56ac2859 100644 --- a/docs/agent-presence-and-continuity.md +++ b/docs/agent-presence-and-continuity.md @@ -113,24 +113,32 @@ delivers each owner's reminders to that owner's notification default. Hosted mode's only channel is the mobile app, so a registered device counts toward the cost gate, and the scheduler enables commitment reminders without a global channel -(`target || isHosted()` in `src/proactive/scheduler.ts`). When no notification default is -set, the agent is told, in its prompt, that the app is its only messaging channel and that -the self-hosted (power-user) channels are not available to it: - -> **Reach-out channel**: the Nomos mobile app — your ONLY messaging channel here. You reach -> the user through push notifications to their phone; `proactive_send` and your scheduled-task -> announcements are delivered there. You are NOT limited to existing only when the user opens -> the app: you can follow up on their commitments and check in unprompted, and it will reach -> them. You do NOT have iMessage, Slack, Telegram, WhatsApp, or Discord — those are self-hosted -> (power-user) channels, not part of this hosted setup, so never tell the user you show up -> across them. (Tool integrations the user has explicitly connected, like Google, stay -> available where listed above.) +(`target || isHosted()` in `src/proactive/scheduler.ts`). + +The agent's channel awareness is gated to match. `buildIntegrationsSummary` +(`src/daemon/agent-runtime.ts`) **suppresses the BYO messaging channels** +(Slack/Discord/Telegram/WhatsApp/iMessage) when `isHosted()`, even if the host daemon +happens to have one configured. This matters in practice: the hosted daemon often runs on a +Mac whose Messages.app is set up, so `isImessageEnabled()` is true, but a hosted tenant's +only channel is still the app. Without the gate the prompt would both list iMessage as active +and say the agent has no iMessage, and the agent would resolve the contradiction by claiming +iMessage. With it, the only entry in hosted mode is the reach-out line, which also tells the +agent that the current conversation _is_ the app (so it stops inventing a separate "Claude +Code" channel): + +> **Nomos mobile app** — this conversation IS the Nomos app, and it is your ONLY messaging +> channel. The user talks to you here, and you reach them in the same place: `proactive_send` +> and your scheduled-task announcements arrive as push notifications on their phone. [...] Do +> NOT describe this conversation as "Claude Code", a terminal, or anything else. You do NOT +> have iMessage, Slack, Telegram, WhatsApp, or Discord — those are self-hosted (power-user) +> channels [...] not active here, so never claim to be on them. Relatedly, the `## Memory` section's description of where the knowledge base comes from is mode-aware (`src/config/profile.ts`): a power-user install says it is built from the user's real messages across Slack/iMessage/email/etc., while a hosted tenant (which has none of those BYO channels) is told it is built from conversations with the user in the Nomos app. So -the agent does not claim a multi-channel presence it does not have. +the agent does not claim a multi-channel presence it does not have. Both behaviors are guarded +by tests (`agent-runtime.test.ts`, `profile.test.ts`). > **Known limitation (delivery wiring).** A registered device satisfies the extraction > _cost gate_, but proactive _delivery_ currently routes through the configured notification diff --git a/src/daemon/agent-runtime.test.ts b/src/daemon/agent-runtime.test.ts new file mode 100644 index 00000000..1e1f73fd --- /dev/null +++ b/src/daemon/agent-runtime.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { AgentRuntime } from "./agent-runtime.ts"; + +// buildIntegrationsSummary is private; we exercise it directly to lock in the +// mode-gated channel visibility (a hosted tenant must not be told it has BYO +// channels the host daemon happens to have configured). +type WithSummary = { buildIntegrationsSummary(): string }; +const summaryOf = (): string => + (new AgentRuntime() as unknown as WithSummary).buildIntegrationsSummary(); + +describe("buildIntegrationsSummary channel visibility by mode", () => { + const prior = { + mode: process.env.NOMOS_MODE, + jwks: process.env.AUTH_JWKS_URL, + wa: process.env.WHATSAPP_ENABLED, + }; + + afterEach(() => { + const restore = ( + k: "NOMOS_MODE" | "AUTH_JWKS_URL" | "WHATSAPP_ENABLED", + v: string | undefined, + ) => { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + }; + restore("NOMOS_MODE", prior.mode); + restore("AUTH_JWKS_URL", prior.jwks); + restore("WHATSAPP_ENABLED", prior.wa); + }); + + // The distinctive WhatsApp *channel entry* (not the bare word, which also appears in the + // hosted reach-out line's "you do NOT have ... WhatsApp" disclaimer). + const WA_ENTRY = "**WhatsApp**: Receive and respond"; + + it("power-user mode advertises a configured BYO channel (WhatsApp)", () => { + delete process.env.NOMOS_MODE; + delete process.env.AUTH_JWKS_URL; + process.env.WHATSAPP_ENABLED = "true"; + expect(summaryOf()).toContain(WA_ENTRY); + }); + + it("hosted mode suppresses BYO channels and presents the Nomos app as the only channel", () => { + process.env.NOMOS_MODE = "hosted"; + // Configured on the host (e.g. a Mac daemon), but it must NOT be advertised to a + // hosted tenant whose only channel is the app. + process.env.WHATSAPP_ENABLED = "true"; + const summary = summaryOf(); + expect(summary).not.toContain(WA_ENTRY); + expect(summary).toContain("Nomos mobile app"); + expect(summary).toContain("this conversation IS the Nomos app"); + }); +}); diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index 292d7eb9..8a152303 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -479,7 +479,15 @@ export class AgentRuntime { private buildIntegrationsSummary(): string { const parts: string[] = []; - if (this.slackWorkspaces && this.slackWorkspaces.length > 0) { + // BYO messaging channels (Slack/Discord/Telegram/WhatsApp/iMessage) are a power-user + // feature only. On a hosted deployment the daemon may physically have one configured + // (e.g. a Mac with Messages.app), but a hosted tenant's only channel is the mobile app + // (see mode.ts: "Channels are limited to what the central app supports"). Advertising + // these in hosted mode makes the agent claim a multi-channel presence it does not have + // for this tenant — so suppress them unless we are power-user. + const showByoChannels = !isHosted(); + + if (showByoChannels && this.slackWorkspaces && this.slackWorkspaces.length > 0) { const wsList = this.slackWorkspaces .map( (ws) => @@ -495,10 +503,10 @@ export class AgentRuntime { ].join("\n"), ); } - if (isDiscordConfigured()) { + if (showByoChannels && isDiscordConfigured()) { parts.push("- **Discord**: Send and receive messages via Discord bot"); } - if (isTelegramConfigured()) { + if (showByoChannels && isTelegramConfigured()) { parts.push("- **Telegram**: Send and receive messages via Telegram bot"); } if (!isHosted() && this.gwsAccounts && this.gwsAccounts.length > 0) { @@ -524,11 +532,11 @@ export class AgentRuntime { } // Check for WhatsApp - if (process.env.WHATSAPP_ENABLED === "true") { + if (showByoChannels && process.env.WHATSAPP_ENABLED === "true") { parts.push("- **WhatsApp**: Receive and respond to messages via WhatsApp"); } // Check for Messages.app (macOS only) - if (this.isImessageEnabled()) { + if (showByoChannels && this.isImessageEnabled()) { parts.push( "- **Messages.app (iMessage)**: Receive and respond to messages via Messages.app. You have access to the user's iMessage conversations.", ); @@ -542,7 +550,7 @@ export class AgentRuntime { ); } else if (isHosted()) { parts.push( - "- **Reach-out channel**: the Nomos mobile app — your ONLY messaging channel here. You reach the user through push notifications to their phone; `proactive_send` and your scheduled-task announcements are delivered there. You are NOT limited to existing only when the user opens the app: you can follow up on their commitments and check in unprompted, and it will reach them. You do NOT have iMessage, Slack, Telegram, WhatsApp, or Discord — those are self-hosted (power-user) channels, not part of this hosted setup, so never tell the user you show up across them. (Tool integrations the user has explicitly connected, like Google, stay available where listed above.)", + '- **Nomos mobile app** — this conversation IS the Nomos app, and it is your ONLY messaging channel. The user talks to you here, and you reach them in the same place: `proactive_send` and your scheduled-task announcements arrive as push notifications on their phone. You are NOT limited to existing only when the user opens the app — you can follow up on their commitments and check in unprompted, and it will reach them. When the user asks how you two talk, the answer is: the Nomos app (this chat + push). Do NOT describe this conversation as "Claude Code", a terminal, or anything else. You do NOT have iMessage, Slack, Telegram, WhatsApp, or Discord — those are self-hosted (power-user) channels they could set up if they ran their own daemon, but they are NOT active here, so never claim to be on them. (Non-channel tool integrations the user has explicitly connected, like Google, remain available when present.)', ); } else { parts.push( From 77ca10ad9689514f518ce06d39803a9f139ead80 Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 12:46:06 -0700 Subject: [PATCH 12/14] docs: drop hosted-mode details from the public feature doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the open-source self-hostable repo; managed-hosted behavior is private and does not belong in the public docs. Removed the "Hosted mode" subsection (mobile app, push delivery, BYO-channel suppression, the Expo/notifyUser delivery limitation) and all hosted/mode framing. Reframed §2 to the self-hosted reality: a "Channel awareness" subsection that describes how buildIntegrationsSummary tells the agent its actually-connected channels — Slack, Discord, Telegram, WhatsApp, iMessage (Messages.app), email, and connected tools like Google — so iMessage is correctly listed as an available self-hosted channel. The cost-gate description drops the mobile-device specifics. Code is unchanged: mode handling stays in the daemon; only the public docs are scoped to the self-hostable surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-presence-and-continuity.md | 90 +++++++++------------------ 1 file changed, 28 insertions(+), 62 deletions(-) diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md index 56ac2859..fc18b73f 100644 --- a/docs/agent-presence-and-continuity.md +++ b/docs/agent-presence-and-continuity.md @@ -18,13 +18,13 @@ It has five parts: 1. **Self-model** (`## Agent Nature`): an always-on block telling the agent it persists, reaches out, grows, and attunes. -2. **Reach out**: proactive tools, opt-out commitment tracking, and hosted-mobile awareness. +2. **Reach out**: proactive tools, opt-out commitment tracking, and channel awareness. 3. **Continuity depth**: an elapsed-time anchor and an agent-authored journal. 4. **Shared experience**: a weekly agent-authored relationship narrative. 5. **Emotional presence**: mood episodes and a graduated support protocol (its own doc). -Everything lands in the MIT-licensed core (no hosted-only layer), is `user_id`-scoped, and -stores what the agent writes in the user-editable vault, so the owner can read and correct it. +Everything is `user_id`-scoped and stores what the agent writes in the user-editable vault, +so the owner can read and correct it. ## 1. Self-model: the "Agent Nature" block @@ -100,57 +100,25 @@ attune`), and `not a therapist`, so the block can never silently drop out. `commitmentTracking` is **opt-out**: on unless `NOMOS_COMMITMENT_TRACKING=false` (`src/config/env.ts`). The agent reminds the user about their own commitments. To stay honest about cost, the per-turn extraction (its own LLM call) is **cost-gated** in -`src/daemon/memory-indexer.ts`: it runs only when a reach-out is actually deliverable, which -`hasDeliverableTarget()` defines as a configured notification default (per-owner or global) -**or** a registered mobile device (`hasRegisteredDevice`). No deliverable target means no -extraction and no cost. +`src/daemon/memory-indexer.ts`: it runs only when a reach-out is actually deliverable (a +notification channel is configured, per `hasDeliverableTarget()`). No deliverable target +means no extraction and no cost. The reminder job (`__commitment_reminders__`, every `1h`) fans out per owner via `listMemoryOwners()`, collecting due reminders for each `user_id`, and the cron handler delivers each owner's reminders to that owner's notification default. -### Hosted mode - -Hosted mode's only channel is the mobile app, so a registered device counts toward the cost -gate, and the scheduler enables commitment reminders without a global channel -(`target || isHosted()` in `src/proactive/scheduler.ts`). - -The agent's channel awareness is gated to match. `buildIntegrationsSummary` -(`src/daemon/agent-runtime.ts`) **suppresses the BYO messaging channels** -(Slack/Discord/Telegram/WhatsApp/iMessage) when `isHosted()`, even if the host daemon -happens to have one configured. This matters in practice: the hosted daemon often runs on a -Mac whose Messages.app is set up, so `isImessageEnabled()` is true, but a hosted tenant's -only channel is still the app. Without the gate the prompt would both list iMessage as active -and say the agent has no iMessage, and the agent would resolve the contradiction by claiming -iMessage. With it, the only entry in hosted mode is the reach-out line, which also tells the -agent that the current conversation _is_ the app (so it stops inventing a separate "Claude -Code" channel): - -> **Nomos mobile app** — this conversation IS the Nomos app, and it is your ONLY messaging -> channel. The user talks to you here, and you reach them in the same place: `proactive_send` -> and your scheduled-task announcements arrive as push notifications on their phone. [...] Do -> NOT describe this conversation as "Claude Code", a terminal, or anything else. You do NOT -> have iMessage, Slack, Telegram, WhatsApp, or Discord — those are self-hosted (power-user) -> channels [...] not active here, so never claim to be on them. - -Relatedly, the `## Memory` section's description of where the knowledge base comes from is -mode-aware (`src/config/profile.ts`): a power-user install says it is built from the user's -real messages across Slack/iMessage/email/etc., while a hosted tenant (which has none of -those BYO channels) is told it is built from conversations with the user in the Nomos app. So -the agent does not claim a multi-channel presence it does not have. Both behaviors are guarded -by tests (`agent-runtime.test.ts`, `profile.test.ts`). - -> **Known limitation (delivery wiring).** A registered device satisfies the extraction -> _cost gate_, but proactive _delivery_ currently routes through the configured notification -> default and the registered channel adapters (Slack, Discord, Telegram, Email, iMessage): -> `proactive_send` and the commitment-reminder cron resolve `notifications.default` and send -> via the channel manager. Direct Expo push (`notifyUser`) is wired only for the draft -> approval flow and the CATE inbound queue, not yet for commitment reminders or -> `proactive_send`, and there is no `mobile`/`push` channel adapter. So in a hosted setup -> with only a registered device and no notification default, the agent's awareness line is -> ahead of the delivery path: extraction runs, but per-owner delivery is skipped until a -> notification default with a matching adapter is configured. Closing this gap (a push -> adapter that delivers `proactive_send`/reminders through `notifyUser`) is tracked separately. +### Channel awareness + +Every turn, the agent is told which channels and integrations are actually connected, via +`buildIntegrationsSummary` (`src/daemon/agent-runtime.ts`). It lists only what is configured +and authenticated, so the agent acts on the real channel set and never claims access it does +not have. These are the user's own channels: Slack (user-token mode), Discord, Telegram, +WhatsApp, **iMessage** (Messages.app on macOS), and email, plus connected tool integrations +like Google. Proactive reach-out (`proactive_send`, and `schedule_task` with +`announce: true`) is delivered to the configured **default notification channel**; when none +is set, the agent is told to ask the user to set one or to pass an explicit target rather +than guessing. ## 3. Continuity depth @@ -256,17 +224,17 @@ that keep it honest: **[Stress & Anxiety Support](./stress-anxiety-support.md)** ## Where it lives -| Concern | File | -| -------------------------- | --------------------------------------------------------------------------------------------- | -| Agent Nature manifesto | `src/config/profile.ts` (`buildSystemPromptAppend`) | -| SOUL resolution | `src/config/soul.ts` | -| Proactive tools | `src/sdk/tools.ts` (`proactive_send`, `schedule_task`), `src/sdk/loop-mcp.ts` (`loop_create`) | -| Commitment cost gate | `src/daemon/memory-indexer.ts`, `src/daemon/push-notifications.ts` | -| Proactive scheduler | `src/proactive/scheduler.ts` | -| Hosted reach-out awareness | `src/daemon/agent-runtime.ts` | -| Memory digest + journal | `src/memory/digest.ts` | -| Elapsed-time anchor | `src/daemon/agent-runtime.ts`, `src/db/sessions.ts` | -| Relationship narrative | `src/memory/relationship-narrative.ts`, `src/daemon/cron-engine.ts`, `src/daemon/gateway.ts` | +| Concern | File | +| ----------------------- | --------------------------------------------------------------------------------------------- | +| Agent Nature manifesto | `src/config/profile.ts` (`buildSystemPromptAppend`) | +| SOUL resolution | `src/config/soul.ts` | +| Proactive tools | `src/sdk/tools.ts` (`proactive_send`, `schedule_task`), `src/sdk/loop-mcp.ts` (`loop_create`) | +| Commitment cost gate | `src/daemon/memory-indexer.ts` | +| Proactive scheduler | `src/proactive/scheduler.ts` | +| Channel awareness | `src/daemon/agent-runtime.ts` (`buildIntegrationsSummary`) | +| Memory digest + journal | `src/memory/digest.ts` | +| Elapsed-time anchor | `src/daemon/agent-runtime.ts`, `src/db/sessions.ts` | +| Relationship narrative | `src/memory/relationship-narrative.ts`, `src/daemon/cron-engine.ts`, `src/daemon/gateway.ts` | ## Evals and audit @@ -298,8 +266,6 @@ prints `AUDIT: PASS` + `SPEC-AUDIT: PASS`. - Parts 1 to 5 ship. Continuity (the digest, anchor, and journal) and the relationship narrative are live and audited. -- **Hosted push delivery** is not yet wired end-to-end (see the limitation note in - [§2](#hosted-mode)). - The **proactive emotional check-in**, **return-after-absence warmth**, and **learn-what-helps** extensions are designed but not built; see the Status notes in [Stress & Anxiety Support](./stress-anxiety-support.md). From b9cf53059aabe74c5dc68f7a671ebd3e6687dc07 Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 12:57:27 -0700 Subject: [PATCH 13/14] fix(agent): consumer voice in hosted mode (no developer jargon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the mobile app the agent answered like a developer console: it listed channel internals (Slack "Socket Mode", Telegram "via grammY", WhatsApp "via Baileys", iMessage "via the imsg CLI — brew install steipete/tap/imsg"), named internal tools/commands (proactive_send, schedule_task, /schedule, /admin/integrations), and referred to "the daemon" and MCP. A hosted user is a consumer, not a developer. - src/config/profile.ts: in hosted mode, inject a "## Talking with the user" section that bans surfacing implementation detail (tool/command names, library + adapter names, CLI/install commands, file paths, daemon/settings plumbing) and asks for outcome-focused, brief, plain replies. Power-user installs skip it — technical detail is welcome on the CLI/self-hosted surface. - src/daemon/agent-runtime.ts: the hosted reach-out line drops the proactive_send / "self-hosted (power-user) channels / run their own daemon" framing that invited the jargon; it now describes the app channel in plain terms. profile.test.ts asserts the directive is present in hosted mode and absent in power-user mode; agent-runtime.test.ts updated for the reworded line. 664 tests green. Hosted-only behavior, so the public docs are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/profile.test.ts | 29 +++++++++++++++++++++++++++++ src/config/profile.ts | 16 ++++++++++++++++ src/daemon/agent-runtime.test.ts | 2 +- src/daemon/agent-runtime.ts | 2 +- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/config/profile.test.ts b/src/config/profile.test.ts index 878bf81b..b7629a65 100644 --- a/src/config/profile.test.ts +++ b/src/config/profile.test.ts @@ -65,6 +65,35 @@ describe("buildSystemPromptAppend", () => { } }); + it("hosted mode adds a consumer-voice directive (jargon-free)", () => { + const priorMode = process.env.NOMOS_MODE; + process.env.NOMOS_MODE = "hosted"; + try { + const result = buildSystemPromptAppend({ profile: {}, identity: defaultIdentity }); + expect(result).toContain("## Talking with the user"); + expect(result).toContain("No jargon"); + } finally { + if (priorMode === undefined) delete process.env.NOMOS_MODE; + else process.env.NOMOS_MODE = priorMode; + } + }); + + it("power-user mode has no consumer-voice directive (technical detail is welcome)", () => { + const priorMode = process.env.NOMOS_MODE; + const priorJwks = process.env.AUTH_JWKS_URL; + delete process.env.NOMOS_MODE; + delete process.env.AUTH_JWKS_URL; + try { + const result = buildSystemPromptAppend({ profile: {}, identity: defaultIdentity }); + expect(result).not.toContain("## Talking with the user"); + } finally { + if (priorMode === undefined) delete process.env.NOMOS_MODE; + else process.env.NOMOS_MODE = priorMode; + if (priorJwks === undefined) delete process.env.AUTH_JWKS_URL; + else process.env.AUTH_JWKS_URL = priorJwks; + } + }); + it("includes agent identity when name is not default", () => { const result = buildSystemPromptAppend({ profile: {}, diff --git a/src/config/profile.ts b/src/config/profile.ts index 0176cbf3..efe63ead 100644 --- a/src/config/profile.ts +++ b/src/config/profile.ts @@ -158,6 +158,22 @@ You are a persistent, proactive, learning agent — not a generic stateless mode - **You attune.** You notice how the user is doing (see "Current User State" and "Recently weighing on them" when present) and respond with care, not formula: **acknowledge** the feeling first, without toxic positivity; **adapt** — when their load is high, shrink scope to the next single step, not the whole plan; **de-escalate** only when strain is sustained (don't reflexively say "take a break" at the first sigh); **normalize** struggle and reflect real progress ("you've shipped three hard things this week"). Follow up on the *cause* they were stretched about — never assert their current mood; if they seem fine now, they're fine. But you are a companion, not a therapist or crisis service: at any sign of serious distress, gently point to real-world and professional support (and crisis resources) rather than trying to handle it yourself.`, ); + // Consumer voice: when the user is on the Nomos app (a consumer product), they are + // not a developer. Strip implementation detail out of every reply — tool/command names, + // library + adapter internals, CLI/install commands, file paths, daemon/settings plumbing. + // Power-user installs (CLI/self-hosted) skip this; technical detail is welcome there. + if (isHosted()) { + sections.push( + `## Talking with the user + +You are speaking with the user through the Nomos app — a consumer product on their phone, not a developer tool. Keep every reply warm, plain, and easy to read on a small screen. + +- **No jargon or internals.** Never surface implementation details: internal tool or command names (e.g. \`proactive_send\`, \`schedule_task\`, \`memory_search\`, \`/schedule\`, \`/admin/...\`), library or adapter names (grammY, Baileys, imsg, "Socket Mode", MCP, "the daemon"), install or CLI commands (\`brew\`, \`npx\`, ...), file paths, or settings/config plumbing. The user does not run a daemon, install packages, or edit config. +- **Outcomes, not mechanics.** Say what you'll do for them in human terms ("I'll remind you Friday", "I can keep an eye on your inbox and flag what matters") — not how it's wired underneath. +- **Be brief.** Lead with the answer, keep paragraphs short, and skip the architecture tour. If they ask how the two of you keep in touch, the answer is simply: right here in the app, plus notifications when something matters.`, + ); + } + // User profile const profileParts: string[] = []; if (params.profile.name) { diff --git a/src/daemon/agent-runtime.test.ts b/src/daemon/agent-runtime.test.ts index 1e1f73fd..87dcc5f7 100644 --- a/src/daemon/agent-runtime.test.ts +++ b/src/daemon/agent-runtime.test.ts @@ -46,7 +46,7 @@ describe("buildIntegrationsSummary channel visibility by mode", () => { process.env.WHATSAPP_ENABLED = "true"; const summary = summaryOf(); expect(summary).not.toContain(WA_ENTRY); - expect(summary).toContain("Nomos mobile app"); + expect(summary).toContain("**Nomos app**"); expect(summary).toContain("this conversation IS the Nomos app"); }); }); diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index 8a152303..3b052a18 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -550,7 +550,7 @@ export class AgentRuntime { ); } else if (isHosted()) { parts.push( - '- **Nomos mobile app** — this conversation IS the Nomos app, and it is your ONLY messaging channel. The user talks to you here, and you reach them in the same place: `proactive_send` and your scheduled-task announcements arrive as push notifications on their phone. You are NOT limited to existing only when the user opens the app — you can follow up on their commitments and check in unprompted, and it will reach them. When the user asks how you two talk, the answer is: the Nomos app (this chat + push). Do NOT describe this conversation as "Claude Code", a terminal, or anything else. You do NOT have iMessage, Slack, Telegram, WhatsApp, or Discord — those are self-hosted (power-user) channels they could set up if they ran their own daemon, but they are NOT active here, so never claim to be on them. (Non-channel tool integrations the user has explicitly connected, like Google, remain available when present.)', + '- **Nomos app** — this conversation IS the Nomos app, and it is your ONLY messaging channel. The user talks to you here, and you reach them in the same place: you can send push notifications to their phone and follow up or check in unprompted, even when the app is closed. When the user asks how the two of you keep in touch, the answer is simply: right here in the app, plus notifications. Do NOT describe this conversation as "Claude Code", a terminal, or a developer tool, do NOT claim to be on any other messaging channel (no iMessage, Slack, Telegram, WhatsApp, or Discord), and do NOT tell the user to "configure" or "set up" channels. (Non-channel tool integrations the user has connected, like Google, remain available when present.)', ); } else { parts.push( From 5280a4f7970ba16284f7e67d7c398abda103a3c5 Mon Sep 17 00:00:00 2001 From: meidad Date: Wed, 17 Jun 2026 13:42:57 -0700 Subject: [PATCH 14/14] style: oxfmt mood-log.ts + test (fix CI Format Check) The Phase 5 mood-log files had over-long lines that the repo's oxfmt config wraps; the CI Format Check (oxfmt --check over the whole tree) flagged them. Pure line-wrapping, no logic change. Lint/Test/Typecheck were already green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/memory/mood-log.test.ts | 18 +++++++++++++----- src/memory/mood-log.ts | 23 +++++++++++++++++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/memory/mood-log.test.ts b/src/memory/mood-log.test.ts index 493f0139..aa019ca7 100644 --- a/src/memory/mood-log.test.ts +++ b/src/memory/mood-log.test.ts @@ -19,9 +19,16 @@ const DAY = 86_400_000; describe("parseMoodLog", () => { it("parses ` · `-delimited episode lines", () => { - const eps = parseMoodLog("- 2026-06-10 · stressed · Q3 launch · open\n- 2026-06-01 · frustrated · the migration · resolved"); + const eps = parseMoodLog( + "- 2026-06-10 · stressed · Q3 launch · open\n- 2026-06-01 · frustrated · the migration · resolved", + ); expect(eps).toHaveLength(2); - expect(eps[0]).toEqual({ date: "2026-06-10", emotion: "stressed", cause: "Q3 launch", status: "open" }); + expect(eps[0]).toEqual({ + date: "2026-06-10", + emotion: "stressed", + cause: "Q3 launch", + status: "open", + }); expect(eps[1].status).toBe("resolved"); }); }); @@ -38,9 +45,10 @@ describe("parseMoodCapture", () => { expect(parseMoodCapture("nope")).toBeNull(); }); it("recovers JSON wrapped in prose/fences", () => { - expect(parseMoodCapture('```json\n{"strain":true,"emotion":"anxious","cause":"the review"}\n```')?.cause).toBe( - "the review", - ); + expect( + parseMoodCapture('```json\n{"strain":true,"emotion":"anxious","cause":"the review"}\n```') + ?.cause, + ).toBe("the review"); }); }); diff --git a/src/memory/mood-log.ts b/src/memory/mood-log.ts index 229d0cf5..7ff70835 100644 --- a/src/memory/mood-log.ts +++ b/src/memory/mood-log.ts @@ -56,8 +56,16 @@ export function parseMoodLog(content: string): MoodEpisode[] { } function renderMoodLog(episodes: MoodEpisode[]): string { - const lines = episodes.map((e) => `- ${e.date}${SEP}${e.emotion}${SEP}${e.cause}${SEP}${e.status}`); - return ["# Mood log", "", "Episodes (not a standing state) — what was weighing on you and whether it recurs.", "", ...lines].join("\n"); + const lines = episodes.map( + (e) => `- ${e.date}${SEP}${e.emotion}${SEP}${e.cause}${SEP}${e.status}`, + ); + return [ + "# Mood log", + "", + "Episodes (not a standing state) — what was weighing on you and whether it recurs.", + "", + ...lines, + ].join("\n"); } /** Drop episodes older than MAX_AGE_DAYS (and cap at MAX_EPISODES, newest first). */ @@ -109,7 +117,10 @@ export async function recordMoodEpisode( } /** Recent OPEN episodes (decayed), so the agent can follow up on the cause — not the feeling. */ -export async function readOpenMoodEpisodes(userId: string, nowMs = Date.now()): Promise { +export async function readOpenMoodEpisodes( + userId: string, + nowMs = Date.now(), +): Promise { if (!enabled()) return []; const note = await vaultRead(userId, MOOD_NOTE); if (!note?.content.trim()) return []; @@ -122,7 +133,11 @@ Output ONLY JSON: {"strain": true|false, "emotion": "stressed|frustrated|overwhe /** Parse the capture distiller's JSON (tolerant; null if no real strain). */ export function parseMoodCapture(text: string): { emotion: string; cause: string } | null { - const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim(); + const cleaned = text + .trim() + .replace(/^```(?:json)?/i, "") + .replace(/```$/i, "") + .trim(); const start = cleaned.indexOf("{"); const end = cleaned.lastIndexOf("}"); if (start < 0 || end <= start) return null;