diff --git a/docs/agent-presence-and-continuity.md b/docs/agent-presence-and-continuity.md new file mode 100644 index 00000000..fc18b73f --- /dev/null +++ b/docs/agent-presence-and-continuity.md @@ -0,0 +1,271 @@ +# Agent Presence & Continuity + +> 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 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 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 (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. + +### 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 + +### 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 +> +> 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` | +| 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 + +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. +- 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 new file mode 100644 index 00000000..14bbec3d --- /dev/null +++ b/docs/stress-anxiety-support.md @@ -0,0 +1,221 @@ +# Stress & Anxiety Support + +> 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. +> +> 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"; +} +``` + +rendered one per line as `- · · · ` (fields joined by a +space-padded middle dot), for example: + +``` +- 2026-06-10 · stressed · Q3 launch · open +``` + +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). diff --git a/eval/agent-eval.ts b/eval/agent-eval.ts index 87f91a0b..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; } @@ -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/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 }; } 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/config/profile.test.ts b/src/config/profile.test.ts index 65b7912d..b7629a65 100644 --- a/src/config/profile.test.ts +++ b/src/config/profile.test.ts @@ -20,6 +20,80 @@ 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("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("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 d0bb0b97..efe63ead 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; @@ -141,6 +142,38 @@ 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. 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" 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) { @@ -246,11 +279,17 @@ You run inside the Nomos daemon — a self-hosted, locally-running agent. You ar 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.test.ts b/src/daemon/agent-runtime.test.ts new file mode 100644 index 00000000..87dcc5f7 --- /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 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 653b36f5..3b052a18 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"; @@ -50,6 +51,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, @@ -465,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) => @@ -481,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) { @@ -510,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.", ); @@ -526,6 +548,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( + '- **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( "- **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.", @@ -715,9 +741,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 @@ -995,6 +1032,40 @@ 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 */ + } + } + + // 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. @@ -1034,6 +1105,16 @@ 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 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/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/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/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"); } diff --git a/src/memory/mood-log.test.ts b/src/memory/mood-log.test.ts new file mode 100644 index 00000000..aa019ca7 --- /dev/null +++ b/src/memory/mood-log.test.ts @@ -0,0 +1,105 @@ +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..7ff70835 --- /dev/null +++ b/src/memory/mood-log.ts @@ -0,0 +1,183 @@ +/** + * 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"); + } +} 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 }; +} 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") {