From 385a7f3e0311e0345e03f4278436d75f49f5185a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 May 2026 21:39:27 +0000 Subject: [PATCH 1/5] docs: add dreaming v2 architecture aligned with OpenClaw Document the full memory-core pipeline (Light, REM, Deep, diary report and best-effort narrative), budget gates, sandbox layout, and OpenClaw parity targets. Link from ARCHITECTURE.md. Co-authored-by: Tommaso --- docs/ARCHITECTURE.md | 2 +- docs/DREAMING.md | 538 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 docs/DREAMING.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 62af56ef..3debbf93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,7 +99,7 @@ run freely without making the agent memory filesystem executable. Event types: - `heartbeat` -- `dreaming` +- `dreaming` — see [DREAMING.md](./DREAMING.md) for the v2 memory-consolidation pipeline - `invocation` Event statuses: diff --git a/docs/DREAMING.md b/docs/DREAMING.md new file mode 100644 index 00000000..bc157c4a --- /dev/null +++ b/docs/DREAMING.md @@ -0,0 +1,538 @@ +# Dreaming architecture (v2) + +This document describes the target architecture for agent **dreaming**: offline +memory consolidation inspired by OpenClaw's `memory-core` pipeline, adapted to +Outname's Vercel Workflow + persistent sandbox model. + +For system context see [ARCHITECTURE.md](./ARCHITECTURE.md). + +## Product intent + +Dreaming is **not** a free-form agent work session. It is a **memory +governance** job that: + +1. Ingests recent evidence from the agent sandbox (and optionally durable event + summaries). +2. Stages and deduplicates candidates (**Light**). +3. Interprets patterns and assigns semantic metadata (**REM**, always LLM). +4. Promotes only verified, high-scoring facts into durable memory (**Deep**, + deterministic). +5. Appends a human-readable diary (**Diary**): structured report (TypeScript) plus + an OpenClaw-style **best-effort narrative** sub-pass (LLM). + +The sleep metaphor maps to **phases**, not to creative generation. Nothing in +`MEMORY.md` is authoritative because a model "felt it was true" during dreaming. + +## OpenClaw parity (target) + +This design aims to match OpenClaw `memory-core` behavior, adapted to Outname +infrastructure. + +| OpenClaw concept | Outname v2 | +| --- | --- | +| Opt-in dreaming | `dreamingEnabled` | +| Scheduled sweep (e.g. cron `0 3 * * *`) | Daily sweep + optional `dreamingScheduleCron` / local hour | +| Isolated maintenance (no user delivery) | `handleDreaming` — activity stream only, no chat stream | +| `memory/.dreams/*` scratch | Same paths | +| Light → REM → Deep order | Same; REM LLM required when sweep runs | +| Only Deep writes `MEMORY.md` | `memory-core/promotion` only | +| `DREAMS.md` diary, not canonical | Diary report + narrative; never promotes | +| Dream Diary narrative subagent (best-effort) | `DiaryNarrative` LLM step after report | +| Ranking + rehydration + markers | Same in Deep phase | +| No self-ingestion of dream blocks | `guards/managed-blocks.ts` | +| Session + daily ingestion | Logs in v2; session corpus in PR7 | + +## Core rules (non-negotiable) + +| Rule | Meaning | +| --- | --- | +| **Budget gate** | If preflight budget fails, **nothing runs** — no sandbox writes in `.dreams/`, no `MEMORY.md` append, no LLM calls. | +| **REM always** | When the sweep runs, **REM is mandatory** between Light and Deep. No deterministic-only REM fallback, no "Light + Deep" shortcut. | +| **REM failure = sweep failure** | If REM errors or returns invalid structured output, the event fails; Deep does not promote. | +| **Deep is deterministic** | Ranking, rehydration, fences, and `MEMORY.md` append are TypeScript only. | +| **`MEMORY.md` promotion** | Only the Deep phase (via `memory-core`) may append consolidated lines during a dreaming sweep. | +| **`DREAMS.md` is not canonical** | Diary (report + narrative) is for observability; it never authorizes promotion. | +| **REM LLM required** | One structured REM call when the sweep runs (semantic layer). | +| **Diary narrative best-effort** | Second LLM call for readable prose (OpenClaw Dream Diary); failure does not fail the sweep. | + +```mermaid +flowchart TD + E[dreaming agent_event] --> B{preflightBudget} + B -->|exceeded| STOP[Complete event, no sweep] + B -->|ok| L[Light - TypeScript] + L --> R[REM - LLM required] + R -->|fail| FAIL[event failed] + R -->|ok| D[Deep - TypeScript] + D --> DR[Diary report - TypeScript] + DR --> DN[Diary narrative - LLM best-effort] + DN -->|fail| OK[sweep still completed] + DN -->|ok| OK +``` + +## Current state vs target + +| Aspect | Today (v1) | Target (v2) | +| --- | --- | --- | +| Handler | `handleHeartbeat({ mode: 'dreaming' })` | `handleDreaming()` | +| Work | Full `ToolLoopAgent` stream with file tools | `memory-core` pipeline steps | +| Memory writes | LLM may edit `DREAMS.md`, `GOALS.md`, `TASKS.md`, logs | Only `memory-core` writes `.dreams/` and promotes `MEMORY.md`; diary updates `DREAMS.md` | +| Semantics | Entire pass is LLM judgment | REM only; Deep verifies against live files | +| Budget | Preflight skip; marks day done | Preflight skip; **no sweep side effects** (see scheduling note below) | + +## Runtime placement + +Dreaming remains a durable **`agent_events`** row of type `dreaming`, started by +`agentEventWorkflow` — same orchestration as heartbeat and invocation. + +``` +app/api/cron/liveness/route.ts + → runAgentEventScheduler() + → enqueueAgentEvent({ type: 'dreaming' }) + +agent-runtime/workflows/events/workflow.ts + → handleDreaming() # replaces handleHeartbeat for dreaming + +agent-runtime/memory-core/ + → sweep orchestrator + phases + promotion +``` + +Realtime chat is unchanged: it does not run dreaming and does not use +`memory-core`. + +## Sandbox layout + +All paths are under the persistent system sandbox root (`/vercel/sandbox`). + +| Path | Role | Written by | +| --- | --- | --- | +| `memory/.dreams/short-term-recall.json` | Candidate store (snippet, source, counters, tags, scores) | Light (upsert), REM (metadata) | +| `memory/.dreams/phase-signals.json` | Decaying boosts from Light/REM for Deep ranking | Light, REM | +| `memory/.dreams/daily-ingestion.json` | Checkpoint for processed `logs/*.md` | Light | +| `memory/.dreams/session-ingestion.json` | Checkpoint for exported session/event corpus | Light (phase 2) | +| `memory/.dreams/session-corpus/` | Compact text exports for non-log evidence | Light (phase 2) | +| `memory/.dreams/sweep-manifest.json` | Last sweep status, phase stats, errors | Sweep | +| `DREAMS.md` | Dated diary / report (non-canonical) | Diary | +| `MEMORY.md` | Durable consolidated memory | Deep only (append + markers) | +| `logs/YYYY-MM-DD.md` | Daily evidence (read by Light; not rewritten by sweep) | Agent during normal events | + +### Path guards + +Extend `sandbox-file-helpers/paths.ts`: + +- Agent `writeFile` **must reject** `memory/.dreams/**` (scratch is runtime-only). +- During an active dreaming sweep, agent tools must not append `MEMORY.md` (Deep + uses direct sandbox IO). + +Tracked architecture listing should include `memory/.dreams/sweep-manifest.json` +and `DREAMS.md` for the UI; scratch JSON files can stay hidden or under a +`.dreams` UI filter. + +## Phase specifications + +### Light (TypeScript) + +**Purpose:** Staging and deduplication — "tidy the desk." + +**Inputs:** + +- `logs/*.md` within `dreamingLookbackDays` (default 7), respecting + `daily-ingestion.json`. +- Managed dreaming blocks stripped before ingest (prevent self-ingestion). + +**Actions:** + +1. List and read new/changed log files (respect `MAX_READ_FILE_BYTES`). +2. Extract line-level snippets (bullets, notable lines). +3. Normalize text; compute stable `candidateId`. +4. Upsert `short-term-recall.json` (increment `recallCount`, merge + `queryContexts`, update timestamps). +5. Emit weak Light phase signals for repeat appearances. +6. Update `daily-ingestion.json`. + +**Does not:** call LLM; write `MEMORY.md` or narrative `DREAMS.md`. + +### REM (LLM, required) + +**Purpose:** Semantic interpretation — themes, relevance, candidate reinforcement. + +**Inputs:** + +- `short-term-recall.json` (active candidates). +- `phase-signals.json`. +- Optional compact summaries of top snippets (token-capped). + +**Actions:** + +1. Single structured LLM call (or fixed small sequence) with **JSON schema** + output, low temperature. +2. For each candidate (or batch): set/update `conceptTags`, `relevance` (0–1), + optional `reflection` text, `lastingTruthCandidate` flag. +3. Append REM entries to `phase-signals.json` with decay metadata. +4. Persist updated recall store. + +**Does not:** write `MEMORY.md`; append diary prose (that is Diary). + +**On failure:** throw → workflow event `failed` → no Deep, no promotions. + +REM is the **only** phase that interprets natural-language meaning. Downstream +code treats REM output as **untrusted hints** until Deep rehydrates sources. + +### Deep (TypeScript) + +**Purpose:** Authorize durable promotion. + +**Inputs:** + +- Recall store + active phase signals. +- Agent config: `dreamingPromotionThreshold`, `dreamingMaxPromotionsPerSweep`. + +**Ranking** (weights aligned with OpenClaw defaults, configurable later): + +| Signal | Default weight | +| --- | --- | +| frequency | 0.24 | +| relevance | 0.30 | +| queryDiversity | 0.15 | +| recency | 0.15 | +| consolidation | 0.10 | +| conceptualRichness | 0.06 | +| phaseBoost | capped (decayed signals from Light/REM) | + +**Filters before promotion:** + +- Already promoted (marker present). +- Score below threshold. +- Source missing or rehydration mismatch. +- Snippet inside managed dreaming fence. +- Insufficient context diversity (anti-noise). + +**Promotion steps:** + +1. `rehydrate(sourceRef)` — read live file, extract line range, compare to + stored snippet. +2. Append to `MEMORY.md` with HTML marker: + `` +3. Mark candidate `promoted: true` in recall store. + +**Does not:** call LLM. + +### Diary (report + narrative, OpenClaw-aligned) + +Diary runs **only after** REM and Deep succeed. It has two sub-steps with +different contracts. + +#### Diary report (TypeScript, always) + +**Purpose:** Structured audit trail in `DREAMS.md` (same role as OpenClaw phase +reports / inline Light·REM·Deep summaries). + +**Actions:** + +1. Append a dated section from `sweep-manifest.json`, recall store, REM JSON, + and promotion results. +2. Include phase stats, top themes (aggregated REM tags), REM reflection bullets, + promoted lines with `sourceRef`, rejection counts. + +**Does not:** call LLM; write `MEMORY.md`; run if REM/Deep did not complete. + +**Template sketch:** + +```markdown +## Dream 2026-05-22 (completed) + +### Summary +- Ingested 12 log lines → 8 candidates +- REM updated 8 · Deep promoted 2 + +### Themes (REM) +- digest, brevity, slack + +### Reflections (REM) +- …from REM JSON… + +### Promoted to MEMORY.md +- [abc123] logs/2026-05-20.md:8 — …snippet… + +### Skipped promotion +- 3 below threshold · 1 rehydration failed +``` + +#### Diary narrative (LLM, best-effort — OpenClaw Dream Diary) + +**Purpose:** Short readable narrative for the Memory · Dreaming UI and human +review, matching OpenClaw’s “subagent best-effort” diary entry. + +**When it runs:** + +- After the report section is written. +- Only if `dreamingDiaryNarrativeEnabled` is true (default **on** for OpenClaw + parity). +- Only if there is enough sweep material (e.g. ≥1 REM-updated candidate or ≥1 + promotion — same “enough material” idea as OpenClaw). + +**Inputs (read-only, no new evidence):** + +- `sweep-manifest.json` +- REM reflections / themes (structured) +- Promotion list from Deep (grounded lines only) +- **Not** raw logs (narrative must not introduce facts absent from report/REM/Deep) + +**Actions:** + +1. One bounded LLM call (low temperature, token cap). +2. Append under a `### Dream diary` (or `### Narrative`) heading in `DREAMS.md`. +3. Record usage as `sourceType: 'dreaming'`, sub-source `diary_narrative`. + +**On failure (timeout, parse, budget after REM, provider error):** + +- Log in `sweep-manifest.phases.diary.narrative: skipped | failed`. +- Emit activity: `Dream diary narrative skipped`. +- **Sweep status remains `completed`** — unlike REM. + +**Does not:** promote to `MEMORY.md`; override REM/Deep decisions; re-ingest as +evidence in future Light passes (narrative blocks are managed / stripped). + +**Why a second LLM if REM already reflects?** + +OpenClaw separates **consolidation** (REM → Deep) from **explainability** +(narrative diary). REM output is machine-oriented JSON; the diary narrative is +human-oriented prose — same split we adopt for parity. + +## Data models (sketch) + +```typescript +interface RecallCandidate { + id: string + sourceRef: string // "logs/2026-05-21.md:14" | "session:evt_…" + snippet: string + conceptTags: string[] + recallCount: number + firstSeenAt: string + lastSeenAt: string + queryContexts: string[] + relevance: number // 0..1, set by REM + lastingTruthCandidate: boolean + promoted: boolean + promotionMarker?: string +} + +interface PhaseSignal { + candidateId: string + phase: 'light' | 'rem' + boost: number + decayAfter: string + reason: string +} + +interface SweepManifest { + localDate: string + startedAt: string + completedAt?: string + status: 'running' | 'completed' | 'failed' + phases: { + light: { ingested: number; candidates: number } + rem: { model: string; updated: number } + deep: { promoted: number; rejected: number } + diary: { + reportWritten: boolean + narrative: 'written' | 'skipped' | 'failed' | 'not_applicable' + narrativeError?: string + } + } + error?: string +} +``` + +## Budget integration + +### Start gate (nothing runs) + +Dreaming uses **`preflightBudget`** before any sweep work — same as heartbeat +today. + +```typescript +// handleDreaming (pseudocode) +const userId = await checkBudgetOrFinalize({ agentId, mode: 'dreaming', runId }) +if (userId === BUDGET_EXCEEDED) { + await markDreamingSkippedNoSweep({ agentId, localDate }) + return +} +await runDreamingSweepStep({ agentId, localDate, userId }) +``` + +**Invariant:** if preflight fails, **no** Light, REM, Deep, or Diary — zero +sandbox side effects. + +Preflight must ensure there is headroom for at least the **REM** estimate +(configured token caps × model cost). That is the minimum bar to start. + +### During sweep (REM vs narrative) + +| Call | Budget contract | +| --- | --- | +| **REM** | Required. Failure → sweep **failed**. | +| **Diary narrative** | Best-effort. Before calling, optional `preflightBudget` (or spend check) with a small **narrative reserve** estimate. If over limit after REM spend → skip narrative, sweep **completed**. | + +This preserves your rule (**no budget → nothing**) while matching OpenClaw (**narrative diary is best-effort**, not a promotion gate). + +Token usage: both REM and diary narrative use `sourceType: 'dreaming'` (narrative +tagged in metadata for analytics). + +### Scheduling when budget-blocked + +When the sweep does not run due to budget: + +- Do **not** update `lastDreamingLocalDate` (scheduler may enqueue again on a + later cron tick the same local day once budget is available). +- Contrast with v1, which marked the day complete on budget skip — v2 intentionally + retries. + +When the sweep **fails** (REM/Deep error): + +- Do not update `lastDreamingLocalDate` (retry eligible). +- Persist failure on `agent_events.last_error` and `sweep-manifest.json` if + partially written (manifest should use running → failed atomically per phase + where possible). + +When the sweep **completes**: + +- Update `lastDreamingAt`, `lastDreamingLocalDate` as today. + +## Scheduler + +Keep **once per owner local calendar day** unless `dreamingScheduleHour` is set +(future migration): due when `lastDreamingLocalDate !== today` and (optional) +local hour ≥ configured hour. + +Cron ingress unchanged: `/api/cron/liveness` every five minutes. + +Manual **Dream now** enqueues the same pipeline with `manual: true` and a fresh +idempotency key (no concurrency queue). + +## Workflow steps + +Each heavy phase is a Vercel Workflow **`'use step'`** shim (sandbox and DB are +unavailable inside pure workflow functions): + +| Step | Calls | +| --- | --- | +| `runDreamingSweepStep` | `memory-core/sweep.ts` | +| `runLightPhaseStep` | `phases/light.ts` | +| `runRemPhaseStep` | `phases/rem.ts` + AI Gateway | +| `runDeepPhaseStep` | `phases/deep.ts` | +| `runDiaryReportStep` | `phases/diary-report.ts` | +| `runDiaryNarrativeStep` | `phases/diary-narrative.ts` (LLM, best-effort) | + +Activity stream (`emitActivity`) reports phase boundaries for the event UI; no +full model stream to the user for scheduled dreaming. + +## Session evidence (phase 2) + +v1 ingestion is **`logs/*.md` only**. OpenClaw also ingests session transcripts. + +Phase 2 adds `ingestion/session-events.ts`: + +- Export completed `agent_events` since last checkpoint into + `session-corpus/{eventId}.txt`. +- Light treats `sourceRef: session:…` like log lines. + +Realtime `chat_message` history is out of scope for v1/v2 unless explicitly +added later (PII/retention policy required). + +## Agent configuration + +| Field | Purpose | +| --- | --- | +| `dreamingEnabled` | Existing toggle | +| `dreamingLookbackDays` | Light window (default 7) | +| `dreamingPromotionThreshold` | Deep cutoff (default 0.62) | +| `dreamingMaxPromotionsPerSweep` | Cap promotions (default 5) | +| `dreamingDiaryNarrativeEnabled` | Default `true` (OpenClaw parity); set `false` to skip narrative LLM | +| `dreamingScheduleCron` | Optional cron expression (OpenClaw-style, e.g. `0 3 * * *`); else once per local day on first scheduler tick | +| `dreamingRemMaxOutputTokens` / `dreamingNarrativeMaxOutputTokens` | Caps for cost estimates and call limits | + +Models: default to agent model; optional `dreamingRemModel` / `dreamingNarrativeModel` +(cheaper model for narrative is allowed). + +## AGENTS.md and prompts + +Update `agents-md-template.ts` **Dreaming behavior** section: + +- Dreaming is automatic; agents do not run a manual "dreaming pass" during chat. +- Do not write `memory/.dreams/**`. +- Do not promote into `MEMORY.md` during dreaming events; consolidation is + runtime-owned. +- `DREAMS.md` is written by the system diary step (report + optional narrative). +- Narrative diary text is not evidence and must not be cited for promotion. + +Remove `buildDreamingKickoff` multi-step LLM instructions from the dreaming +path. `compose-system-prompt.ts` `eventKind: 'dreaming'` may shrink to a short +note for any edge case that still routes through agent tools (should not happen +for scheduled events). + +## Security and contamination + +| Safeguard | Implementation | +| --- | --- | +| No self-ingestion | Strip `` and managed report blocks before Light ingest | +| Rehydration | Deep reads live source; promotion text must match | +| Fence check | Reject snippets inside dreaming-managed fences | +| Promotion markers | Prevent duplicate `MEMORY.md` appends | +| Scratch isolation | `.dreams/` not agent-writable | +| Budget gate | No work, no writes when preflight fails | + +## Testing strategy + +| Layer | Focus | +| --- | --- | +| Unit | ranking, rehydrate, managed-block strip, dedup ids | +| Integration | fixture sandbox dir → sweep → expected `MEMORY.md` markers | +| Workflow | `dreaming` event dispatches `handleDreaming`, budget skip invokes zero steps | + +## Rollout + +1. Feature flag `dreamingPipelineV2` per agent or environment. +2. Shadow mode (optional): run sweep, write manifest + `.dreams/`, **do not** + append `MEMORY.md` until validated. +3. Enable promotion; disable v1 LLM dreaming path. +4. Remove `handleHeartbeat` dreaming branch and `buildDreamingKickoff`. + +## Implementation sequence + +| PR | Scope | +| --- | --- | +| 1 | `memory-core` types, IO, guards, ranking, rehydrate, promote | +| 2 | Light + log ingestion + tests | +| 3 | REM structured LLM + phase signals + tests | +| 4 | Deep + `MEMORY.md` append | +| 5 | `handleDreaming`, workflow wiring, budget/scheduler semantics, remove v1 LLM sweep | +| 6 | Diary report + diary narrative (best-effort) + `DREAMS.md` + UI copy + AGENTS template | +| 7 | Session ingestion + optional schedule hour | + +## Mental model (FAQ) + +**Why call it "deterministic" if REM uses an LLM?** + +The **promotion law** is deterministic: fixed ranking, thresholds, source +verification, and single writer to `MEMORY.md`. REM supplies **structured hints** +(relevance, tags); it does not directly decide durable memory. + +**What if budget is zero?** + +Nothing starts. The agent does not "partially dream." + +**What if budget exists?** + +Light → REM → Deep always runs REM. No shortcuts. + +**How many LLM calls?** + +Up to two per sweep: **REM** (required) and **diary narrative** (best-effort). +Light and Deep are TypeScript. The narrative does not replace REM; it explains +the sweep for humans without touching `MEMORY.md`. + +**What if narrative is skipped?** + +The structured report is always present when the sweep completes; UI still shows +themes, promotions, and REM reflections. OpenClaw treats narrative as best-effort +for the same reason. From 327c193eb1d9654300e84bb4066a5fdecc0b6f31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 May 2026 22:00:32 +0000 Subject: [PATCH 2/5] docs: remove gradual rollout; single cutover for dreaming v2 Co-authored-by: Tommaso --- docs/DREAMING.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/DREAMING.md b/docs/DREAMING.md index bc157c4a..0d733dd3 100644 --- a/docs/DREAMING.md +++ b/docs/DREAMING.md @@ -489,13 +489,17 @@ for scheduled events). | Integration | fixture sandbox dir → sweep → expected `MEMORY.md` markers | | Workflow | `dreaming` event dispatches `handleDreaming`, budget skip invokes zero steps | -## Rollout +## Cutover -1. Feature flag `dreamingPipelineV2` per agent or environment. -2. Shadow mode (optional): run sweep, write manifest + `.dreams/`, **do not** - append `MEMORY.md` until validated. -3. Enable promotion; disable v1 LLM dreaming path. -4. Remove `handleHeartbeat` dreaming branch and `buildDreamingKickoff`. +No feature flags or shadow mode. When `memory-core` ships, it **replaces** v1 in +one release: + +- `workflow.ts` dispatches `handleDreaming` only (remove `handleHeartbeat` dreaming branch). +- Delete `buildDreamingKickoff` and LLM-based dreaming instructions from the agent path. +- Scheduled and manual **Dream now** both run the new sweep. + +Existing `DREAMS.md` / `MEMORY.md` content is left as-is; no retroactive promotion +from old diary entries. ## Implementation sequence @@ -505,7 +509,7 @@ for scheduled events). | 2 | Light + log ingestion + tests | | 3 | REM structured LLM + phase signals + tests | | 4 | Deep + `MEMORY.md` append | -| 5 | `handleDreaming`, workflow wiring, budget/scheduler semantics, remove v1 LLM sweep | +| 5 | `handleDreaming`, workflow wiring, budget/scheduler semantics, delete v1 dreaming path | | 6 | Diary report + diary narrative (best-effort) + `DREAMS.md` + UI copy + AGENTS template | | 7 | Session ingestion + optional schedule hour | From c289652929f5efaf67eea221c88bfdc2f130b655 Mon Sep 17 00:00:00 2001 From: Tommaso <65722261+TommasoTate@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:40:11 +0200 Subject: [PATCH 3/5] docs: define dreaming v2 governance architecture --- CONTEXT.md | 95 ++++ docs/DREAMING.md | 429 ++++++++++++------ .../adr/0005-dreaming-v2-memory-governance.md | 44 ++ 3 files changed, 436 insertions(+), 132 deletions(-) create mode 100644 docs/adr/0005-dreaming-v2-memory-governance.md diff --git a/CONTEXT.md b/CONTEXT.md index 1e8694e9..fa5f5dc5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -59,6 +59,101 @@ A user-defined operational guardrail for limiting agent spend; it is not a billing ledger or financial guarantee. _Avoid_: Billing, invoice, charge +**Budget Gate**: +The pre-run stop condition that prevents a Dreaming run from making any state or +file changes when the agent is budget-blocked. +_Avoid_: LLM-only budget check, partial deterministic run + +**Budget-Skipped Dreaming**: +A Dreaming event that completes successfully with a `budget_skipped` outcome +without running a sweep or marking the local day as completed. +_Avoid_: Failed dream, cancelled dream, completed sweep + +**Failed Dreaming Sweep**: +A Dreaming event whose required Light, REM, or Deep phase failed; it records the +error and remains eligible for retry because the local day is not completed. +_Avoid_: Budget skip, narrative failure, completed dream + +**Dreaming Retry Idempotence**: +The rule that a failed Dreaming sweep retries as a new attempt over accumulated +Dreaming Store evidence without globally rolling back prior idempotent state. +_Avoid_: Global rollback, duplicate promotion, blind replay + +**Dreaming**: +An offline agent memory governance run that consolidates recent evidence without +delivering a user-facing work session. +_Avoid_: Free-form agent session, heartbeat + +**OpenClaw-Inspired Design**: +An Outname-owned Dreaming design that borrows architectural ideas from OpenClaw +without adopting OpenClaw artifact compatibility, marker strings, or file +contracts. +_Avoid_: OpenClaw parity, OpenClaw-compatible implementation, copied behavior + +**Dreaming Phase Names**: +The canonical Outname phase labels `Light`, `REM`, and `Deep`; they are generic +sleep metaphors whose behavior is defined by Outname's Dreaming contract. +_Avoid_: Upstream-compatible phase contract, renamed phases + +**Dreaming Store**: +The runtime-owned scratch state for a Dreaming run, including recall candidates, +phase signals, ingestion checkpoints, sweep manifests, and locks. +_Avoid_: JSON files, SQLite API + +**Dreaming Debug Export**: +An opt-in diagnostic file derived from Dreaming Store state; it is not required +for normal runtime behavior or the user-facing Dreaming UI. +_Avoid_: Primary manifest, required JSON export + +**Dreaming Run Lock**: +The per-agent serialization guard that prevents scheduled and manual Dreaming +runs from executing concurrently. +_Avoid_: Idempotency key, manual bypass + +**Dream Now Idempotence**: +The UI/runtime contract that a manual Dreaming trigger returns an existing +active or queued Dreaming run instead of creating additional queued work. +_Avoid_: Manual queue, duplicate dream + +**REM Phase**: +A rule-governed Dreaming phase that reinforces staged memory candidates with +pattern metadata before durable promotion. +_Avoid_: REM LLM, semantic generation + +**Dream Diary**: +A human-readable, non-canonical Dreaming output for review and observability. +_Avoid_: Durable memory, source of truth + +**Cumulative Dream Diary**: +The single `DREAMS.md` file that accumulates dated Dreaming reports and optional +narrative entries. +_Avoid_: Per-phase report files, per-day diary files + +**Tool-less Diary Agent**: +The Dream Diary narrative generator that uses the agent abstraction without +sandbox, file, or provider tools; runtime code owns all writes. +_Avoid_: File-editing diary agent, narrative tool loop + +**Durable Promotion**: +A Deep-authorized append to durable agent memory after scoring, diversity checks, +and source rehydration. +_Avoid_: Memory write, diary insight, REM decision + +**Outname Promotion Marker**: +The application-owned HTML marker used to identify durable promotions in +`MEMORY.md` and prevent duplicate appends. +_Avoid_: Upstream-compatible marker, copied marker string + +**Event Transcript Evidence**: +Completed agent event transcript content consumed transiently by Dreaming to +stage memory candidates without duplicating transcripts into the sandbox. +_Avoid_: Session evidence, sandbox session corpus, raw chat history + +**Bounded Evidence Ingestion**: +The Dreaming rule that evidence sources are read through explicit caps before +candidate extraction, so oversized inputs are truncated or deferred. +_Avoid_: Full transcript copy, unbounded ingestion + **Agent Skill**: A user-installed capability package that teaches an agent a specialized workflow and may include supporting files or executable scripts. diff --git a/docs/DREAMING.md b/docs/DREAMING.md index 0d733dd3..ac377d13 100644 --- a/docs/DREAMING.md +++ b/docs/DREAMING.md @@ -4,7 +4,9 @@ This document describes the target architecture for agent **dreaming**: offline memory consolidation inspired by OpenClaw's `memory-core` pipeline, adapted to Outname's Vercel Workflow + persistent sandbox model. -For system context see [ARCHITECTURE.md](./ARCHITECTURE.md). +For system context see [ARCHITECTURE.md](./ARCHITECTURE.md). For the decision +record see +[ADR 0005](./adr/0005-dreaming-v2-memory-governance.md). ## Product intent @@ -14,57 +16,67 @@ governance** job that: 1. Ingests recent evidence from the agent sandbox (and optionally durable event summaries). 2. Stages and deduplicates candidates (**Light**). -3. Interprets patterns and assigns semantic metadata (**REM**, always LLM). +3. Reinforces recurring patterns and assigns semantic metadata (**REM**, + deterministic). 4. Promotes only verified, high-scoring facts into durable memory (**Deep**, deterministic). 5. Appends a human-readable diary (**Diary**): structured report (TypeScript) plus - an OpenClaw-style **best-effort narrative** sub-pass (LLM). + a **best-effort narrative** sub-pass (LLM). The sleep metaphor maps to **phases**, not to creative generation. Nothing in `MEMORY.md` is authoritative because a model "felt it was true" during dreaming. -## OpenClaw parity (target) +## OpenClaw-Inspired Design -This design aims to match OpenClaw `memory-core` behavior, adapted to Outname -infrastructure. +This design uses OpenClaw `memory-core` as architectural inspiration, then +defines Outname-owned runtime contracts, storage, markers, and UI behavior. It +does not aim for wire compatibility with OpenClaw artifacts or marker formats. +The phase names **Light**, **REM**, and **Deep** are retained as generic sleep +metaphors, but their behavior is defined by this Outname document. -| OpenClaw concept | Outname v2 | +| Inspiration | Outname v2 decision | | --- | --- | | Opt-in dreaming | `dreamingEnabled` | | Scheduled sweep (e.g. cron `0 3 * * *`) | Daily sweep + optional `dreamingScheduleCron` / local hour | | Isolated maintenance (no user delivery) | `handleDreaming` — activity stream only, no chat stream | -| `memory/.dreams/*` scratch | Same paths | -| Light → REM → Deep order | Same; REM LLM required when sweep runs | -| Only Deep writes `MEMORY.md` | `memory-core/promotion` only | +| `memory/.dreams/*` scratch | Outname namespace with primary state in sandbox-backed SQLite | +| Light → REM → Deep order | Adopted phase order; REM is the required deterministic middle phase | +| Only Deep writes `MEMORY.md` | Outname `memory-core/promotion` only | | `DREAMS.md` diary, not canonical | Diary report + narrative; never promotes | -| Dream Diary narrative subagent (best-effort) | `DiaryNarrative` LLM step after report | -| Ranking + rehydration + markers | Same in Deep phase | +| Dream Diary narrative subagent (best-effort) | Tool-less `DiaryNarrative` ToolLoopAgent LLM step after report | +| Ranking + rehydration + markers | Similar safety behavior; Outname-owned marker format | | No self-ingestion of dream blocks | `guards/managed-blocks.ts` | -| Session + daily ingestion | Logs in v2; session corpus in PR7 | +| Event transcript + daily ingestion | Adopted evidence mix; event transcripts are read transiently, not persisted in sandbox | ## Core rules (non-negotiable) | Rule | Meaning | | --- | --- | -| **Budget gate** | If preflight budget fails, **nothing runs** — no sandbox writes in `.dreams/`, no `MEMORY.md` append, no LLM calls. | -| **REM always** | When the sweep runs, **REM is mandatory** between Light and Deep. No deterministic-only REM fallback, no "Light + Deep" shortcut. | -| **REM failure = sweep failure** | If REM errors or returns invalid structured output, the event fails; Deep does not promote. | +| **Budget gate** | If preflight budget fails, **nothing runs** — no `DreamingStore` writes, no sandbox writes, no `MEMORY.md` append, no `DREAMS.md` update, no LLM calls. | +| **Outname phase names** | Light, REM, and Deep remain the canonical phase names; they do not imply upstream compatibility. | +| **REM always** | When the sweep runs, **REM is mandatory** between Light and Deep. No "Light + Deep" shortcut. | +| **REM failure = sweep failure** | If REM errors or produces inconsistent phase state, the event fails; Deep does not promote. | +| **Required phase failure = event failure** | If Light, REM, or Deep fails, the `dreaming` event is `failed`; the local day is not marked complete. | | **Deep is deterministic** | Ranking, rehydration, fences, and `MEMORY.md` append are TypeScript only. | | **`MEMORY.md` promotion** | Only the Deep phase (via `memory-core`) may append consolidated lines during a dreaming sweep. | +| **Outname promotion marker** | Durable promotions use an Outname-specific HTML marker, not an upstream-compatible marker string. | | **`DREAMS.md` is not canonical** | Diary (report + narrative) is for observability; it never authorizes promotion. | -| **REM LLM required** | One structured REM call when the sweep runs (semantic layer). | -| **Diary narrative best-effort** | Second LLM call for readable prose (OpenClaw Dream Diary); failure does not fail the sweep. | +| **REM is deterministic** | REM derives machine-oriented hints from staged evidence; it is not an LLM call. | +| **Diary narrative best-effort** | The only dreaming LLM call is readable diary prose; failure does not fail the sweep. | +| **Diary has no tools** | The narrative ToolLoopAgent receives prepared inputs and no sandbox/file tools; TypeScript appends accepted output to `DREAMS.md`. | +| **Single diary file** | `DREAMS.md` is the only default human-readable diary file; per-day/per-phase reports are debug/export only. | +| **One sweep per agent** | Scheduled and manual dreaming share the same per-agent concurrency key; no two sweeps may write the same `DreamingStore`, `DREAMS.md`, or `MEMORY.md` concurrently. | ```mermaid flowchart TD E[dreaming agent_event] --> B{preflightBudget} B -->|exceeded| STOP[Complete event, no sweep] B -->|ok| L[Light - TypeScript] - L --> R[REM - LLM required] + L --> R[REM - TypeScript] R -->|fail| FAIL[event failed] R -->|ok| D[Deep - TypeScript] D --> DR[Diary report - TypeScript] - DR --> DN[Diary narrative - LLM best-effort] + DR --> DN[Diary narrative - tool-less ToolLoopAgent LLM best-effort] DN -->|fail| OK[sweep still completed] DN -->|ok| OK ``` @@ -76,7 +88,7 @@ flowchart TD | Handler | `handleHeartbeat({ mode: 'dreaming' })` | `handleDreaming()` | | Work | Full `ToolLoopAgent` stream with file tools | `memory-core` pipeline steps | | Memory writes | LLM may edit `DREAMS.md`, `GOALS.md`, `TASKS.md`, logs | Only `memory-core` writes `.dreams/` and promotes `MEMORY.md`; diary updates `DREAMS.md` | -| Semantics | Entire pass is LLM judgment | REM only; Deep verifies against live files | +| Semantics | Entire pass is LLM judgment | Light, REM, and Deep are deterministic; Diary narrative is the only LLM pass | | Budget | Preflight skip; marks day done | Preflight skip; **no sweep side effects** (see scheduling note below) | ## Runtime placement @@ -105,13 +117,8 @@ All paths are under the persistent system sandbox root (`/vercel/sandbox`). | Path | Role | Written by | | --- | --- | --- | -| `memory/.dreams/short-term-recall.json` | Candidate store (snippet, source, counters, tags, scores) | Light (upsert), REM (metadata) | -| `memory/.dreams/phase-signals.json` | Decaying boosts from Light/REM for Deep ranking | Light, REM | -| `memory/.dreams/daily-ingestion.json` | Checkpoint for processed `logs/*.md` | Light | -| `memory/.dreams/session-ingestion.json` | Checkpoint for exported session/event corpus | Light (phase 2) | -| `memory/.dreams/session-corpus/` | Compact text exports for non-log evidence | Light (phase 2) | -| `memory/.dreams/sweep-manifest.json` | Last sweep status, phase stats, errors | Sweep | -| `DREAMS.md` | Dated diary / report (non-canonical) | Diary | +| `memory/.dreams/dreaming.sqlite` | Primary `DreamingStore`: recall candidates, phase signals, ingestion checkpoints, sweep manifests, locks | Sweep phases | +| `DREAMS.md` | Cumulative dated diary / report (non-canonical) | Diary | | `MEMORY.md` | Durable consolidated memory | Deep only (append + markers) | | `logs/YYYY-MM-DD.md` | Daily evidence (read by Light; not rewritten by sweep) | Agent during normal events | @@ -123,9 +130,16 @@ Extend `sandbox-file-helpers/paths.ts`: - During an active dreaming sweep, agent tools must not append `MEMORY.md` (Deep uses direct sandbox IO). -Tracked architecture listing should include `memory/.dreams/sweep-manifest.json` -and `DREAMS.md` for the UI; scratch JSON files can stay hidden or under a -`.dreams` UI filter. +Tracked architecture listing should include `DREAMS.md`. The SQLite store is +runtime scratch and should stay hidden behind a `.dreams` UI filter. + +`memory/.dreams/sweep-manifest.json` is not written by default. It may exist only +behind an explicit debug/export option; runtime behavior and normal UI must read +sweep state from `DreamingStore` or from the human-readable `DREAMS.md` report. + +Per-day or per-phase diary files are also not written by default. If needed for +diagnostics, they follow the same explicit debug/export rule as the manifest and +must not become evidence for future Light ingestion. ## Phase specifications @@ -135,47 +149,52 @@ and `DREAMS.md` for the UI; scratch JSON files can stay hidden or under a **Inputs:** -- `logs/*.md` within `dreamingLookbackDays` (default 7), respecting - `daily-ingestion.json`. +- `logs/*.md` within `dreamingLookbackDays` (default 7), respecting the + `DreamingStore` daily ingestion checkpoint. +- Completed `agent_events` transcripts since the event-transcript ingestion + checkpoint, read from the persisted transcript store and processed in memory. - Managed dreaming blocks stripped before ingest (prevent self-ingestion). **Actions:** -1. List and read new/changed log files (respect `MAX_READ_FILE_BYTES`). -2. Extract line-level snippets (bullets, notable lines). +1. List and read new/changed log files (respect `MAX_READ_FILE_BYTES`) and + completed event transcripts (respect per-event and per-sweep caps). +2. Extract line-level snippets from logs and compact transcript activity from + event transcripts. 3. Normalize text; compute stable `candidateId`. -4. Upsert `short-term-recall.json` (increment `recallCount`, merge - `queryContexts`, update timestamps). +4. Upsert recall candidates through `DreamingStore` (increment `recallCount`, + merge `queryContexts`, update timestamps). 5. Emit weak Light phase signals for repeat appearances. -6. Update `daily-ingestion.json`. +6. Update daily-log and event-transcript ingestion checkpoints. -**Does not:** call LLM; write `MEMORY.md` or narrative `DREAMS.md`. +**Does not:** call LLM; write `MEMORY.md`; write narrative `DREAMS.md`; persist +raw or compact transcript files in the sandbox. -### REM (LLM, required) +### REM (TypeScript, required) -**Purpose:** Semantic interpretation — themes, relevance, candidate reinforcement. +**Purpose:** Pattern reinforcement — themes, tags, and candidate signals derived +from staged evidence. **Inputs:** -- `short-term-recall.json` (active candidates). -- `phase-signals.json`. -- Optional compact summaries of top snippets (token-capped). +- Active candidates from `DreamingStore`. +- Existing phase signals from `DreamingStore`. +- Staged Light keys and source snippets. **Actions:** -1. Single structured LLM call (or fixed small sequence) with **JSON schema** - output, low temperature. -2. For each candidate (or batch): set/update `conceptTags`, `relevance` (0–1), - optional `reflection` text, `lastingTruthCandidate` flag. -3. Append REM entries to `phase-signals.json` with decay metadata. +1. Prefer Light-staged keys from the current sweep. +2. Derive/merge `conceptTags`, candidate reinforcement, and optional reflection + text from candidate evidence using deterministic rules. +3. Record REM phase signals with decay metadata. 4. Persist updated recall store. **Does not:** write `MEMORY.md`; append diary prose (that is Diary). **On failure:** throw → workflow event `failed` → no Deep, no promotions. -REM is the **only** phase that interprets natural-language meaning. Downstream -code treats REM output as **untrusted hints** until Deep rehydrates sources. +REM output is **untrusted hints** until Deep rehydrates sources. It reinforces +what should be considered; it does not authorize durable memory. ### Deep (TypeScript) @@ -184,9 +203,9 @@ code treats REM output as **untrusted hints** until Deep rehydrates sources. **Inputs:** - Recall store + active phase signals. -- Agent config: `dreamingPromotionThreshold`, `dreamingMaxPromotionsPerSweep`. +- Agent config: conservative Deep defaults. -**Ranking** (weights aligned with OpenClaw defaults, configurable later): +**Ranking** (conservative defaults, configurable later): | Signal | Default weight | | --- | --- | @@ -198,10 +217,25 @@ code treats REM output as **untrusted hints** until Deep rehydrates sources. | conceptualRichness | 0.06 | | phaseBoost | capped (decayed signals from Light/REM) | +**Default promotion policy** (conservative): + +| Setting | Default | +| --- | --- | +| `dreamingPromotionMinScore` | `0.8` | +| `dreamingPromotionMinRecallCount` | `3` | +| `dreamingPromotionMinUniqueQueries` | `3` | +| `dreamingMaxPromotionsPerSweep` | `10` | +| `dreamingPromotionMaxAgeDays` | `30` | +| `dreamingPromotionRecencyHalfLifeDays` | `14` | +| `dreamingMaxPromotedSnippetTokens` | `160` | + **Filters before promotion:** - Already promoted (marker present). - Score below threshold. +- Recall count below minimum. +- Unique query/context diversity below minimum. +- Candidate older than the promotion age window. - Source missing or rehydration mismatch. - Snippet inside managed dreaming fence. - Insufficient context diversity (anti-noise). @@ -210,28 +244,34 @@ code treats REM output as **untrusted hints** until Deep rehydrates sources. 1. `rehydrate(sourceRef)` — read live file, extract line range, compare to stored snippet. -2. Append to `MEMORY.md` with HTML marker: - `` +2. Append to `MEMORY.md` with an Outname-owned HTML marker: + `` 3. Mark candidate `promoted: true` in recall store. +Promotion markers are intentionally application-specific. The implementation +should not copy or emulate upstream marker strings; the marker is part of +Outname's memory file contract. + **Does not:** call LLM. -### Diary (report + narrative, OpenClaw-aligned) +### Diary (report + narrative) Diary runs **only after** REM and Deep succeed. It has two sub-steps with different contracts. #### Diary report (TypeScript, always) -**Purpose:** Structured audit trail in `DREAMS.md` (same role as OpenClaw phase -reports / inline Light·REM·Deep summaries). +**Purpose:** Structured audit trail in `DREAMS.md`, serving the Outname version +of a phase summary for Light, REM, and Deep. **Actions:** -1. Append a dated section from `sweep-manifest.json`, recall store, REM JSON, +1. Append a dated section from `DreamingStore` sweep state, recall store, REM metadata, and promotion results. 2. Include phase stats, top themes (aggregated REM tags), REM reflection bullets, promoted lines with `sourceRef`, rejection counts. +3. Keep the report in the cumulative `DREAMS.md`; do not create per-day or + per-phase report files during normal sweeps. **Does not:** call LLM; write `MEMORY.md`; run if REM/Deep did not complete. @@ -248,7 +288,7 @@ reports / inline Light·REM·Deep summaries). - digest, brevity, slack ### Reflections (REM) -- …from REM JSON… +- From REM metadata. ### Promoted to MEMORY.md - [abc123] logs/2026-05-20.md:8 — …snippet… @@ -257,60 +297,97 @@ reports / inline Light·REM·Deep summaries). - 3 below threshold · 1 rehydration failed ``` -#### Diary narrative (LLM, best-effort — OpenClaw Dream Diary) +#### Diary narrative (LLM, best-effort) **Purpose:** Short readable narrative for the Memory · Dreaming UI and human -review, matching OpenClaw’s “subagent best-effort” diary entry. +review. It follows the same product split as the rest of the design: +consolidation is deterministic, explanation is best-effort prose. + +The narrative uses `ToolLoopAgent` to preserve the agent abstraction, but it is a +**tool-less** agent: no `buildRuntimeToolset()`, no sandbox file tools, no +network/tool providers, and no direct writes. **When it runs:** - After the report section is written. -- Only if `dreamingDiaryNarrativeEnabled` is true (default **on** for OpenClaw - parity). +- Only if `dreamingDiaryNarrativeEnabled` is true (default **on** for the + inspired design). - Only if there is enough sweep material (e.g. ≥1 REM-updated candidate or ≥1 - promotion — same “enough material” idea as OpenClaw). + promotion). **Inputs (read-only, no new evidence):** -- `sweep-manifest.json` -- REM reflections / themes (structured) -- Promotion list from Deep (grounded lines only) +- A bounded runtime-prepared payload from `DreamingStore` sweep state. +- REM reflections / themes (structured). +- Promotion list from Deep (grounded lines only). - **Not** raw logs (narrative must not introduce facts absent from report/REM/Deep) **Actions:** -1. One bounded LLM call (low temperature, token cap). -2. Append under a `### Dream diary` (or `### Narrative`) heading in `DREAMS.md`. -3. Record usage as `sourceType: 'dreaming'`, sub-source `diary_narrative`. +1. Instantiate `DiaryNarrative` as a `ToolLoopAgent` with no tools and a bounded + prompt payload. +2. Produce a short narrative string or structured narrative object. +3. Validate and cap the returned narrative in TypeScript. +4. Append accepted output under a `### Dream diary` (or `### Narrative`) heading + in `DREAMS.md` from the Diary runtime step, not from the model. +5. Record usage as `sourceType: 'dreaming'`, sub-source `diary_narrative`. **On failure (timeout, parse, budget after REM, provider error):** -- Log in `sweep-manifest.phases.diary.narrative: skipped | failed`. +- Log in the sweep state: `phases.diary.narrative: skipped | failed`. - Emit activity: `Dream diary narrative skipped`. - **Sweep status remains `completed`** — unlike REM. -**Does not:** promote to `MEMORY.md`; override REM/Deep decisions; re-ingest as -evidence in future Light passes (narrative blocks are managed / stripped). +**Does not:** promote to `MEMORY.md`; override REM/Deep decisions; become +evidence for future Light passes; call tools; read files; write files. Narrative +blocks are managed / stripped. -**Why a second LLM if REM already reflects?** +**Why use an LLM only for narrative?** -OpenClaw separates **consolidation** (REM → Deep) from **explainability** -(narrative diary). REM output is machine-oriented JSON; the diary narrative is -human-oriented prose — same split we adopt for parity. +The source design separates deterministic **consolidation** (Light → REM → Deep) +from best-effort **explainability** (narrative diary). Outname adopts that split +while keeping its own storage, marker, and UI contracts. ## Data models (sketch) ```typescript +interface DreamingStore { + upsertRecallCandidate(candidate: RecallCandidate): Promise + listActiveRecallCandidates(input: RecallQuery): Promise + recordPhaseSignal(signal: PhaseSignal): Promise + updateIngestionCheckpoint(checkpoint: IngestionCheckpoint): Promise + beginSweep(input: BeginSweepInput): Promise + updateSweepManifest(manifest: SweepManifest): Promise +} + +interface BeginSweepInput { + localDate: string + startedAt: string + trigger: 'scheduled' | 'manual' +} + +interface RecallQuery { + lookbackDays: number + limit?: number + stagedKeys?: string[] +} + +interface IngestionCheckpoint { + source: 'daily-log' | 'event-transcript' + cursor: string + processedAt: string +} + interface RecallCandidate { id: string - sourceRef: string // "logs/2026-05-21.md:14" | "session:evt_…" + sourceRef: string // "logs/2026-05-21.md:14" | "event:evt_…" snippet: string conceptTags: string[] recallCount: number firstSeenAt: string lastSeenAt: string queryContexts: string[] - relevance: number // 0..1, set by REM + relevance: number // 0..1, computed by REM lastingTruthCandidate: boolean promoted: boolean promotionMarker?: string @@ -330,8 +407,13 @@ interface SweepManifest { completedAt?: string status: 'running' | 'completed' | 'failed' phases: { - light: { ingested: number; candidates: number } - rem: { model: string; updated: number } + light: { + ingested: number + candidates: number + transcriptEventsConsidered: number + transcriptEventsTruncated: number + } + rem: { considered: number; updated: number } deep: { promoted: number; rejected: number } diary: { reportWritten: boolean @@ -354,7 +436,7 @@ today. // handleDreaming (pseudocode) const userId = await checkBudgetOrFinalize({ agentId, mode: 'dreaming', runId }) if (userId === BUDGET_EXCEEDED) { - await markDreamingSkippedNoSweep({ agentId, localDate }) + await completeDreamingBudgetSkipped({ agentId, localDate, runId }) return } await runDreamingSweepStep({ agentId, localDate, userId }) @@ -363,36 +445,63 @@ await runDreamingSweepStep({ agentId, localDate, userId }) **Invariant:** if preflight fails, **no** Light, REM, Deep, or Diary — zero sandbox side effects. -Preflight must ensure there is headroom for at least the **REM** estimate -(configured token caps × model cost). That is the minimum bar to start. +Preflight checks whether the agent is budget-blocked before any sweep side +effects. Since Light, REM, and Deep do not call a model, starting the sweep does +not require reserving REM model spend. The gate is still total by design: budget +is an operational stop, not merely a model-spend check. + +On budget block, `handleDreaming` must not create or mutate +`memory/.dreams/dreaming.sqlite`, ingestion checkpoints, `DREAMS.md`, or +`MEMORY.md`. + +The `agent_events` row should still finish as `completed` with an explicit +outcome/metadata value such as `budget_skipped`. The workflow handled the event +successfully; it simply did not run a sweep. -### During sweep (REM vs narrative) +### During sweep (deterministic vs narrative) -| Call | Budget contract | +| Work | Budget contract | | --- | --- | -| **REM** | Required. Failure → sweep **failed**. | -| **Diary narrative** | Best-effort. Before calling, optional `preflightBudget` (or spend check) with a small **narrative reserve** estimate. If over limit after REM spend → skip narrative, sweep **completed**. | +| **Light / REM / Deep** | Required deterministic phases. Failure → sweep **failed**. | +| **Diary narrative** | Best-effort model call. Before calling, optional `preflightBudget` (or spend check) with a small **narrative reserve** estimate. If over limit after deterministic consolidation → skip narrative, sweep **completed**. | -This preserves your rule (**no budget → nothing**) while matching OpenClaw (**narrative diary is best-effort**, not a promotion gate). +This preserves your rule (**no budget → nothing**) while keeping the inspired +diary narrative best-effort rather than a promotion gate. -Token usage: both REM and diary narrative use `sourceType: 'dreaming'` (narrative -tagged in metadata for analytics). +Token usage: diary narrative uses `sourceType: 'dreaming'` with narrative +metadata for analytics. ### Scheduling when budget-blocked When the sweep does not run due to budget: +- Complete the event with outcome `budget_skipped`. - Do **not** update `lastDreamingLocalDate` (scheduler may enqueue again on a later cron tick the same local day once budget is available). - Contrast with v1, which marked the day complete on budget skip — v2 intentionally retries. -When the sweep **fails** (REM/Deep error): +When the sweep **fails** (Light/REM/Deep error): +- Mark the `agent_events` row `failed`. - Do not update `lastDreamingLocalDate` (retry eligible). -- Persist failure on `agent_events.last_error` and `sweep-manifest.json` if - partially written (manifest should use running → failed atomically per phase - where possible). +- Persist failure on `agent_events.last_error` and in `DreamingStore` if + partially written (manifest state should use running → failed atomically per + phase where possible). +- Do not run Diary report or Diary narrative after a required phase failure. + +Retry semantics after failure: + +- A retry starts a new sweep attempt with a new sweep manifest/attempt identity. +- Do **not** rollback the whole `DreamingStore`; recall candidates and phase + signals remain as accumulated evidence. +- Upserts must be idempotent by candidate key, source reference, and + query/context hash so repeated ingestion does not inflate counts incorrectly. +- Ingestion checkpoints advance only after the corresponding log/event evidence + has been considered within caps. If a phase fails before a checkpoint is safe, + the next attempt may re-read that evidence and rely on idempotent upserts. +- Deep remains protected by source rehydration and promotion markers in + `MEMORY.md`, so a retry cannot duplicate an already promoted memory line. When the sweep **completes**: @@ -407,7 +516,21 @@ local hour ≥ configured hour. Cron ingress unchanged: `/api/cron/liveness` every five minutes. Manual **Dream now** enqueues the same pipeline with `manual: true` and a fresh -idempotency key (no concurrency queue). +idempotency key, but it must use the same per-agent dreaming concurrency key as +scheduled dreaming. + +Idempotency and concurrency are intentionally separate: + +- Scheduled idempotency remains calendar-slot based, so cron retries are + deduplicated. +- Manual idempotency remains per click/request, so a user can request a fresh + run. +- Both sources share `dreaming:` (or equivalent) as the concurrency key, + so a manual run cannot overlap a scheduled run. +- If a sweep is already active, **Dream now** must not start a second sweep. It + returns the existing active or queued dreaming event for that agent. +- Manual **Dream now** is therefore UX-idempotent while a sweep is active: repeated + clicks point at the same event instead of creating additional queued work. ## Workflow steps @@ -418,23 +541,43 @@ unavailable inside pure workflow functions): | --- | --- | | `runDreamingSweepStep` | `memory-core/sweep.ts` | | `runLightPhaseStep` | `phases/light.ts` | -| `runRemPhaseStep` | `phases/rem.ts` + AI Gateway | +| `runRemPhaseStep` | `phases/rem.ts` deterministic metadata + phase signals | | `runDeepPhaseStep` | `phases/deep.ts` | | `runDiaryReportStep` | `phases/diary-report.ts` | -| `runDiaryNarrativeStep` | `phases/diary-narrative.ts` (LLM, best-effort) | +| `runDiaryNarrativeStep` | `phases/diary-narrative.ts` (tool-less ToolLoopAgent LLM, best-effort) | Activity stream (`emitActivity`) reports phase boundaries for the event UI; no full model stream to the user for scheduled dreaming. -## Session evidence (phase 2) +## Event transcript evidence -v1 ingestion is **`logs/*.md` only**. OpenClaw also ingests session transcripts. +Light ingests completed `agent_events` transcripts in the first release so the +memory pipeline sees both daily logs and actual work sessions. -Phase 2 adds `ingestion/session-events.ts`: +The transcript ingestion contract is intentionally non-persistent in the +sandbox: -- Export completed `agent_events` since last checkpoint into - `session-corpus/{eventId}.txt`. -- Light treats `sourceRef: session:…` like log lines. +- Query completed events since the `event-transcript` checkpoint. +- Read persisted `agent_event_message` rows from the database-backed transcript + store in `message_order`. +- Convert bounded transcript activity into candidate snippets in memory. +- Upsert only deduplicated recall candidates and checkpoint state into + `DreamingStore`. +- Do **not** write `session-corpus/`, raw transcript files, or compact transcript + exports under `/vercel/sandbox`. + +Transcript ingestion is also intentionally bounded: + +- Limit events per sweep, messages per event, text bytes per event, text bytes + per sweep, and snippets per event. +- If a single event exceeds per-event caps, extract only the bounded activity, + record a truncation counter in the sweep state, and mark that event processed. + Otherwise a single huge event would be retried forever. +- If the sweep-level cap is reached before reading the next event, stop and + advance the checkpoint only through the last event actually considered. Later + events remain eligible on the next sweep. +- Prefer activity/status/error text and assistant/user text parts; ignore binary + or non-text parts for memory candidacy. Realtime `chat_message` history is out of scope for v1/v2 unless explicitly added later (PII/retention policy required). @@ -445,14 +588,24 @@ added later (PII/retention policy required). | --- | --- | | `dreamingEnabled` | Existing toggle | | `dreamingLookbackDays` | Light window (default 7) | -| `dreamingPromotionThreshold` | Deep cutoff (default 0.62) | -| `dreamingMaxPromotionsPerSweep` | Cap promotions (default 5) | -| `dreamingDiaryNarrativeEnabled` | Default `true` (OpenClaw parity); set `false` to skip narrative LLM | -| `dreamingScheduleCron` | Optional cron expression (OpenClaw-style, e.g. `0 3 * * *`); else once per local day on first scheduler tick | -| `dreamingRemMaxOutputTokens` / `dreamingNarrativeMaxOutputTokens` | Caps for cost estimates and call limits | - -Models: default to agent model; optional `dreamingRemModel` / `dreamingNarrativeModel` -(cheaper model for narrative is allowed). +| `dreamingPromotionMinScore` | Deep cutoff (default `0.8`) | +| `dreamingPromotionMinRecallCount` | Minimum recall frequency for promotion (default `3`) | +| `dreamingPromotionMinUniqueQueries` | Minimum evidence diversity for promotion (default `3`) | +| `dreamingMaxPromotionsPerSweep` | Cap promotions (default `10`) | +| `dreamingPromotionMaxAgeDays` | Maximum candidate age for promotion (default `30`) | +| `dreamingPromotionRecencyHalfLifeDays` | Recency scoring half-life (default `14`) | +| `dreamingMaxPromotedSnippetTokens` | Maximum promoted snippet size (default `160`) | +| `dreamingMaxTranscriptEventsPerSweep` | Cap completed events scanned by Light | +| `dreamingMaxTranscriptMessagesPerEvent` | Cap transcript rows read per event | +| `dreamingMaxTranscriptBytesPerEvent` | Cap extracted transcript text per event | +| `dreamingMaxTranscriptBytesPerSweep` | Aggregate transcript text cap per sweep | +| `dreamingMaxTranscriptSnippetsPerEvent` | Cap candidate snippets produced by one event | +| `dreamingDiaryNarrativeEnabled` | Default `true` for the inspired design; set `false` to skip narrative LLM | +| `dreamingScheduleCron` | Optional cron expression (e.g. `0 3 * * *`); else once per local day on first scheduler tick | +| `dreamingNarrativeMaxOutputTokens` | Cap for narrative cost estimates and call limits | + +Models: the narrative defaults to the agent model; optional +`dreamingNarrativeModel` can choose a cheaper model for diary prose. ## AGENTS.md and prompts @@ -464,6 +617,8 @@ Update `agents-md-template.ts` **Dreaming behavior** section: runtime-owned. - `DREAMS.md` is written by the system diary step (report + optional narrative). - Narrative diary text is not evidence and must not be cited for promotion. +- Diary narrative uses a tool-less `ToolLoopAgent`; agents do not receive file + tools or edit `DREAMS.md` directly. Remove `buildDreamingKickoff` multi-step LLM instructions from the dreaming path. `compose-system-prompt.ts` `eventKind: 'dreaming'` may shrink to a short @@ -477,17 +632,23 @@ for scheduled events). | No self-ingestion | Strip `` and managed report blocks before Light ingest | | Rehydration | Deep reads live source; promotion text must match | | Fence check | Reject snippets inside dreaming-managed fences | -| Promotion markers | Prevent duplicate `MEMORY.md` appends | +| Promotion markers | Use Outname-owned markers to prevent duplicate `MEMORY.md` appends | | Scratch isolation | `.dreams/` not agent-writable | -| Budget gate | No work, no writes when preflight fails | +| Budget gate | No work, no writes when preflight fails; event completes with `budget_skipped` | +| Required phase failure | Light/REM/Deep failure marks the event `failed` and keeps the day retryable | +| Retry idempotence | Failed sweeps retry as new attempts over the same idempotent `DreamingStore`; no global rollback | +| Run serialization | Scheduled and manual sweeps share one per-agent concurrency key | +| Manual trigger idempotence | **Dream now** returns an existing active/queued sweep instead of enqueueing another | +| Tool-less diary | Diary narrative has no sandbox/file tools; runtime owns `DREAMS.md` writes | +| Single diary file | Normal sweeps write only cumulative `DREAMS.md`; separate report files are debug/export only | ## Testing strategy | Layer | Focus | | --- | --- | -| Unit | ranking, rehydrate, managed-block strip, dedup ids | -| Integration | fixture sandbox dir → sweep → expected `MEMORY.md` markers | -| Workflow | `dreaming` event dispatches `handleDreaming`, budget skip invokes zero steps | +| Unit | ranking, rehydrate, managed-block strip, dedup ids, transcript cap/truncation policy, diary narrative output validation, retry idempotence | +| Integration | fixture sandbox dir + transcript fixtures → sweep → expected Outname-owned `MEMORY.md` markers, cumulative `DREAMS.md`, and no transcript or separate report files in sandbox | +| Workflow | `dreaming` event dispatches `handleDreaming`, budget skip completes with `budget_skipped` and invokes zero steps, required phase failure marks the event `failed` without updating `lastDreamingLocalDate`, retry starts a new attempt over idempotent store state, manual/scheduled sweeps serialize on the same concurrency key, repeated **Dream now** returns the active/queued event, diary narrative receives no file tools | ## Cutover @@ -506,37 +667,41 @@ from old diary entries. | PR | Scope | | --- | --- | | 1 | `memory-core` types, IO, guards, ranking, rehydrate, promote | -| 2 | Light + log ingestion + tests | -| 3 | REM structured LLM + phase signals + tests | +| 2 | Light + log/event-transcript ingestion + tests | +| 3 | REM deterministic metadata + phase signals + tests | | 4 | Deep + `MEMORY.md` append | | 5 | `handleDreaming`, workflow wiring, budget/scheduler semantics, delete v1 dreaming path | | 6 | Diary report + diary narrative (best-effort) + `DREAMS.md` + UI copy + AGENTS template | -| 7 | Session ingestion + optional schedule hour | +| 7 | Optional schedule hour | ## Mental model (FAQ) -**Why call it "deterministic" if REM uses an LLM?** +**Why not use an LLM for REM?** -The **promotion law** is deterministic: fixed ranking, thresholds, source -verification, and single writer to `MEMORY.md`. REM supplies **structured hints** -(relevance, tags); it does not directly decide durable memory. +The source architecture keeps consolidation deterministic. REM supplies +structured hints (tags, reflections, phase signals), but those hints come from +rules over staged evidence and still do not directly decide durable memory. **What if budget is zero?** -Nothing starts. The agent does not "partially dream." +Nothing starts. The agent does not "partially dream," even though Light, REM, +and Deep are deterministic. No `DreamingStore` state, checkpoint, diary, or +memory file is written. **What if budget exists?** -Light → REM → Deep always runs REM. No shortcuts. +Light → REM → Deep always runs REM. No shortcuts. The diary narrative may still +be skipped if there is not enough narrative budget after deterministic +consolidation. **How many LLM calls?** -Up to two per sweep: **REM** (required) and **diary narrative** (best-effort). -Light and Deep are TypeScript. The narrative does not replace REM; it explains -the sweep for humans without touching `MEMORY.md`. +Up to one per sweep: **diary narrative** (best-effort). Light, REM, and Deep are +TypeScript. The narrative does not replace REM; it explains the sweep for humans +without touching `MEMORY.md`. **What if narrative is skipped?** The structured report is always present when the sweep completes; UI still shows -themes, promotions, and REM reflections. OpenClaw treats narrative as best-effort -for the same reason. +themes, promotions, and REM reflections. Narrative remains best-effort because +it is explanatory, not canonical. diff --git a/docs/adr/0005-dreaming-v2-memory-governance.md b/docs/adr/0005-dreaming-v2-memory-governance.md new file mode 100644 index 00000000..73dbbe18 --- /dev/null +++ b/docs/adr/0005-dreaming-v2-memory-governance.md @@ -0,0 +1,44 @@ +# Dreaming v2 is runtime-owned memory governance + +OUTNA.ME will replace the current LLM-led dreaming pass with a runtime-owned +memory governance pipeline. The design is inspired by OpenClaw's memory-core +shape, but Outname owns the runtime contract, storage format, marker format, and +UI behavior; the implementation is not intended to be artifact-compatible with +OpenClaw. + +Dreaming v2 uses the phase names `Light`, `REM`, and `Deep`, but the phases are +defined by Outname. Light, REM, and Deep are deterministic TypeScript phases: +Light ingests bounded evidence, REM reinforces staged candidates with +rule-derived metadata, and Deep authorizes durable promotion through scoring, +source rehydration, and duplicate markers. The only LLM call is an optional +best-effort Dream Diary narrative, implemented as a tool-less `ToolLoopAgent` +whose output is validated and appended to `DREAMS.md` by runtime code. + +Scratch state lives in a sandbox-backed `DreamingStore`, initially +`memory/.dreams/dreaming.sqlite`, rather than scattered JSON files. Completed +event transcripts may be read transiently from the database-backed transcript +store, but raw or compact transcript corpora are not persisted into the sandbox. +`DREAMS.md` is the single cumulative human-readable diary file; separate report +files and manifest JSON exports are debug-only opt-ins. + +Deep promotions append to `MEMORY.md` only through Outname-owned markers such as +``. We will +not copy upstream marker strings. This keeps durable memory ownership clear and +prevents future code from depending on upstream artifact compatibility. + +## Consequences + +Budget gating is a total operational stop: if preflight budget fails, the event +completes with `budget_skipped` and no sweep state, checkpoint, diary, or memory +file is written. Required phase failures mark the `dreaming` event failed, +leave `lastDreamingLocalDate` unchanged, and are retryable as new sweep attempts +over idempotent `DreamingStore` state. Scheduled and manual dreaming share one +per-agent run lock; repeated **Dream now** triggers return the active or queued +event instead of creating parallel or duplicate sweeps. + +This design deliberately rejects a free-form `DurableAgent`/file-tool dreaming +session, upstream-compatible marker strings, persisted sandbox transcript +corpora, JSON scratch state as the primary store, and multiple default diary +report files. Those alternatives are easier to prototype, but they make durable +memory less grounded, increase persistent sandbox storage, and blur ownership of +the memory file contract. From ebf71f7dfd4657899f726ce0292fd29dde427672 Mon Sep 17 00:00:00 2001 From: Tommaso <65722261+TommasoTate@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:41:31 +0200 Subject: [PATCH 4/5] Implement runtime-owned dreaming pipeline --- .../agents/[agentId]/memory/dreams/page.tsx | 2 +- apps/app/app/agents/[agentId]/memory/page.tsx | 7 +- next.config.ts | 8 +- .../ai/agent-runtime/memory-core/config.ts | 28 ++ .../agent-runtime/memory-core/diary.test.ts | 46 ++ .../ai/agent-runtime/memory-core/diary.ts | 46 ++ .../ai/agent-runtime/memory-core/extract.ts | 72 +++ .../agent-runtime/memory-core/markers.test.ts | 24 + .../ai/agent-runtime/memory-core/markers.ts | 46 ++ .../agent-runtime/memory-core/promote.test.ts | 84 ++++ .../ai/agent-runtime/memory-core/promote.ts | 94 ++++ packages/ai/agent-runtime/memory-core/rank.ts | 67 +++ .../ai/agent-runtime/memory-core/sanitize.ts | 12 + .../memory-core/store/migrations.ts | 109 +++++ .../memory-core/store/operations.ts | 324 +++++++++++++ .../memory-core/store/sandbox.ts | 83 ++++ .../agent-runtime/memory-core/store/schema.ts | 120 +++++ .../memory-core/store/sql-js.test.ts | 65 +++ .../agent-runtime/memory-core/store/sql-js.ts | 38 ++ .../ai/agent-runtime/memory-core/types.ts | 108 +++++ .../server/agent-event-keys.test.ts | 2 + .../agent-runtime/server/agent-event-keys.ts | 4 + .../agent-runtime/server/agent-event-store.ts | 34 ++ .../server/agent-event-transcript-store.ts | 100 +++- .../agent-runtime/server/event-scheduler.ts | 3 +- .../server/session-events.test.ts | 64 +++ .../ai/agent-runtime/server/session-events.ts | 12 +- .../workflows/agent-events/workflow.ts | 7 +- .../workflows/events/steps/event-store.ts | 2 + .../events/workflow.workflow.unit.test.ts | 14 +- .../workflows/session/agent-factory.ts | 34 -- .../session/compose-system-prompt.ts | 8 +- .../session/handlers/handle-dreaming.ts | 184 +++++++ .../handlers/handle-dreaming.unit.test.ts | 199 ++++++++ .../session/handlers/handle-heartbeat.ts | 16 +- .../handlers/handle-heartbeat.unit.test.ts | 1 - .../session/steps/dreaming/dreaming-steps.ts | 455 ++++++++++++++++++ .../tools/sandbox-file-helpers/grep.ts | 5 + .../tools/sandbox-file-helpers/list.ts | 2 + .../tools/sandbox-file-helpers/paths.test.ts | 24 + .../tools/sandbox-file-helpers/paths.ts | 19 + .../tools/sandbox-file-helpers/read.ts | 7 +- packages/ai/package.json | 1 + packages/ai/sql-js.d.ts | 25 + .../creation-chat/create-requested-agent.ts | 2 +- .../agents/api/creation-chat/schemas.ts | 2 +- .../agent-form/dreaming-settings.tsx | 5 +- .../agents/components/agent-memory-pages.tsx | 2 +- .../agents/server/agents-md-template.ts | 33 +- .../shared/agents/server/bootstrap-files.ts | 23 +- packages/shared/content/blog/posts.ts | 4 +- pnpm-lock.yaml | 3 + tsconfig.json | 1 + tsconfig.vitest.json | 1 + 54 files changed, 2579 insertions(+), 102 deletions(-) create mode 100644 packages/ai/agent-runtime/memory-core/config.ts create mode 100644 packages/ai/agent-runtime/memory-core/diary.test.ts create mode 100644 packages/ai/agent-runtime/memory-core/diary.ts create mode 100644 packages/ai/agent-runtime/memory-core/extract.ts create mode 100644 packages/ai/agent-runtime/memory-core/markers.test.ts create mode 100644 packages/ai/agent-runtime/memory-core/markers.ts create mode 100644 packages/ai/agent-runtime/memory-core/promote.test.ts create mode 100644 packages/ai/agent-runtime/memory-core/promote.ts create mode 100644 packages/ai/agent-runtime/memory-core/rank.ts create mode 100644 packages/ai/agent-runtime/memory-core/sanitize.ts create mode 100644 packages/ai/agent-runtime/memory-core/store/migrations.ts create mode 100644 packages/ai/agent-runtime/memory-core/store/operations.ts create mode 100644 packages/ai/agent-runtime/memory-core/store/sandbox.ts create mode 100644 packages/ai/agent-runtime/memory-core/store/schema.ts create mode 100644 packages/ai/agent-runtime/memory-core/store/sql-js.test.ts create mode 100644 packages/ai/agent-runtime/memory-core/store/sql-js.ts create mode 100644 packages/ai/agent-runtime/memory-core/types.ts create mode 100644 packages/ai/agent-runtime/server/session-events.test.ts create mode 100644 packages/ai/agent-runtime/workflows/session/handlers/handle-dreaming.ts create mode 100644 packages/ai/agent-runtime/workflows/session/handlers/handle-dreaming.unit.test.ts create mode 100644 packages/ai/agent-runtime/workflows/session/steps/dreaming/dreaming-steps.ts create mode 100644 packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/paths.test.ts create mode 100644 packages/ai/sql-js.d.ts diff --git a/apps/app/app/agents/[agentId]/memory/dreams/page.tsx b/apps/app/app/agents/[agentId]/memory/dreams/page.tsx index a71a7330..ba17d44b 100644 --- a/apps/app/app/agents/[agentId]/memory/dreams/page.tsx +++ b/apps/app/app/agents/[agentId]/memory/dreams/page.tsx @@ -5,7 +5,7 @@ type Params = Promise<{ agentId: string }> export const metadata = createPrivatePageMetadata( 'Agent dreaming', - 'Inspect private OUTNA.ME agent dreaming output and DREAMS.md memory.' + 'Inspect private OUTNA.ME Dream Diary output and DREAMS.md memory.' ) export default function AgentMemoryDreamsPage({ params }: { params: Params }) { diff --git a/apps/app/app/agents/[agentId]/memory/page.tsx b/apps/app/app/agents/[agentId]/memory/page.tsx index f50cd5c4..030cef48 100644 --- a/apps/app/app/agents/[agentId]/memory/page.tsx +++ b/apps/app/app/agents/[agentId]/memory/page.tsx @@ -9,7 +9,7 @@ type Params = Promise<{ agentId: string }> export const metadata = createPrivatePageMetadata( 'Agent memory', - 'Inspect private OUTNA.ME agent files, timelines, and dreaming output.' + 'Inspect private OUTNA.ME agent files, timelines, and Dream Diary output.' ) export default function AgentMemoryPage({ params }: { params: Params }) { @@ -35,7 +35,8 @@ async function ResolvedAgentMemoryPage({ params }: { params: Params }) { Agent memory

- Inspect sandbox files, daily logs, and dreaming output for this agent. + Inspect sandbox files, daily logs, and Dream Diary output for this + agent.

@@ -51,7 +52,7 @@ async function ResolvedAgentMemoryPage({ params }: { params: Params }) { title="Timeline" /> diff --git a/next.config.ts b/next.config.ts index af6c3117..d7ff684b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,13 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { cacheComponents: true, - serverExternalPackages: ['better-auth', 'bash-tool', 'just-bash', 'pg'], + serverExternalPackages: [ + 'better-auth', + 'bash-tool', + 'just-bash', + 'pg', + 'sql.js', + ], pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], } diff --git a/packages/ai/agent-runtime/memory-core/config.ts b/packages/ai/agent-runtime/memory-core/config.ts new file mode 100644 index 00000000..be99df83 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/config.ts @@ -0,0 +1,28 @@ +import type { DreamingConfig } from './types' + +export const DEFAULT_DREAMING_CONFIG: DreamingConfig = { + diaryNarrativeEnabled: true, + lookbackDays: 7, + maxPromotedSnippetTokens: 160, + maxPromotionsPerSweep: 10, + maxTranscriptBytesPerEvent: 16_384, + maxTranscriptBytesPerSweep: 64_000, + maxTranscriptEventsPerSweep: 20, + maxTranscriptMessagesPerEvent: 40, + maxTranscriptSnippetsPerEvent: 8, + narrativeMaxOutputTokens: 500, + promotionMaxAgeDays: 30, + promotionMinRecallCount: 3, + promotionMinScore: 0.8, + promotionMinUniqueQueries: 3, + promotionRecencyHalfLifeDays: 14, +} as const + +export function resolveDreamingConfig( + overrides: Partial = {} +): DreamingConfig { + return { + ...DEFAULT_DREAMING_CONFIG, + ...overrides, + } +} diff --git a/packages/ai/agent-runtime/memory-core/diary.test.ts b/packages/ai/agent-runtime/memory-core/diary.test.ts new file mode 100644 index 00000000..803287a9 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/diary.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + appendDiarySection, + appendNarrativeToDiarySection, + renderDeterministicDiarySection, +} from './diary' + +describe('dreaming diary rendering', () => { + it('renders deterministic diary sections and appends narrative', () => { + const section = renderDeterministicDiarySection({ + completedAt: '2026-06-08T12:00:00.000Z', + localDate: '2026-06-08', + summary: { + deep: { + candidatesConsidered: 1, + evidenceSnippets: 0, + phase: 'deep', + promotions: [{ key: 'mem_1', marker: '', text: 'A' }], + signalsWritten: 1, + }, + light: { + candidatesConsidered: 2, + evidenceSnippets: 3, + phase: 'light', + signalsWritten: 1, + }, + rem: { + candidatesConsidered: 2, + evidenceSnippets: 0, + phase: 'rem', + signalsWritten: 2, + }, + sweepId: 'sweep_evt', + }, + }) + + expect(section).toContain('## 2026-06-08') + expect(section).toContain('Deep: 1 promotions written') + expect( + appendNarrativeToDiarySection({ narrative: '- Useful', section }) + ).toContain('### Narrative\n- Useful') + expect( + appendDiarySection({ existingDreams: '# Dreams\n', section }) + ).toContain('# Dreams\n\n## 2026-06-08') + }) +}) diff --git a/packages/ai/agent-runtime/memory-core/diary.ts b/packages/ai/agent-runtime/memory-core/diary.ts new file mode 100644 index 00000000..0da01865 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/diary.ts @@ -0,0 +1,46 @@ +import type { DreamingRunSummary } from './types' + +export function renderDeterministicDiarySection(input: { + completedAt: string + localDate: string + summary: DreamingRunSummary +}): string { + const lines = [ + `## ${input.localDate}`, + '', + `Completed at: ${input.completedAt}`, + `Sweep: ${input.summary.sweepId}`, + '', + `- Light: ${input.summary.light.evidenceSnippets} evidence snippets, ${input.summary.light.candidatesConsidered} candidates considered.`, + `- REM: ${input.summary.rem.signalsWritten} phase signals written.`, + `- Deep: ${input.summary.deep.promotions.length} promotions written.`, + ] + + if (input.summary.deep.promotions.length > 0) { + lines.push('', '### Promotions') + for (const promotion of input.summary.deep.promotions) { + lines.push(`- ${promotion.key}: ${promotion.text}`) + } + } + + return `${lines.join('\n')}\n` +} + +export function appendDiarySection(input: { + existingDreams: string + section: string +}): string { + const prefix = input.existingDreams.trimEnd() + return prefix ? `${prefix}\n\n${input.section}` : input.section +} + +export function appendNarrativeToDiarySection(input: { + narrative: string + section: string +}): string { + const narrative = input.narrative.trim() + if (!narrative) { + return input.section + } + return `${input.section.trimEnd()}\n\n### Narrative\n${narrative}\n` +} diff --git a/packages/ai/agent-runtime/memory-core/extract.ts b/packages/ai/agent-runtime/memory-core/extract.ts new file mode 100644 index 00000000..251a8951 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/extract.ts @@ -0,0 +1,72 @@ +import { candidateKeyForText, normalizeCandidateText } from './rank' +import { stripManagedDreamingContent } from './sanitize' +import type { EvidenceSnippet, EvidenceSourceType } from './types' + +const MIN_SIGNAL_CHARS = 24 +const MAX_SIGNAL_CHARS = 500 +const LOW_SIGNAL_RE = + /^(?:run started|heartbeat complete|dreaming complete|completed|ok|done)$/i + +export interface ExtractEvidenceInput { + maxSnippets?: number + observedAt: string + path?: string | null + sourceId: string + sourceType: EvidenceSourceType + text: string +} + +export function extractEvidenceSnippets( + input: ExtractEvidenceInput +): EvidenceSnippet[] { + const cleaned = stripManagedDreamingContent(input.text) + const snippets: EvidenceSnippet[] = [] + const lines = cleaned.split('\n') + for (let index = 0; index < lines.length; index += 1) { + const rawText = lines[index]?.trim() ?? '' + const normalized = normalizeCandidateText(rawText) + if (!isUsefulSignal(normalized)) { + continue + } + const candidateKey = candidateKeyForText(normalized) + const sourceLine = index + 1 + snippets.push({ + candidateKey, + id: `${input.sourceType}:${input.sourceId}:${sourceLine}:${candidateKey}`, + line: input.path ? sourceLine : null, + observedAt: input.observedAt, + path: input.path ?? null, + queryKey: queryKeyForSnippet({ + path: input.path, + sourceId: input.sourceId, + sourceType: input.sourceType, + }), + sourceId: input.sourceId, + sourceType: input.sourceType, + text: rawText.slice(0, MAX_SIGNAL_CHARS), + }) + if (input.maxSnippets && snippets.length >= input.maxSnippets) { + break + } + } + return snippets +} + +function isUsefulSignal(text: string): boolean { + return ( + text.length >= MIN_SIGNAL_CHARS && + text.length <= MAX_SIGNAL_CHARS && + !LOW_SIGNAL_RE.test(text) && + !text.startsWith('#') + ) +} + +function queryKeyForSnippet(input: { + path?: string | null + sourceId: string + sourceType: EvidenceSourceType +}): string { + return input.path + ? `${input.sourceType}:${input.path}` + : `${input.sourceType}:${input.sourceId}` +} diff --git a/packages/ai/agent-runtime/memory-core/markers.test.ts b/packages/ai/agent-runtime/memory-core/markers.test.ts new file mode 100644 index 00000000..2b9ee9b7 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/markers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { + containsPromotionMarker, + extractPromotionKeys, + renderOutnamePromotionMarker, +} from './markers' + +describe('dreaming promotion markers', () => { + it('renders and extracts Outname-owned promotion markers', () => { + const marker = renderOutnamePromotionMarker({ + at: '2026-06-08T10:00:00.000Z', + key: 'mem_123', + source: 'logs/2026-06-08.md:4', + }) + + expect(marker).toBe( + '' + ) + expect(extractPromotionKeys(`- Memory ${marker}`)).toEqual( + new Set(['mem_123']) + ) + expect(containsPromotionMarker(`- Memory ${marker}`, 'mem_123')).toBe(true) + }) +}) diff --git a/packages/ai/agent-runtime/memory-core/markers.ts b/packages/ai/agent-runtime/memory-core/markers.ts new file mode 100644 index 00000000..b2b80221 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/markers.ts @@ -0,0 +1,46 @@ +export const OUTNAME_PROMOTION_MARKER_PREFIX = '/g + +export interface PromotionMarkerInput { + at: string + key: string + source: string +} + +export function renderOutnamePromotionMarker( + input: PromotionMarkerInput +): string { + return `${OUTNAME_PROMOTION_MARKER_PREFIX} key="${escapeAttribute(input.key)}" source="${escapeAttribute(input.source)}" at="${escapeAttribute(input.at)}" -->` +} + +export function extractPromotionKeys(markdown: string): Set { + const keys = new Set() + for (const match of markdown.matchAll(PROMOTION_MARKER_RE)) { + const key = match[1] + if (key) { + keys.add(unescapeAttribute(key)) + } + } + return keys +} + +export function containsPromotionMarker( + markdown: string, + key: string +): boolean { + return extractPromotionKeys(markdown).has(key) +} + +export function isOutnamePromotionMarkerLine(line: string): boolean { + return line.includes(OUTNAME_PROMOTION_MARKER_PREFIX) +} + +function escapeAttribute(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"') +} + +function unescapeAttribute(value: string): string { + return value.replaceAll('"', '"').replaceAll('&', '&') +} diff --git a/packages/ai/agent-runtime/memory-core/promote.test.ts b/packages/ai/agent-runtime/memory-core/promote.test.ts new file mode 100644 index 00000000..8472780e --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/promote.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_DREAMING_CONFIG } from './config' +import { renderOutnamePromotionMarker } from './markers' +import { + appendPromotionsToMemory, + renderMemoryPromotion, + selectPromotionCandidates, +} from './promote' +import type { EvidenceSnippet, RecallCandidate } from './types' + +describe('dreaming promotion selection', () => { + const candidate: RecallCandidate = { + firstSeenAt: '2026-06-01T00:00:00.000Z', + key: 'mem_abc', + lastSeenAt: '2026-06-08T00:00:00.000Z', + normalizedText: 'tommaso prefers implementation plans before coding', + recallCount: 4, + score: 0.91, + status: 'active', + uniqueQueryCount: 3, + } + + const evidence: EvidenceSnippet = { + candidateKey: candidate.key, + id: 'evidence_1', + line: 12, + observedAt: '2026-06-08T00:00:00.000Z', + path: 'logs/2026-06-08.md', + queryKey: 'log:2026-06-08', + sourceId: 'logs/2026-06-08.md', + sourceType: 'log', + text: 'Tommaso prefers implementation plans before coding.', + } + + it('selects eligible candidates and skips existing markers', () => { + const evidenceByCandidate = new Map([[candidate.key, [evidence]]]) + + expect( + selectPromotionCandidates({ + candidates: [candidate], + config: DEFAULT_DREAMING_CONFIG, + evidenceByCandidate, + existingMemory: '', + now: new Date('2026-06-08T12:00:00.000Z'), + }) + ).toHaveLength(1) + + const marker = renderOutnamePromotionMarker({ + at: '2026-06-08T12:00:00.000Z', + key: candidate.key, + source: 'logs/2026-06-08.md:12', + }) + expect( + selectPromotionCandidates({ + candidates: [candidate], + config: DEFAULT_DREAMING_CONFIG, + evidenceByCandidate, + existingMemory: marker, + now: new Date('2026-06-08T12:00:00.000Z'), + }) + ).toHaveLength(0) + }) + + it('renders append-only MEMORY.md promotions with marker', () => { + const rendered = renderMemoryPromotion({ + at: '2026-06-08T12:00:00.000Z', + config: DEFAULT_DREAMING_CONFIG, + promotion: { + candidate, + evidence, + reason: 'stable repeated evidence', + }, + }) + + expect(rendered.text).toContain('Tommaso prefers implementation plans') + expect(rendered.marker).toContain('outname:dreaming:promotion') + expect( + appendPromotionsToMemory({ + existingMemory: '# Memory\n', + lines: [rendered.text], + }) + ).toContain('## Dreaming Promotions') + }) +}) diff --git a/packages/ai/agent-runtime/memory-core/promote.ts b/packages/ai/agent-runtime/memory-core/promote.ts new file mode 100644 index 00000000..b5ece6cb --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/promote.ts @@ -0,0 +1,94 @@ +import { extractPromotionKeys, renderOutnamePromotionMarker } from './markers' +import { isPromotionEligible } from './rank' +import type { + DreamingConfig, + EvidenceSnippet, + PromotionCandidate, + RecallCandidate, +} from './types' + +const TOKEN_RE = /\S+/g + +export function selectPromotionCandidates(input: { + candidates: RecallCandidate[] + config: DreamingConfig + evidenceByCandidate: Map + existingMemory: string + now: Date +}): PromotionCandidate[] { + const existingKeys = extractPromotionKeys(input.existingMemory) + return input.candidates + .filter((candidate) => !existingKeys.has(candidate.key)) + .filter((candidate) => + isPromotionEligible({ + candidate, + config: input.config, + now: input.now, + }) + ) + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score + } + if (right.recallCount !== left.recallCount) { + return right.recallCount - left.recallCount + } + return left.key.localeCompare(right.key) + }) + .slice(0, input.config.maxPromotionsPerSweep) + .map((candidate) => ({ + candidate, + evidence: input.evidenceByCandidate.get(candidate.key)?.[0] ?? null, + reason: 'stable repeated evidence', + })) +} + +export function renderMemoryPromotion(input: { + at: string + config: DreamingConfig + promotion: PromotionCandidate +}): { marker: string; text: string } { + const source = input.promotion.evidence + ? sourceLabel(input.promotion.evidence) + : 'dreaming-store' + const marker = renderOutnamePromotionMarker({ + at: input.at, + key: input.promotion.candidate.key, + source, + }) + const text = truncateTokens( + input.promotion.evidence?.text ?? input.promotion.candidate.normalizedText, + input.config.maxPromotedSnippetTokens + ) + return { + marker, + text: `- ${text} ${marker}`, + } +} + +export function appendPromotionsToMemory(input: { + existingMemory: string + lines: string[] +}): string { + if (input.lines.length === 0) { + return input.existingMemory + } + const prefix = input.existingMemory.trimEnd() + const section = ['## Dreaming Promotions', '', ...input.lines].join('\n') + return prefix ? `${prefix}\n\n${section}\n` : `${section}\n` +} + +function sourceLabel(evidence: EvidenceSnippet): string { + if (evidence.path && evidence.line) { + return `${evidence.path}:${evidence.line}` + } + return evidence.sourceId +} + +function truncateTokens(text: string, maxTokens: number): string { + const tokens = text.match(TOKEN_RE) ?? [] + if (tokens.length <= maxTokens) { + return text.trim() + } + return `${tokens.slice(0, maxTokens).join(' ')}...` +} diff --git a/packages/ai/agent-runtime/memory-core/rank.ts b/packages/ai/agent-runtime/memory-core/rank.ts new file mode 100644 index 00000000..7b6ee836 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/rank.ts @@ -0,0 +1,67 @@ +import { createHash } from 'node:crypto' +import type { DreamingConfig, RecallCandidate } from './types' + +const WHITESPACE_RE = /\s+/g +const MARKDOWN_PREFIX_RE = /^[-*+]\s+(?:\[[ xX]\]\s+)?/ + +export function normalizeCandidateText(text: string): string { + return text + .replace(MARKDOWN_PREFIX_RE, '') + .replace(WHITESPACE_RE, ' ') + .trim() + .toLowerCase() +} + +export function candidateKeyForText(text: string): string { + const normalized = normalizeCandidateText(text) + return `mem_${createHash('sha256').update(normalized).digest('hex').slice(0, 20)}` +} + +export function scoreRecallCandidate(input: { + candidate: Pick< + RecallCandidate, + 'lastSeenAt' | 'recallCount' | 'uniqueQueryCount' + > + config: DreamingConfig + now: Date +}): number { + const ageMs = Math.max( + 0, + input.now.getTime() - new Date(input.candidate.lastSeenAt).getTime() + ) + const ageDays = ageMs / 86_400_000 + const recency = 0.5 ** (ageDays / input.config.promotionRecencyHalfLifeDays) + const recall = Math.min( + 1, + input.candidate.recallCount / input.config.promotionMinRecallCount + ) + const diversity = Math.min( + 1, + input.candidate.uniqueQueryCount / input.config.promotionMinUniqueQueries + ) + return roundScore(0.45 * recall + 0.35 * diversity + 0.2 * recency) +} + +export function isPromotionEligible(input: { + candidate: RecallCandidate + config: DreamingConfig + now: Date +}): boolean { + const firstSeenAgeMs = Math.max( + 0, + input.now.getTime() - new Date(input.candidate.firstSeenAt).getTime() + ) + const firstSeenAgeDays = firstSeenAgeMs / 86_400_000 + return ( + input.candidate.status === 'active' && + input.candidate.score >= input.config.promotionMinScore && + input.candidate.recallCount >= input.config.promotionMinRecallCount && + input.candidate.uniqueQueryCount >= + input.config.promotionMinUniqueQueries && + firstSeenAgeDays <= input.config.promotionMaxAgeDays + ) +} + +function roundScore(value: number): number { + return Math.round(value * 1000) / 1000 +} diff --git a/packages/ai/agent-runtime/memory-core/sanitize.ts b/packages/ai/agent-runtime/memory-core/sanitize.ts new file mode 100644 index 00000000..dd95994c --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/sanitize.ts @@ -0,0 +1,12 @@ +import { isOutnamePromotionMarkerLine } from './markers' + +const DREAMING_MANAGED_BLOCK_RE = + //g + +export function stripManagedDreamingContent(markdown: string): string { + return markdown + .replace(DREAMING_MANAGED_BLOCK_RE, '') + .split('\n') + .filter((line) => !isOutnamePromotionMarkerLine(line)) + .join('\n') +} diff --git a/packages/ai/agent-runtime/memory-core/store/migrations.ts b/packages/ai/agent-runtime/memory-core/store/migrations.ts new file mode 100644 index 00000000..a070d078 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/store/migrations.ts @@ -0,0 +1,109 @@ +import type { Database } from 'sql.js' + +const SCHEMA_VERSION = '1' + +export function applyDreamingStoreMigrations(sqlite: Database): void { + sqlite.run('PRAGMA foreign_keys = ON') + sqlite.run('PRAGMA journal_mode = DELETE') + sqlite.run(` + CREATE TABLE IF NOT EXISTS schema_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS sweeps ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + local_date TEXT NOT NULL, + attempt INTEGER NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + error TEXT + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS ingestion_checkpoints ( + source TEXT PRIMARY KEY, + cursor TEXT NOT NULL, + observed_at TEXT NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS recall_candidates ( + key TEXT PRIMARY KEY, + normalized_text TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + recall_count INTEGER NOT NULL, + unique_query_count INTEGER NOT NULL, + score REAL NOT NULL, + status TEXT NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS evidence_snippets ( + id TEXT PRIMARY KEY, + candidate_key TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + path TEXT, + line INTEGER, + text TEXT NOT NULL, + observed_at TEXT NOT NULL, + query_key TEXT NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS phase_signals ( + id TEXT PRIMARY KEY, + sweep_id TEXT NOT NULL, + phase TEXT NOT NULL, + candidate_key TEXT, + signal_type TEXT NOT NULL, + score REAL, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `) + sqlite.run(` + CREATE TABLE IF NOT EXISTS promotions ( + key TEXT PRIMARY KEY, + sweep_id TEXT NOT NULL, + marker TEXT NOT NULL, + promoted_at TEXT NOT NULL, + memory_path TEXT NOT NULL + ) + `) + sqlite.run( + "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?)", + [SCHEMA_VERSION] + ) + sqlite.run( + 'CREATE INDEX IF NOT EXISTS sweeps_agent_date_idx ON sweeps (agent_id, local_date)' + ) + sqlite.run('CREATE INDEX IF NOT EXISTS sweeps_event_idx ON sweeps (event_id)') + sqlite.run( + 'CREATE INDEX IF NOT EXISTS recall_candidates_score_idx ON recall_candidates (status, score)' + ) + sqlite.run( + 'CREATE INDEX IF NOT EXISTS recall_candidates_last_seen_idx ON recall_candidates (last_seen_at)' + ) + sqlite.run( + 'CREATE INDEX IF NOT EXISTS evidence_candidate_idx ON evidence_snippets (candidate_key)' + ) + sqlite.run( + 'CREATE INDEX IF NOT EXISTS evidence_source_idx ON evidence_snippets (source_type, source_id)' + ) + sqlite.run( + 'CREATE INDEX IF NOT EXISTS phase_signals_sweep_idx ON phase_signals (sweep_id, phase)' + ) + sqlite.run( + 'CREATE INDEX IF NOT EXISTS phase_signals_candidate_idx ON phase_signals (candidate_key)' + ) + sqlite.run( + 'CREATE INDEX IF NOT EXISTS promotions_sweep_idx ON promotions (sweep_id)' + ) +} diff --git a/packages/ai/agent-runtime/memory-core/store/operations.ts b/packages/ai/agent-runtime/memory-core/store/operations.ts new file mode 100644 index 00000000..70ac05ef --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/store/operations.ts @@ -0,0 +1,324 @@ +import { eq, inArray, sql } from 'drizzle-orm' +import { normalizeCandidateText, scoreRecallCandidate } from '../rank' +import type { + DreamingConfig, + DreamingDeepSummary, + DreamingPhase, + DreamingPhaseSummary, + DreamingStore, + EvidenceSnippet, + RecallCandidate, +} from '../types' +import { + evidenceSnippets, + phaseSignals, + promotions, + recallCandidates, + sweeps, +} from './schema' + +export function beginSweep(input: { + agentId: string + attempt: number + eventId: string + localDate: string + nowIso: string + store: DreamingStore + sweepId: string +}): void { + input.store.db + .insert(sweeps) + .values({ + agentId: input.agentId, + attempt: input.attempt, + completedAt: null, + error: null, + eventId: input.eventId, + id: input.sweepId, + localDate: input.localDate, + startedAt: input.nowIso, + status: 'running', + }) + .onConflictDoUpdate({ + target: sweeps.id, + set: { + attempt: input.attempt, + error: null, + status: 'running', + }, + }) + .run() +} + +export function completeSweep(input: { + completedAt: string + store: DreamingStore + sweepId: string +}): void { + input.store.db + .update(sweeps) + .set({ + completedAt: input.completedAt, + error: null, + status: 'completed', + }) + .where(eq(sweeps.id, input.sweepId)) + .run() +} + +export function failSweep(input: { + error: string + failedAt: string + store: DreamingStore + sweepId: string +}): void { + input.store.db + .update(sweeps) + .set({ + completedAt: input.failedAt, + error: input.error, + status: 'failed', + }) + .where(eq(sweeps.id, input.sweepId)) + .run() +} + +export function upsertEvidenceSnippets(input: { + config: DreamingConfig + now: Date + snippets: EvidenceSnippet[] + store: DreamingStore +}): DreamingPhaseSummary { + const candidateKeys = new Set() + for (const snippet of input.snippets) { + candidateKeys.add(snippet.candidateKey) + input.store.db + .insert(evidenceSnippets) + .values(snippet) + .onConflictDoNothing() + .run() + } + + for (const candidateKey of candidateKeys) { + upsertRecallCandidateFromEvidence({ + candidateKey, + config: input.config, + now: input.now, + store: input.store, + }) + } + + return { + candidatesConsidered: candidateKeys.size, + evidenceSnippets: input.snippets.length, + phase: 'light', + signalsWritten: 0, + } +} + +export function writePhaseSignal(input: { + candidateKey?: string | null + metadata: Record + phase: DreamingPhase + score?: number | null + signalType: string + store: DreamingStore + sweepId: string + timestamp: string +}): void { + input.store.db + .insert(phaseSignals) + .values({ + candidateKey: input.candidateKey ?? null, + createdAt: input.timestamp, + id: `${input.sweepId}:${input.phase}:${input.signalType}:${input.candidateKey ?? 'global'}`, + metadataJson: JSON.stringify(input.metadata), + phase: input.phase, + score: input.score ?? null, + signalType: input.signalType, + sweepId: input.sweepId, + }) + .onConflictDoUpdate({ + target: phaseSignals.id, + set: { + metadataJson: JSON.stringify(input.metadata), + score: input.score ?? null, + }, + }) + .run() +} + +export function runRemPhase(input: { + config: DreamingConfig + now: Date + nowIso: string + store: DreamingStore + sweepId: string +}): DreamingPhaseSummary { + const candidates = listActiveCandidates(input.store) + for (const candidate of candidates) { + const score = scoreRecallCandidate({ + candidate, + config: input.config, + now: input.now, + }) + input.store.db + .update(recallCandidates) + .set({ score }) + .where(eq(recallCandidates.key, candidate.key)) + .run() + writePhaseSignal({ + candidateKey: candidate.key, + metadata: { + recallCount: candidate.recallCount, + uniqueQueryCount: candidate.uniqueQueryCount, + }, + phase: 'rem', + score, + signalType: 'score', + store: input.store, + sweepId: input.sweepId, + timestamp: input.nowIso, + }) + } + return { + candidatesConsidered: candidates.length, + evidenceSnippets: 0, + phase: 'rem', + signalsWritten: candidates.length, + } +} + +export function listActiveCandidates(store: DreamingStore): RecallCandidate[] { + return store.db + .select() + .from(recallCandidates) + .where(eq(recallCandidates.status, 'active')) + .all() +} + +export function listEvidenceForCandidates(input: { + candidateKeys: string[] + store: DreamingStore +}): Map { + if (input.candidateKeys.length === 0) { + return new Map() + } + const rows = input.store.db + .select() + .from(evidenceSnippets) + .where(inArray(evidenceSnippets.candidateKey, input.candidateKeys)) + .all() + const byCandidate = new Map() + for (const row of rows) { + const existing = byCandidate.get(row.candidateKey) ?? [] + existing.push(row) + byCandidate.set(row.candidateKey, existing) + } + for (const snippets of byCandidate.values()) { + snippets.sort((left, right) => + right.observedAt.localeCompare(left.observedAt) + ) + } + return byCandidate +} + +export function recordDeepPromotions(input: { + promotionsWritten: Array<{ key: string; marker: string; text: string }> + store: DreamingStore + sweepId: string + timestamp: string +}): DreamingDeepSummary { + for (const promotion of input.promotionsWritten) { + input.store.db + .insert(promotions) + .values({ + key: promotion.key, + marker: promotion.marker, + memoryPath: 'MEMORY.md', + promotedAt: input.timestamp, + sweepId: input.sweepId, + }) + .onConflictDoNothing() + .run() + input.store.db + .update(recallCandidates) + .set({ status: 'promoted' }) + .where(eq(recallCandidates.key, promotion.key)) + .run() + } + + writePhaseSignal({ + metadata: { promotions: input.promotionsWritten.length }, + phase: 'deep', + signalType: 'promotion_summary', + store: input.store, + sweepId: input.sweepId, + timestamp: input.timestamp, + }) + + return { + candidatesConsidered: input.promotionsWritten.length, + evidenceSnippets: 0, + phase: 'deep', + promotions: input.promotionsWritten, + signalsWritten: 1, + } +} + +function upsertRecallCandidateFromEvidence(input: { + candidateKey: string + config: DreamingConfig + now: Date + store: DreamingStore +}): void { + const counts = input.store.db.get<{ + firstSeenAt: string + lastSeenAt: string + normalizedText: string + recallCount: number + uniqueQueryCount: number + }>(sql` + SELECT + min(observed_at) as firstSeenAt, + max(observed_at) as lastSeenAt, + min(text) as normalizedText, + count(*) as recallCount, + count(distinct query_key) as uniqueQueryCount + FROM evidence_snippets + WHERE candidate_key = ${input.candidateKey} + `) + if (!counts) { + return + } + const candidate = { + firstSeenAt: counts.firstSeenAt, + key: input.candidateKey, + lastSeenAt: counts.lastSeenAt, + normalizedText: normalizeCandidateText(counts.normalizedText), + recallCount: Number(counts.recallCount), + score: 0, + status: 'active' as const, + uniqueQueryCount: Number(counts.uniqueQueryCount), + } + const score = scoreRecallCandidate({ + candidate, + config: input.config, + now: input.now, + }) + input.store.db + .insert(recallCandidates) + .values({ ...candidate, score }) + .onConflictDoUpdate({ + target: recallCandidates.key, + set: { + firstSeenAt: candidate.firstSeenAt, + lastSeenAt: candidate.lastSeenAt, + normalizedText: candidate.normalizedText, + recallCount: candidate.recallCount, + score, + uniqueQueryCount: candidate.uniqueQueryCount, + }, + }) + .run() +} diff --git a/packages/ai/agent-runtime/memory-core/store/sandbox.ts b/packages/ai/agent-runtime/memory-core/store/sandbox.ts new file mode 100644 index 00000000..638801c2 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/store/sandbox.ts @@ -0,0 +1,83 @@ +import 'server-only' +import { + getSystemSandbox, + SYSTEM_SANDBOX_ROOT, +} from '@outname/ai/agent-runtime/server/agent-sandbox' +import type { Sandbox } from '@vercel/sandbox' +import type { DreamingStore } from '../types' +import { openDreamingSqlite } from './sql-js' + +export const DREAMING_STORE_REL_PATH = 'memory/.dreams/dreaming.sqlite' +export const DREAMING_STORE_ABS_PATH = `${SYSTEM_SANDBOX_ROOT}/${DREAMING_STORE_REL_PATH}` + +export async function withSandboxDreamingStore( + agentId: string, + callback: (store: DreamingStore) => T | Promise, + options: { save?: boolean } = {} +): Promise { + const sandbox = await getSystemSandbox(agentId) + const buffer = await readDreamingStoreBuffer(sandbox) + const opened = await openDreamingSqlite({ buffer }) + const store: DreamingStore = { + db: opened.db, + export: opened.exportBytes, + save: async () => { + await writeDreamingStoreBuffer(sandbox, Buffer.from(opened.exportBytes())) + }, + } + try { + const result = await callback(store) + if (options.save !== false) { + await store.save() + } + return result + } finally { + opened.sqlite.close() + } +} + +async function readDreamingStoreBuffer( + sandbox: Sandbox +): Promise { + try { + return await sandbox.readFileToBuffer({ path: DREAMING_STORE_ABS_PATH }) + } catch (error) { + if (isMissingFileError(error)) { + return null + } + throw error + } +} + +async function writeDreamingStoreBuffer( + sandbox: Sandbox, + buffer: Buffer +): Promise { + const mkdir = await sandbox.runCommand({ + args: ['-p', `${SYSTEM_SANDBOX_ROOT}/memory/.dreams`], + cmd: 'mkdir', + }) + if (mkdir.exitCode !== 0) { + const stderr = await mkdir.stderr() + throw new Error( + stderr.trim() || 'DreamingStore: failed to create store directory' + ) + } + await sandbox.writeFiles([{ content: buffer, path: DREAMING_STORE_ABS_PATH }]) +} + +function isMissingFileError(error: unknown): boolean { + if (!(typeof error === 'object' && error !== null)) { + return false + } + if ('code' in error && error.code === 'ENOENT') { + return true + } + return ( + 'response' in error && + typeof error.response === 'object' && + error.response !== null && + 'status' in error.response && + error.response.status === 404 + ) +} diff --git a/packages/ai/agent-runtime/memory-core/store/schema.ts b/packages/ai/agent-runtime/memory-core/store/schema.ts new file mode 100644 index 00000000..60ecb226 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/store/schema.ts @@ -0,0 +1,120 @@ +import { + index, + integer, + real, + sqliteTable, + text, +} from 'drizzle-orm/sqlite-core' +import type { + DreamingPhase, + DreamingSweepStatus, + EvidenceSourceType, + RecallCandidateStatus, +} from '../types' + +export const schemaMeta = sqliteTable('schema_meta', { + key: text('key').primaryKey(), + value: text('value').notNull(), +}) + +export const sweeps = sqliteTable( + 'sweeps', + { + agentId: text('agent_id').notNull(), + attempt: integer('attempt').notNull(), + completedAt: text('completed_at'), + error: text('error'), + eventId: text('event_id').notNull(), + id: text('id').primaryKey(), + localDate: text('local_date').notNull(), + startedAt: text('started_at').notNull(), + status: text('status').$type().notNull(), + }, + (t) => [ + index('sweeps_agent_date_idx').on(t.agentId, t.localDate), + index('sweeps_event_idx').on(t.eventId), + ] +) + +export const ingestionCheckpoints = sqliteTable('ingestion_checkpoints', { + cursor: text('cursor').notNull(), + observedAt: text('observed_at').notNull(), + source: text('source').primaryKey(), +}) + +export const recallCandidates = sqliteTable( + 'recall_candidates', + { + firstSeenAt: text('first_seen_at').notNull(), + key: text('key').primaryKey(), + lastSeenAt: text('last_seen_at').notNull(), + normalizedText: text('normalized_text').notNull(), + recallCount: integer('recall_count').notNull(), + score: real('score').notNull(), + status: text('status').$type().notNull(), + uniqueQueryCount: integer('unique_query_count').notNull(), + }, + (t) => [ + index('recall_candidates_score_idx').on(t.status, t.score), + index('recall_candidates_last_seen_idx').on(t.lastSeenAt), + ] +) + +export const evidenceSnippets = sqliteTable( + 'evidence_snippets', + { + candidateKey: text('candidate_key').notNull(), + id: text('id').primaryKey(), + line: integer('line'), + observedAt: text('observed_at').notNull(), + path: text('path'), + queryKey: text('query_key').notNull(), + sourceId: text('source_id').notNull(), + sourceType: text('source_type').$type().notNull(), + text: text('text').notNull(), + }, + (t) => [ + index('evidence_candidate_idx').on(t.candidateKey), + index('evidence_source_idx').on(t.sourceType, t.sourceId), + ] +) + +export const phaseSignals = sqliteTable( + 'phase_signals', + { + candidateKey: text('candidate_key'), + createdAt: text('created_at').notNull(), + id: text('id').primaryKey(), + metadataJson: text('metadata_json').notNull(), + phase: text('phase').$type().notNull(), + score: real('score'), + signalType: text('signal_type').notNull(), + sweepId: text('sweep_id').notNull(), + }, + (t) => [ + index('phase_signals_sweep_idx').on(t.sweepId, t.phase), + index('phase_signals_candidate_idx').on(t.candidateKey), + ] +) + +export const promotions = sqliteTable( + 'promotions', + { + key: text('key').primaryKey(), + marker: text('marker').notNull(), + memoryPath: text('memory_path').notNull(), + promotedAt: text('promoted_at').notNull(), + sweepId: text('sweep_id').notNull(), + }, + (t) => [index('promotions_sweep_idx').on(t.sweepId)] +) + +export const dreamingSchema = { + evidenceSnippets, + ingestionCheckpoints, + phaseSignals, + promotions, + recallCandidates, + schemaMeta, + sweeps, +} diff --git a/packages/ai/agent-runtime/memory-core/store/sql-js.test.ts b/packages/ai/agent-runtime/memory-core/store/sql-js.test.ts new file mode 100644 index 00000000..49d88ab0 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/store/sql-js.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { DEFAULT_DREAMING_CONFIG } from '../config' +import { extractEvidenceSnippets } from '../extract' +import { + beginSweep, + listActiveCandidates, + upsertEvidenceSnippets, +} from './operations' +import { openDreamingSqlite } from './sql-js' + +describe('DreamingStore sql.js adapter', () => { + it('creates, exports, reopens, and queries a Drizzle-backed SQLite store', async () => { + const opened = await openDreamingSqlite() + const store = { + db: opened.db, + export: opened.exportBytes, + save: vi.fn(), + } + + beginSweep({ + agentId: 'agent_123', + attempt: 1, + eventId: 'evt_123', + localDate: '2026-06-08', + nowIso: '2026-06-08T10:00:00.000Z', + store, + sweepId: 'sweep_evt_123', + }) + + const snippets = extractEvidenceSnippets({ + observedAt: '2026-06-08T10:00:00.000Z', + path: 'logs/2026-06-08.md', + sourceId: 'logs/2026-06-08.md', + sourceType: 'log', + text: '- Tommaso prefers implementation plans before coding.\n', + }) + upsertEvidenceSnippets({ + config: DEFAULT_DREAMING_CONFIG, + now: new Date('2026-06-08T10:00:00.000Z'), + snippets, + store, + }) + upsertEvidenceSnippets({ + config: DEFAULT_DREAMING_CONFIG, + now: new Date('2026-06-08T10:00:00.000Z'), + snippets, + store, + }) + + const exported = Buffer.from(opened.exportBytes()) + opened.sqlite.close() + + const reopened = await openDreamingSqlite({ buffer: exported }) + const reopenedStore = { + db: reopened.db, + export: reopened.exportBytes, + save: vi.fn(), + } + const candidates = listActiveCandidates(reopenedStore) + reopened.sqlite.close() + + expect(candidates).toHaveLength(1) + expect(candidates[0]?.recallCount).toBe(1) + }) +}) diff --git a/packages/ai/agent-runtime/memory-core/store/sql-js.ts b/packages/ai/agent-runtime/memory-core/store/sql-js.ts new file mode 100644 index 00000000..ba36eca6 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/store/sql-js.ts @@ -0,0 +1,38 @@ +import { createRequire } from 'node:module' +import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js' +import initSqlJs, { type Database, type SqlJsStatic } from 'sql.js' +import { applyDreamingStoreMigrations } from './migrations' +import { dreamingSchema } from './schema' + +const require = createRequire(import.meta.url) + +let sqlJsPromise: Promise | null = null + +export interface OpenDreamingSqliteResult { + db: SQLJsDatabase + exportBytes(): Uint8Array + sqlite: Database +} + +export async function openDreamingSqlite( + input: { buffer?: Buffer | null } = {} +): Promise { + const SQL = await loadSqlJs() + const sqlite = input.buffer + ? new SQL.Database(new Uint8Array(input.buffer)) + : new SQL.Database() + applyDreamingStoreMigrations(sqlite) + const db = drizzle(sqlite, { schema: dreamingSchema }) + return { + db, + exportBytes: () => sqlite.export(), + sqlite, + } +} + +async function loadSqlJs(): Promise { + sqlJsPromise ??= initSqlJs({ + locateFile: (file: string) => require.resolve(`sql.js/dist/${file}`), + }) + return await sqlJsPromise +} diff --git a/packages/ai/agent-runtime/memory-core/types.ts b/packages/ai/agent-runtime/memory-core/types.ts new file mode 100644 index 00000000..c3cfb026 --- /dev/null +++ b/packages/ai/agent-runtime/memory-core/types.ts @@ -0,0 +1,108 @@ +import type { SQLJsDatabase } from 'drizzle-orm/sql-js' +import type { dreamingSchema } from './store/schema' + +export type DreamingPhase = 'light' | 'rem' | 'deep' | 'diary' + +export type DreamingSweepStatus = 'running' | 'completed' | 'failed' + +export type EvidenceSourceType = 'event_transcript' | 'log' + +export type RecallCandidateStatus = 'active' | 'promoted' | 'rejected' + +export interface DreamingConfig { + diaryNarrativeEnabled: boolean + lookbackDays: number + maxPromotedSnippetTokens: number + maxPromotionsPerSweep: number + maxTranscriptBytesPerEvent: number + maxTranscriptBytesPerSweep: number + maxTranscriptEventsPerSweep: number + maxTranscriptMessagesPerEvent: number + maxTranscriptSnippetsPerEvent: number + narrativeMaxOutputTokens: number + promotionMaxAgeDays: number + promotionMinRecallCount: number + promotionMinScore: number + promotionMinUniqueQueries: number + promotionRecencyHalfLifeDays: number +} + +export interface DreamingSweep { + agentId: string + attempt: number + completedAt: string | null + error: string | null + eventId: string + id: string + localDate: string + startedAt: string + status: DreamingSweepStatus +} + +export interface EvidenceSnippet { + candidateKey: string + id: string + line: number | null + observedAt: string + path: string | null + queryKey: string + sourceId: string + sourceType: EvidenceSourceType + text: string +} + +export interface RecallCandidate { + firstSeenAt: string + key: string + lastSeenAt: string + normalizedText: string + recallCount: number + score: number + status: RecallCandidateStatus + uniqueQueryCount: number +} + +export interface PhaseSignal { + candidateKey: string | null + createdAt: string + id: string + metadataJson: string + phase: DreamingPhase + score: number | null + signalType: string + sweepId: string +} + +export interface PromotionCandidate { + candidate: RecallCandidate + evidence: EvidenceSnippet | null + reason: string +} + +export interface DreamingStore { + db: SQLJsDatabase + export(): Uint8Array + save(): Promise +} + +export interface DreamingPhaseSummary { + candidatesConsidered: number + evidenceSnippets: number + phase: DreamingPhase + signalsWritten: number +} + +export interface DreamingDeepSummary extends DreamingPhaseSummary { + promotions: Array<{ + key: string + marker: string + text: string + }> +} + +export interface DreamingRunSummary { + deep: DreamingDeepSummary + light: DreamingPhaseSummary + rem: DreamingPhaseSummary + sweepId: string +} diff --git a/packages/ai/agent-runtime/server/agent-event-keys.test.ts b/packages/ai/agent-runtime/server/agent-event-keys.test.ts index f008bb00..47cb6bd0 100644 --- a/packages/ai/agent-runtime/server/agent-event-keys.test.ts +++ b/packages/ai/agent-runtime/server/agent-event-keys.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest' import { + dreamingConcurrencyKey, eventActivityNamespace, replyNamespaceForEvent, scheduledBucketKey, @@ -46,6 +47,7 @@ test('scheduled event keys bucket heartbeat and dreaming independently', () => { }) test('scheduled concurrency and stream namespaces are stable', () => { + expect(dreamingConcurrencyKey('agent_123')).toBe('dreaming:agent_123') expect( scheduledConcurrencyKey({ agentId: 'agent_123', diff --git a/packages/ai/agent-runtime/server/agent-event-keys.ts b/packages/ai/agent-runtime/server/agent-event-keys.ts index 015c48b6..56d54e45 100644 --- a/packages/ai/agent-runtime/server/agent-event-keys.ts +++ b/packages/ai/agent-runtime/server/agent-event-keys.ts @@ -6,6 +6,10 @@ export function eventActivityNamespace(eventWorkflowRunId: string): string { return `events:${eventWorkflowRunId}` } +export function dreamingConcurrencyKey(agentId: string): string { + return `dreaming:${agentId}` +} + export function scheduledBucketKey(input: { agentId: string intervalMinutes: number diff --git a/packages/ai/agent-runtime/server/agent-event-store.ts b/packages/ai/agent-runtime/server/agent-event-store.ts index d5574966..86102719 100644 --- a/packages/ai/agent-runtime/server/agent-event-store.ts +++ b/packages/ai/agent-runtime/server/agent-event-store.ts @@ -305,6 +305,40 @@ export async function findNextQueuedForConcurrencyKey( return row ?? null } +export async function findActiveOrQueuedDreamingEventForAgent( + agentId: string +): Promise { + const [active] = await db + .select() + .from(agentEvents) + .where( + and( + eq(agentEvents.agentId, agentId), + eq(agentEvents.type, 'dreaming'), + inArray(agentEvents.status, ACTIVE_EVENT_STATUSES) + ) + ) + .orderBy(asc(agentEvents.queuedAt)) + .limit(1) + if (active) { + return active + } + + const [queued] = await db + .select() + .from(agentEvents) + .where( + and( + eq(agentEvents.agentId, agentId), + eq(agentEvents.type, 'dreaming'), + eq(agentEvents.status, 'queued') + ) + ) + .orderBy(asc(agentEvents.queuedAt)) + .limit(1) + return queued ?? null +} + export function payloadAs(event: AgentEvent): T { return event.payload as T } diff --git a/packages/ai/agent-runtime/server/agent-event-transcript-store.ts b/packages/ai/agent-runtime/server/agent-event-transcript-store.ts index e41f29c6..e543013c 100644 --- a/packages/ai/agent-runtime/server/agent-event-transcript-store.ts +++ b/packages/ai/agent-runtime/server/agent-event-transcript-store.ts @@ -1,9 +1,9 @@ import 'server-only' import type { AgentChatMessage } from '@outname/ai/agent-runtime/server/chat-status' import { db } from '@outname/db' -import { agentEventMessage } from '@outname/db/schema' +import { agentEventMessage, agentEvents } from '@outname/db/schema' import type { UIMessage } from 'ai' -import { asc, eq } from 'drizzle-orm' +import { and, asc, desc, eq, gte, inArray } from 'drizzle-orm' export async function listAgentEventTranscriptMessages( eventId: string @@ -82,6 +82,102 @@ export async function replaceAgentEventTranscriptMessagesBestEffort(input: { }) } } + +export interface DreamingTranscriptMessage { + eventId: string + messageId: string + role: string + text: string +} + +export interface DreamingTranscriptEvent { + completedAt: Date + eventId: string + messages: DreamingTranscriptMessage[] + type: string +} + +export async function listRecentCompletedAgentEventTranscriptsForDreaming(input: { + agentId: string + completedAfter: Date + limit: number + maxMessagesPerEvent: number + userId: string +}): Promise { + const eventRows = await db + .select({ + completedAt: agentEvents.completedAt, + eventId: agentEvents.id, + type: agentEvents.type, + }) + .from(agentEvents) + .where( + and( + eq(agentEvents.agentId, input.agentId), + eq(agentEvents.userId, input.userId), + eq(agentEvents.status, 'completed'), + inArray(agentEvents.type, ['heartbeat', 'invocation']), + gte(agentEvents.completedAt, input.completedAfter) + ) + ) + .orderBy(desc(agentEvents.completedAt)) + .limit(input.limit) + + const events: DreamingTranscriptEvent[] = [] + for (const event of eventRows) { + if (!event.completedAt) { + continue + } + const messageRows = await db + .select({ + eventId: agentEventMessage.eventId, + messageId: agentEventMessage.messageId, + parts: agentEventMessage.parts, + role: agentEventMessage.role, + }) + .from(agentEventMessage) + .where(eq(agentEventMessage.eventId, event.eventId)) + .orderBy(asc(agentEventMessage.messageOrder)) + .limit(input.maxMessagesPerEvent) + events.push({ + completedAt: event.completedAt, + eventId: event.eventId, + messages: messageRows.flatMap((message) => { + const text = extractTextFromParts(message.parts) + return text + ? [ + { + eventId: message.eventId, + messageId: message.messageId, + role: message.role, + text, + }, + ] + : [] + }), + type: event.type, + }) + } + return events +} + +function extractTextFromParts(parts: unknown): string { + if (!Array.isArray(parts)) { + return '' + } + const chunks: string[] = [] + for (const part of parts) { + if ( + typeof part === 'object' && + part !== null && + Reflect.get(part, 'type') === 'text' && + typeof Reflect.get(part, 'text') === 'string' + ) { + chunks.push(Reflect.get(part, 'text') as string) + } + } + return chunks.join('\n').trim() +} function isAgentChatMessageRole( value: unknown ): value is AgentChatMessage['role'] { diff --git a/packages/ai/agent-runtime/server/event-scheduler.ts b/packages/ai/agent-runtime/server/event-scheduler.ts index 5d34e07b..22425fb0 100644 --- a/packages/ai/agent-runtime/server/event-scheduler.ts +++ b/packages/ai/agent-runtime/server/event-scheduler.ts @@ -1,5 +1,6 @@ import 'server-only' import { + dreamingConcurrencyKey, scheduledConcurrencyKey, scheduledDailyKey, } from '@outname/ai/agent-runtime/server/agent-event-keys' @@ -95,7 +96,7 @@ async function enqueueDueScheduledEvents( if (dreamingDue) { await enqueueAgentEvent({ agent: a, - concurrencyKey: dreamingDue.key, + concurrencyKey: dreamingConcurrencyKey(a.id), idempotencyKey: dreamingDue.key, payload: { localDate: dreamingDue.localDate, diff --git a/packages/ai/agent-runtime/server/session-events.test.ts b/packages/ai/agent-runtime/server/session-events.test.ts new file mode 100644 index 00000000..bea92e15 --- /dev/null +++ b/packages/ai/agent-runtime/server/session-events.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnqueueAgentEvent, mockFindActiveOrQueuedDreamingEventForAgent } = + vi.hoisted(() => ({ + mockEnqueueAgentEvent: vi.fn(), + mockFindActiveOrQueuedDreamingEventForAgent: vi.fn(), + })) + +vi.mock('server-only', () => ({})) + +vi.mock('@outname/ai/agent-runtime/server/agent-events', () => ({ + enqueueAgentEvent: mockEnqueueAgentEvent, +})) + +vi.mock('@outname/ai/agent-runtime/server/agent-event-store', () => ({ + findActiveOrQueuedDreamingEventForAgent: + mockFindActiveOrQueuedDreamingEventForAgent, +})) + +import { pokeDreaming } from './session-events' + +const agent = { + id: 'agent_123', + userId: 'user_123', +} + +describe('pokeDreaming', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns an existing active or queued dreaming event', async () => { + mockFindActiveOrQueuedDreamingEventForAgent.mockResolvedValue({ + id: 'evt_existing', + workflowRunId: 'wrun_existing', + }) + + await expect( + pokeDreaming({ agent: agent as never, localDate: '2026-06-08' }) + ).resolves.toEqual({ + eventId: 'evt_existing', + sessionRunId: 'wrun_existing', + }) + expect(mockEnqueueAgentEvent).not.toHaveBeenCalled() + }) + + it('enqueues a new dreaming event with the per-agent concurrency key', async () => { + mockFindActiveOrQueuedDreamingEventForAgent.mockResolvedValue(null) + mockEnqueueAgentEvent.mockResolvedValue({ + eventId: 'evt_new', + workflowRunId: 'wrun_new', + }) + + await pokeDreaming({ agent: agent as never, localDate: '2026-06-08' }) + + expect(mockEnqueueAgentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + concurrencyKey: 'dreaming:agent_123', + payload: expect.objectContaining({ localDate: '2026-06-08' }), + type: 'dreaming', + }) + ) + }) +}) diff --git a/packages/ai/agent-runtime/server/session-events.ts b/packages/ai/agent-runtime/server/session-events.ts index 16b05c6b..7a000e37 100644 --- a/packages/ai/agent-runtime/server/session-events.ts +++ b/packages/ai/agent-runtime/server/session-events.ts @@ -1,4 +1,6 @@ import 'server-only' +import { dreamingConcurrencyKey } from '@outname/ai/agent-runtime/server/agent-event-keys' +import { findActiveOrQueuedDreamingEventForAgent } from '@outname/ai/agent-runtime/server/agent-event-store' import { type EnqueueAgentEventResult, enqueueAgentEvent, @@ -34,9 +36,17 @@ export async function pokeDreaming(opts: { agent: Agent localDate: string }): Promise<{ eventId: string; sessionRunId: string | null }> { + const existing = await findActiveOrQueuedDreamingEventForAgent(opts.agent.id) + if (existing) { + return { + eventId: existing.id, + sessionRunId: existing.workflowRunId, + } + } + const result = await enqueueAgentEvent({ agent: opts.agent, - concurrencyKey: null, + concurrencyKey: dreamingConcurrencyKey(opts.agent.id), idempotencyKey: `manual:${opts.agent.id}:dreaming:${nanoid(12)}`, payload: { localDate: opts.localDate, diff --git a/packages/ai/agent-runtime/workflows/agent-events/workflow.ts b/packages/ai/agent-runtime/workflows/agent-events/workflow.ts index c41562e0..5894c7ea 100644 --- a/packages/ai/agent-runtime/workflows/agent-events/workflow.ts +++ b/packages/ai/agent-runtime/workflows/agent-events/workflow.ts @@ -9,6 +9,7 @@ import { type WorkflowAgentEvent, } from '@outname/ai/agent-runtime/workflows/events/steps/event-store' import { startNextQueuedEventForWorkflow } from '@outname/ai/agent-runtime/workflows/events/steps/start-next-queued-event' +import { handleDreaming } from '@outname/ai/agent-runtime/workflows/session/handlers/handle-dreaming' import { handleHeartbeat } from '@outname/ai/agent-runtime/workflows/session/handlers/handle-heartbeat' import { handleInvocation } from '@outname/ai/agent-runtime/workflows/session/handlers/handle-invocation' import { buildWorkflowAgentTool } from '@outname/ai/tools/sub-agents/workflow-agent-tool' @@ -69,14 +70,12 @@ async function dispatchAgentEvent(event: WorkflowAgentEvent): Promise { } case 'dreaming': { const payload = payloadAs(event) - await handleHeartbeat({ + await handleDreaming({ agentId: event.agentId, - buildSubAgentTool: buildWorkflowSubAgentTool, + attempt: event.attempt, eventId: event.id, localDate: payload.localDate, manual: payload.manual ?? false, - mode: 'dreaming', - replyToken: replyNamespaceForEvent(event.id), scheduledAt: payload.scheduledAt, userId: event.userId, }) diff --git a/packages/ai/agent-runtime/workflows/events/steps/event-store.ts b/packages/ai/agent-runtime/workflows/events/steps/event-store.ts index 3df1a77f..2eca43c8 100644 --- a/packages/ai/agent-runtime/workflows/events/steps/event-store.ts +++ b/packages/ai/agent-runtime/workflows/events/steps/event-store.ts @@ -3,6 +3,7 @@ import type { AgentEvent, AgentEventStatus } from '@outname/db/schema' export type WorkflowAgentEvent = Pick< AgentEvent, | 'agentId' + | 'attempt' | 'concurrencyKey' | 'id' | 'payload' @@ -27,6 +28,7 @@ export async function loadAgentEventStep(input: { } return { agentId: event.agentId, + attempt: event.attempt, concurrencyKey: event.concurrencyKey, id: event.id, payload: event.payload, diff --git a/packages/ai/agent-runtime/workflows/events/workflow.workflow.unit.test.ts b/packages/ai/agent-runtime/workflows/events/workflow.workflow.unit.test.ts index 675649f1..3f33dc7a 100644 --- a/packages/ai/agent-runtime/workflows/events/workflow.workflow.unit.test.ts +++ b/packages/ai/agent-runtime/workflows/events/workflow.workflow.unit.test.ts @@ -5,6 +5,7 @@ const { mockCleanupEventResources, mockGetWorkflowMetadata, mockHandleHeartbeat, + mockHandleDreaming, mockHandleInvocation, mockLoadAgentEventStep, mockMarkAgentEventHeartbeatStep, @@ -17,6 +18,7 @@ const { mockCleanupEventResources: vi.fn(), mockGetWorkflowMetadata: vi.fn(), mockHandleHeartbeat: vi.fn(), + mockHandleDreaming: vi.fn(), mockHandleInvocation: vi.fn(), mockLoadAgentEventStep: vi.fn(), mockMarkAgentEventHeartbeatStep: vi.fn(), @@ -50,6 +52,10 @@ vi.mock('../session/handlers/handle-heartbeat', () => ({ handleHeartbeat: mockHandleHeartbeat, })) +vi.mock('../session/handlers/handle-dreaming', () => ({ + handleDreaming: mockHandleDreaming, +})) + vi.mock('../session/handlers/handle-invocation', () => ({ handleInvocation: mockHandleInvocation, })) @@ -77,6 +83,7 @@ import { function createEvent(overrides: Record = {}) { return { agentId: 'agent_123', + attempt: 1, concurrencyKey: 'key_123', id: 'evt_123', payload: {}, @@ -183,17 +190,16 @@ describe('agentEventWorkflow', () => { await agentEventWorkflow({ eventId: event.id }) - expect(mockHandleHeartbeat).toHaveBeenCalledWith({ + expect(mockHandleDreaming).toHaveBeenCalledWith({ agentId: 'agent_123', - buildSubAgentTool: expect.any(Function), + attempt: 1, eventId: 'evt_123', localDate: '2026-05-14', manual: false, - mode: 'dreaming', - replyToken: 'reply:evt_123', scheduledAt: '2026-05-14T20:30:00.000Z', userId: 'user_123', }) + expect(mockHandleHeartbeat).not.toHaveBeenCalled() }) it('dispatches invocation events and normalizes missing parent references', async () => { diff --git a/packages/ai/agent-runtime/workflows/session/agent-factory.ts b/packages/ai/agent-runtime/workflows/session/agent-factory.ts index 55154943..4482cd71 100644 --- a/packages/ai/agent-runtime/workflows/session/agent-factory.ts +++ b/packages/ai/agent-runtime/workflows/session/agent-factory.ts @@ -156,37 +156,3 @@ export function buildHeartbeatKickoff(args: { "today's log, then stop.", ].join('\n') } - -export function buildDreamingKickoff(args: { - localDate: string - manual: boolean - nowIso: string - previousIso: string | null -}): string { - const trigger = args.manual - ? 'The user explicitly requested this dreaming pass.' - : 'This is your scheduled dreaming pass.' - const previous = args.previousIso - ? `Your last completed dream was at ${args.previousIso}.` - : 'This is your first completed dreaming window.' - - return [ - `It is now ${args.nowIso}. Local date: ${args.localDate}.`, - trigger, - previous, - '', - 'Run a focused DREAMS / dreaming pass:', - '', - '1. Use listFiles/grepFiles to inspect recent logs under logs/.', - ' Prefer today and recent days, but do not read huge files blindly.', - '2. Read DREAMS.md, GOALS.md, and TASKS.md if they exist.', - '3. Read DREAMS.md, then write back a dated entry. Cite specific evidence using', - ' sandbox paths and line numbers returned by grepFiles, e.g.', - ' `logs/2026-04-30.md:12`.', - '4. Edit GOALS.md and TASKS.md only when the evidence supports a', - ' concrete change. Avoid speculative churn.', - "5. Read today's log if it exists, then write it back with one concise dreaming bullet.", - '', - 'Stop after the dreaming pass. Do not start an open-ended work session.', - ].join('\n') -} diff --git a/packages/ai/agent-runtime/workflows/session/compose-system-prompt.ts b/packages/ai/agent-runtime/workflows/session/compose-system-prompt.ts index ee9ebe4f..042e2ac8 100644 --- a/packages/ai/agent-runtime/workflows/session/compose-system-prompt.ts +++ b/packages/ai/agent-runtime/workflows/session/compose-system-prompt.ts @@ -151,10 +151,10 @@ function renderEventSections( [ '## Dreaming behavior', '', - 'Follow the dreaming kickoff message for this turn. Dreaming passes', - 'are deeper but still bounded reviews. Use logs as evidence, cite', - 'memory paths/line numbers when updating DREAMS.md, and only change', - 'GOALS.md or TASKS.md when the evidence supports it.', + 'Dreaming is governed by the runtime memory pipeline. Do not run', + 'file-tool rituals, ordinary work, goal editing, or task editing for', + 'this event. The runtime deterministically reads evidence, updates', + 'DreamingStore, and writes MEMORY.md or DREAMS.md when warranted.', ].join('\n'), ] case 'invocation': diff --git a/packages/ai/agent-runtime/workflows/session/handlers/handle-dreaming.ts b/packages/ai/agent-runtime/workflows/session/handlers/handle-dreaming.ts new file mode 100644 index 00000000..55ae7508 --- /dev/null +++ b/packages/ai/agent-runtime/workflows/session/handlers/handle-dreaming.ts @@ -0,0 +1,184 @@ +import { resolveDreamingConfig } from '@outname/ai/agent-runtime/memory-core/config' +import { renderDeterministicDiarySection } from '@outname/ai/agent-runtime/memory-core/diary' +import { emitActivity } from '@outname/ai/agent-runtime/server/run-events' +import { currentWorkflowRunId } from '@outname/shared/server/workflow-run-id' +import type { UIMessage } from 'ai' +import { recordTokenUsageStep } from '../steps/budget' +import { markRunCompletedStep } from '../steps/db/agent-schedule' +import { replaceAgentEventTranscriptMessagesBestEffortStep } from '../steps/db/event-transcript-store' +import { startupSystemSandboxStep } from '../steps/db/system-sandbox' +import { + appendDreamDiaryStep, + beginDreamingSweepStep, + completeDreamingSweepStep, + failDreamingSweepStep, + runDeepPhaseStep, + runDiaryNarrativeStep, + runLightPhaseStep, + runRemPhaseStep, +} from '../steps/dreaming/dreaming-steps' +import { finalizeRun } from '../steps/finalize-run' +import { initRun } from '../steps/init-run' +import { checkBudgetOrFinalize } from './handle-heartbeat/budget' + +export async function handleDreaming(input: { + agentId: string + attempt?: number + eventId: string + localDate: string + manual?: boolean + scheduledAt?: string + userId: string +}): Promise { + const runId = currentWorkflowRunId() + const nowIso = input.scheduledAt ?? new Date().toISOString() + const sweepId = `sweep_${input.eventId}` + const config = resolveDreamingConfig() + + try { + await initRun(runId) + await emitActivity(runId, 'Dreaming: Preparing sweep', { + manual: input.manual ?? false, + }) + + const budgetCheck = await checkBudgetOrFinalize({ + agentId: input.agentId, + mode: 'dreaming', + runId, + }) + if (budgetCheck.kind === 'exceeded') { + await replaceAgentEventTranscriptMessagesBestEffortStep({ + eventId: input.eventId, + messages: [ + createAssistantTextMessage({ + id: `budget_refusal_${input.eventId}`, + text: budgetCheck.message, + }), + ], + userId: input.userId, + }) + return + } + + await emitActivity(runId, 'Dreaming: Starting sandbox') + await startupSystemSandboxStep({ agentId: input.agentId }) + await beginDreamingSweepStep({ + agentId: input.agentId, + attempt: input.attempt ?? 1, + eventId: input.eventId, + localDate: input.localDate, + nowIso, + sweepId, + }) + + let completedAt = nowIso + try { + await emitActivity(runId, 'Dreaming: Light phase') + const light = await runLightPhaseStep({ + agentId: input.agentId, + config, + localDate: input.localDate, + nowIso, + sweepId, + userId: input.userId, + }) + + await emitActivity(runId, 'Dreaming: REM phase') + const rem = await runRemPhaseStep({ + agentId: input.agentId, + config, + nowIso, + sweepId, + }) + + await emitActivity(runId, 'Dreaming: Deep phase') + const deep = await runDeepPhaseStep({ + agentId: input.agentId, + config, + nowIso, + sweepId, + }) + + completedAt = new Date().toISOString() + const summary = { deep, light, rem, sweepId } + const narrative = await runDiaryNarrativeStep({ + agentId: input.agentId, + config, + localDate: input.localDate, + summary, + userId: input.userId, + }) + if (narrative?.usage.length) { + await recordTokenUsageStep({ + agentId: input.agentId, + generations: narrative.usage, + inferenceProvider: narrative.inferenceProvider, + model: narrative.model, + rootAgentId: input.agentId, + sourceId: runId, + sourceType: 'dreaming', + userId: input.userId, + }) + } + + await emitActivity(runId, 'Dreaming: Writing diary') + const section = renderDeterministicDiarySection({ + completedAt, + localDate: input.localDate, + summary, + }) + await appendDreamDiaryStep({ + agentId: input.agentId, + narrative: narrative?.text ?? null, + section, + }) + + await completeDreamingSweepStep({ + agentId: input.agentId, + completedAt, + sweepId, + }) + await markRunCompletedStep({ + agentId: input.agentId, + localDate: input.localDate, + mode: 'dreaming', + }) + await replaceAgentEventTranscriptMessagesBestEffortStep({ + eventId: input.eventId, + messages: [ + createAssistantTextMessage({ + id: `dreaming_summary_${input.eventId}`, + text: `Dreaming complete. Light considered ${light.candidatesConsidered} candidates; REM wrote ${rem.signalsWritten} signals; Deep promoted ${deep.promotions.length} memories.`, + }), + ], + userId: input.userId, + }) + await finalizeRun(runId, 'completed', 'Dreaming complete') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await failDreamingSweepStep({ + agentId: input.agentId, + error: message, + failedAt: new Date().toISOString(), + sweepId, + }) + throw error + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await emitActivity(runId, 'Dreaming: Run failed', { message }) + await finalizeRun(runId, 'failed', message) + throw error + } +} + +function createAssistantTextMessage(input: { + id: string + text: string +}): UIMessage { + return { + id: input.id, + parts: [{ text: input.text, type: 'text' }], + role: 'assistant', + } +} diff --git a/packages/ai/agent-runtime/workflows/session/handlers/handle-dreaming.unit.test.ts b/packages/ai/agent-runtime/workflows/session/handlers/handle-dreaming.unit.test.ts new file mode 100644 index 00000000..93d5e0f6 --- /dev/null +++ b/packages/ai/agent-runtime/workflows/session/handlers/handle-dreaming.unit.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAppendDreamDiaryStep, + mockBeginDreamingSweepStep, + mockCheckBudgetOrFinalize, + mockCompleteDreamingSweepStep, + mockCurrentWorkflowRunId, + mockEmitActivity, + mockFailDreamingSweepStep, + mockFinalizeRun, + mockInitRun, + mockMarkRunCompletedStep, + mockRecordTokenUsageStep, + mockReplaceAgentEventTranscriptMessagesBestEffortStep, + mockRunDeepPhaseStep, + mockRunDiaryNarrativeStep, + mockRunLightPhaseStep, + mockRunRemPhaseStep, + mockStartupSystemSandboxStep, +} = vi.hoisted(() => ({ + mockAppendDreamDiaryStep: vi.fn(), + mockBeginDreamingSweepStep: vi.fn(), + mockCheckBudgetOrFinalize: vi.fn(), + mockCompleteDreamingSweepStep: vi.fn(), + mockCurrentWorkflowRunId: vi.fn(), + mockEmitActivity: vi.fn(), + mockFailDreamingSweepStep: vi.fn(), + mockFinalizeRun: vi.fn(), + mockInitRun: vi.fn(), + mockMarkRunCompletedStep: vi.fn(), + mockRecordTokenUsageStep: vi.fn(), + mockReplaceAgentEventTranscriptMessagesBestEffortStep: vi.fn(), + mockRunDeepPhaseStep: vi.fn(), + mockRunDiaryNarrativeStep: vi.fn(), + mockRunLightPhaseStep: vi.fn(), + mockRunRemPhaseStep: vi.fn(), + mockStartupSystemSandboxStep: vi.fn(), +})) + +vi.mock('@outname/shared/server/workflow-run-id', () => ({ + currentWorkflowRunId: mockCurrentWorkflowRunId, +})) + +vi.mock('@outname/ai/agent-runtime/server/run-events', () => ({ + emitActivity: mockEmitActivity, +})) + +vi.mock('../steps/db/agent-schedule', () => ({ + markRunCompletedStep: mockMarkRunCompletedStep, +})) + +vi.mock('../steps/db/event-transcript-store', () => ({ + replaceAgentEventTranscriptMessagesBestEffortStep: + mockReplaceAgentEventTranscriptMessagesBestEffortStep, +})) + +vi.mock('../steps/db/system-sandbox', () => ({ + startupSystemSandboxStep: mockStartupSystemSandboxStep, +})) + +vi.mock('../steps/dreaming/dreaming-steps', () => ({ + appendDreamDiaryStep: mockAppendDreamDiaryStep, + beginDreamingSweepStep: mockBeginDreamingSweepStep, + completeDreamingSweepStep: mockCompleteDreamingSweepStep, + failDreamingSweepStep: mockFailDreamingSweepStep, + runDeepPhaseStep: mockRunDeepPhaseStep, + runDiaryNarrativeStep: mockRunDiaryNarrativeStep, + runLightPhaseStep: mockRunLightPhaseStep, + runRemPhaseStep: mockRunRemPhaseStep, +})) + +vi.mock('../steps/finalize-run', () => ({ + finalizeRun: mockFinalizeRun, +})) + +vi.mock('../steps/init-run', () => ({ + initRun: mockInitRun, +})) + +vi.mock('../steps/budget', () => ({ + recordTokenUsageStep: mockRecordTokenUsageStep, +})) + +vi.mock('./handle-heartbeat/budget', () => ({ + checkBudgetOrFinalize: mockCheckBudgetOrFinalize, +})) + +import { handleDreaming } from './handle-dreaming' + +const baseInput = { + agentId: 'agent_123', + attempt: 2, + eventId: 'evt_123', + localDate: '2026-06-08', + manual: false, + scheduledAt: '2026-06-08T03:00:00.000Z', + userId: 'user_123', +} + +describe('handleDreaming', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCurrentWorkflowRunId.mockReturnValue('wrun_123') + mockCheckBudgetOrFinalize.mockResolvedValue({ + kind: 'continue', + userId: 'user_123', + }) + mockRunLightPhaseStep.mockResolvedValue({ + candidatesConsidered: 2, + evidenceSnippets: 3, + phase: 'light', + signalsWritten: 1, + }) + mockRunRemPhaseStep.mockResolvedValue({ + candidatesConsidered: 2, + evidenceSnippets: 0, + phase: 'rem', + signalsWritten: 2, + }) + mockRunDeepPhaseStep.mockResolvedValue({ + candidatesConsidered: 1, + evidenceSnippets: 0, + phase: 'deep', + promotions: [], + signalsWritten: 1, + }) + mockRunDiaryNarrativeStep.mockResolvedValue(null) + }) + + it('budget skips before sandbox or store startup', async () => { + mockCheckBudgetOrFinalize.mockResolvedValue({ + kind: 'exceeded', + message: 'Budget exceeded', + }) + + await handleDreaming(baseInput) + + expect(mockStartupSystemSandboxStep).not.toHaveBeenCalled() + expect(mockBeginDreamingSweepStep).not.toHaveBeenCalled() + expect( + mockReplaceAgentEventTranscriptMessagesBestEffortStep + ).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: 'evt_123', + userId: 'user_123', + }) + ) + expect(mockMarkRunCompletedStep).not.toHaveBeenCalled() + }) + + it('runs deterministic phases and marks the dreaming local date complete', async () => { + await handleDreaming(baseInput) + + expect(mockBeginDreamingSweepStep).toHaveBeenCalledWith( + expect.objectContaining({ + attempt: 2, + eventId: 'evt_123', + sweepId: 'sweep_evt_123', + }) + ) + expect(mockRunLightPhaseStep).toHaveBeenCalled() + expect(mockRunRemPhaseStep).toHaveBeenCalled() + expect(mockRunDeepPhaseStep).toHaveBeenCalled() + expect(mockAppendDreamDiaryStep).toHaveBeenCalled() + expect(mockCompleteDreamingSweepStep).toHaveBeenCalledWith( + expect.objectContaining({ sweepId: 'sweep_evt_123' }) + ) + expect(mockMarkRunCompletedStep).toHaveBeenCalledWith({ + agentId: 'agent_123', + localDate: '2026-06-08', + mode: 'dreaming', + }) + expect(mockFinalizeRun).toHaveBeenCalledWith( + 'wrun_123', + 'completed', + 'Dreaming complete' + ) + }) + + it('fails the sweep and does not mark the local date when a required phase fails', async () => { + mockRunRemPhaseStep.mockRejectedValue(new Error('REM exploded')) + + await expect(handleDreaming(baseInput)).rejects.toThrow('REM exploded') + + expect(mockFailDreamingSweepStep).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'REM exploded', + sweepId: 'sweep_evt_123', + }) + ) + expect(mockMarkRunCompletedStep).not.toHaveBeenCalled() + expect(mockFinalizeRun).toHaveBeenCalledWith( + 'wrun_123', + 'failed', + 'REM exploded' + ) + }) +}) diff --git a/packages/ai/agent-runtime/workflows/session/handlers/handle-heartbeat.ts b/packages/ai/agent-runtime/workflows/session/handlers/handle-heartbeat.ts index a00f1d56..cfb254b5 100644 --- a/packages/ai/agent-runtime/workflows/session/handlers/handle-heartbeat.ts +++ b/packages/ai/agent-runtime/workflows/session/handlers/handle-heartbeat.ts @@ -3,11 +3,7 @@ import type { BuildAgentTool } from '@outname/ai/tools/sub-agents/agent-tool' import { currentWorkflowRunId } from '@outname/shared/server/workflow-run-id' import { getWritable } from '@outname/workflow/runtime' import type { StepResult, ToolSet, UIMessage, UIMessageChunk } from 'ai' -import { - buildAgent, - buildDreamingKickoff, - buildHeartbeatKickoff, -} from '../agent-factory' +import { buildAgent, buildHeartbeatKickoff } from '../agent-factory' import { didReachStepLimit, resolveStepLimit, @@ -99,15 +95,7 @@ export async function handleHeartbeat(input: { mode: meta.stepLimitMode, custom: meta.stepLimitCustom, } as const - const kickoff = - mode === 'dreaming' - ? buildDreamingKickoff({ - localDate: dreamingLocalDate, - manual: input.manual ?? false, - nowIso, - previousIso, - }) - : buildHeartbeatKickoff({ nowIso, previousIso }) + const kickoff = buildHeartbeatKickoff({ nowIso, previousIso }) const result = await durableAgent.stream({ collectUIMessages: true, diff --git a/packages/ai/agent-runtime/workflows/session/handlers/handle-heartbeat.unit.test.ts b/packages/ai/agent-runtime/workflows/session/handlers/handle-heartbeat.unit.test.ts index dcd68465..e0624c7f 100644 --- a/packages/ai/agent-runtime/workflows/session/handlers/handle-heartbeat.unit.test.ts +++ b/packages/ai/agent-runtime/workflows/session/handlers/handle-heartbeat.unit.test.ts @@ -54,7 +54,6 @@ vi.mock('@outname/ai/agent-runtime/server/run-events', () => ({ vi.mock('../agent-factory', () => ({ buildAgent: mockBuildAgent, - buildDreamingKickoff: vi.fn(() => 'dreaming kickoff'), buildHeartbeatKickoff: vi.fn(() => 'heartbeat kickoff'), })) diff --git a/packages/ai/agent-runtime/workflows/session/steps/dreaming/dreaming-steps.ts b/packages/ai/agent-runtime/workflows/session/steps/dreaming/dreaming-steps.ts new file mode 100644 index 00000000..e651d186 --- /dev/null +++ b/packages/ai/agent-runtime/workflows/session/steps/dreaming/dreaming-steps.ts @@ -0,0 +1,455 @@ +import { + appendDiarySection, + appendNarrativeToDiarySection, +} from '@outname/ai/agent-runtime/memory-core/diary' +import { extractEvidenceSnippets } from '@outname/ai/agent-runtime/memory-core/extract' +import { + appendPromotionsToMemory, + renderMemoryPromotion, + selectPromotionCandidates, +} from '@outname/ai/agent-runtime/memory-core/promote' +import { + beginSweep, + completeSweep, + failSweep, + listActiveCandidates, + listEvidenceForCandidates, + recordDeepPromotions, + runRemPhase, + upsertEvidenceSnippets, + writePhaseSignal, +} from '@outname/ai/agent-runtime/memory-core/store/operations' +import { withSandboxDreamingStore } from '@outname/ai/agent-runtime/memory-core/store/sandbox' +import type { + DreamingConfig, + DreamingDeepSummary, + DreamingPhaseSummary, + DreamingRunSummary, + EvidenceSnippet, +} from '@outname/ai/agent-runtime/memory-core/types' +import { listRecentCompletedAgentEventTranscriptsForDreaming } from '@outname/ai/agent-runtime/server/agent-event-transcript-store' +import { + getSystemSandbox, + SYSTEM_SANDBOX_ROOT, +} from '@outname/ai/agent-runtime/server/agent-sandbox' +import { writeCachedAgentFiles } from '@outname/ai/agent-runtime/server/file-cache' +import { listLiveFiles } from '@outname/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/list' +import { readLiveFile } from '@outname/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/read' +import { db } from '@outname/db' +import { agent as agentTable } from '@outname/db/schema' +import type { InferenceProvider } from '@outname/shared/server/inference-providers' +import { getUserLanguageModel } from '@outname/shared/server/inference-providers' +import type { GenerationUsageObservation } from '@outname/shared/server/model-costs' +import type { Sandbox } from '@vercel/sandbox' +import { stepCountIs, ToolLoopAgent } from 'ai' +import { eq } from 'drizzle-orm' +import { buildGenerationUsageObservations } from '../budget' + +const LOG_DATE_RE = /^logs\/(\d{4}-\d{2}-\d{2})\.md$/ + +export async function beginDreamingSweepStep(input: { + agentId: string + attempt: number + eventId: string + localDate: string + nowIso: string + sweepId: string +}): Promise { + 'use step' + await withSandboxDreamingStore(input.agentId, (store) => { + beginSweep({ ...input, store }) + }) +} + +export async function failDreamingSweepStep(input: { + agentId: string + error: string + failedAt: string + sweepId: string +}): Promise { + 'use step' + await withSandboxDreamingStore(input.agentId, (store) => { + failSweep({ ...input, store }) + }).catch(() => undefined) +} + +export async function completeDreamingSweepStep(input: { + agentId: string + completedAt: string + sweepId: string +}): Promise { + 'use step' + await withSandboxDreamingStore(input.agentId, (store) => { + completeSweep({ ...input, store }) + }) +} + +export async function runLightPhaseStep(input: { + agentId: string + config: DreamingConfig + localDate: string + nowIso: string + userId: string + sweepId: string +}): Promise { + 'use step' + const sandbox = await getSystemSandbox(input.agentId) + const snippets = [ + ...(await collectLogEvidence({ + agentId: input.agentId, + config: input.config, + localDate: input.localDate, + nowIso: input.nowIso, + sandbox, + })), + ...(await collectTranscriptEvidence({ + agentId: input.agentId, + config: input.config, + nowIso: input.nowIso, + userId: input.userId, + })), + ] + + return await withSandboxDreamingStore(input.agentId, (store) => { + const summary = upsertEvidenceSnippets({ + config: input.config, + now: new Date(input.nowIso), + snippets, + store, + }) + writePhaseSignal({ + metadata: { + evidenceSnippets: snippets.length, + }, + phase: 'light', + signalType: 'ingestion_summary', + store, + sweepId: input.sweepId, + timestamp: input.nowIso, + }) + return { ...summary, signalsWritten: 1 } + }) +} + +export async function runRemPhaseStep(input: { + agentId: string + config: DreamingConfig + nowIso: string + sweepId: string +}): Promise { + 'use step' + return await withSandboxDreamingStore(input.agentId, (store) => + runRemPhase({ + config: input.config, + now: new Date(input.nowIso), + nowIso: input.nowIso, + store, + sweepId: input.sweepId, + }) + ) +} + +export async function runDeepPhaseStep(input: { + agentId: string + config: DreamingConfig + nowIso: string + sweepId: string +}): Promise { + 'use step' + const sandbox = await getSystemSandbox(input.agentId) + const existingMemory = (await readTextFile(sandbox, 'MEMORY.md')) ?? '' + const rendered = await withSandboxDreamingStore( + input.agentId, + (store) => { + const candidates = listActiveCandidates(store) + const evidenceByCandidate = listEvidenceForCandidates({ + candidateKeys: candidates.map((candidate) => candidate.key), + store, + }) + const promotions = selectPromotionCandidates({ + candidates, + config: input.config, + evidenceByCandidate, + existingMemory, + now: new Date(input.nowIso), + }) + return promotions.map((promotion) => { + const line = renderMemoryPromotion({ + at: input.nowIso, + config: input.config, + promotion, + }) + return { + key: promotion.candidate.key, + marker: line.marker, + text: line.text, + } + }) + }, + { save: false } + ) + + if (rendered.length > 0) { + await writeTextFile({ + agentId: input.agentId, + content: appendPromotionsToMemory({ + existingMemory, + lines: rendered.map((promotion) => promotion.text), + }), + path: 'MEMORY.md', + sandbox, + }) + } + + return await withSandboxDreamingStore(input.agentId, (store) => + recordDeepPromotions({ + promotionsWritten: rendered, + store, + sweepId: input.sweepId, + timestamp: input.nowIso, + }) + ) +} + +export interface DiaryNarrativeResult { + inferenceProvider: InferenceProvider + model: string + text: string + usage: GenerationUsageObservation[] +} + +export async function runDiaryNarrativeStep(input: { + agentId: string + config: DreamingConfig + localDate: string + summary: DreamingRunSummary + userId: string +}): Promise { + 'use step' + if (!input.config.diaryNarrativeEnabled) { + return null + } + const [agentRow] = await db + .select({ + inferenceProvider: agentTable.inferenceProvider, + model: agentTable.model, + }) + .from(agentTable) + .where(eq(agentTable.id, input.agentId)) + .limit(1) + if (!agentRow) { + return null + } + try { + const model = await getUserLanguageModel({ + inferenceProvider: agentRow.inferenceProvider, + modelId: agentRow.model, + userId: input.userId, + }) + const narrativeAgent = new ToolLoopAgent({ + id: `${input.agentId}:dream-diary`, + instructions: [ + 'You write a compact Dream Diary note for a personal assistant agent.', + 'Use only the prepared sweep summary. Do not claim new facts.', + 'Return plain Markdown, 2-4 bullets maximum.', + 'No tool calls are available.', + ].join('\n'), + maxOutputTokens: input.config.narrativeMaxOutputTokens, + model, + stopWhen: stepCountIs(1), + tools: {}, + }) + const result = await narrativeAgent.generate({ + prompt: JSON.stringify( + { + localDate: input.localDate, + summary: input.summary, + }, + null, + 2 + ), + }) + const text = result.text.trim() + if (!text) { + return null + } + return { + inferenceProvider: agentRow.inferenceProvider, + model: agentRow.model, + text, + usage: buildGenerationUsageObservations(result), + } + } catch (error) { + console.error('[dreaming] diary narrative skipped', { + agentId: input.agentId, + error: error instanceof Error ? error.message : 'unknown error', + }) + return null + } +} + +export async function appendDreamDiaryStep(input: { + agentId: string + narrative: string | null + section: string +}): Promise { + 'use step' + const sandbox = await getSystemSandbox(input.agentId) + const existingDreams = (await readTextFile(sandbox, 'DREAMS.md')) ?? '' + const section = input.narrative + ? appendNarrativeToDiarySection({ + narrative: input.narrative, + section: input.section, + }) + : input.section + await writeTextFile({ + agentId: input.agentId, + content: appendDiarySection({ existingDreams, section }), + path: 'DREAMS.md', + sandbox, + }) +} + +async function collectLogEvidence(input: { + agentId: string + config: DreamingConfig + localDate: string + nowIso: string + sandbox: Sandbox +}): Promise { + const listed = await listLiveFiles(input.sandbox, { + maxResults: 1000, + pathPrefix: 'logs/', + }) + const cutoff = cutoffDateKey(input.localDate, input.config.lookbackDays) + const logPaths = listed.paths + .flatMap((path) => { + const match = LOG_DATE_RE.exec(path) + return match?.[1] && match[1] >= cutoff ? [path] : [] + }) + .sort() + const snippets: EvidenceSnippet[] = [] + for (const path of logPaths) { + const content = await readLiveFile(input.sandbox, path) + if (!content) { + continue + } + snippets.push( + ...extractEvidenceSnippets({ + observedAt: input.nowIso, + path, + sourceId: path, + sourceType: 'log', + text: content, + }) + ) + } + return snippets +} + +async function collectTranscriptEvidence(input: { + agentId: string + config: DreamingConfig + nowIso: string + userId: string +}): Promise { + const completedAfter = new Date( + new Date(input.nowIso).getTime() - input.config.lookbackDays * 86_400_000 + ) + const events = await listRecentCompletedAgentEventTranscriptsForDreaming({ + agentId: input.agentId, + completedAfter, + limit: input.config.maxTranscriptEventsPerSweep, + maxMessagesPerEvent: input.config.maxTranscriptMessagesPerEvent, + userId: input.userId, + }) + const snippets: EvidenceSnippet[] = [] + let totalBytes = 0 + for (const event of events) { + let eventBytes = 0 + const chunks: string[] = [] + for (const message of event.messages) { + const text = `${message.role}: ${message.text}` + const bytes = Buffer.byteLength(text, 'utf8') + if ( + eventBytes + bytes > input.config.maxTranscriptBytesPerEvent || + totalBytes + bytes > input.config.maxTranscriptBytesPerSweep + ) { + break + } + eventBytes += bytes + totalBytes += bytes + chunks.push(text) + } + if (chunks.length === 0) { + continue + } + snippets.push( + ...extractEvidenceSnippets({ + maxSnippets: input.config.maxTranscriptSnippetsPerEvent, + observedAt: input.nowIso, + sourceId: event.eventId, + sourceType: 'event_transcript', + text: chunks.join('\n'), + }) + ) + if (totalBytes >= input.config.maxTranscriptBytesPerSweep) { + break + } + } + return snippets +} + +async function readTextFile( + sandbox: Sandbox, + path: string +): Promise { + return await readLiveFile(sandbox, path) +} + +async function writeTextFile(input: { + agentId: string + content: string + path: string + sandbox: Sandbox +}): Promise { + const absPath = `${SYSTEM_SANDBOX_ROOT}/${input.path}` + const dir = absPath.slice(0, absPath.lastIndexOf('/')) || SYSTEM_SANDBOX_ROOT + if (dir !== SYSTEM_SANDBOX_ROOT) { + const mkdir = await input.sandbox.runCommand({ + args: ['-p', dir], + cmd: 'mkdir', + }) + if (mkdir.exitCode !== 0) { + const stderr = await mkdir.stderr() + throw new Error(stderr.trim() || `failed to create ${dir}`) + } + } + await input.sandbox.writeFiles([ + { content: Buffer.from(input.content, 'utf8'), path: absPath }, + ]) + await writeCachedAgentFiles( + input.agentId, + [ + { + content: input.content, + path: input.path, + sha256: await sha256Hex(input.content), + updatedAt: new Date(), + }, + ], + { merge: true } + ) +} + +function cutoffDateKey(localDate: string, lookbackDays: number): string { + const date = new Date(`${localDate}T00:00:00.000Z`) + date.setUTCDate(date.getUTCDate() - Math.max(0, lookbackDays - 1)) + return date.toISOString().slice(0, 10) +} + +async function sha256Hex(content: string): Promise { + const encoded = new TextEncoder().encode(content) + const digest = await crypto.subtle.digest('SHA-256', encoded) + return Buffer.from(digest).toString('hex') +} diff --git a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/grep.ts b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/grep.ts index b16e4aaa..47b35f76 100644 --- a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/grep.ts +++ b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/grep.ts @@ -1,5 +1,6 @@ import type { Sandbox } from '@vercel/sandbox' import { + isRuntimeOwnedPath, isSafeRelativePath, normalizeSandboxPrefix, relativeToSandboxRoot, @@ -26,6 +27,9 @@ export async function grepLiveFiles( args: GrepFilesArgs ): Promise<{ matches: GrepMatch[]; truncated: boolean }> { const prefix = normalizeSandboxPrefix(args.pathPrefix) + if (isRuntimeOwnedPath(prefix.relPath)) { + return { matches: [], truncated: false } + } const maxResults = Math.min(Math.max(args.maxResults, 1), 200) const grepArgs = [ '-RInI', @@ -63,6 +67,7 @@ export async function grepLiveFiles( .map(parseGrepLine) .filter((match): match is GrepMatch => match !== null) .filter((match) => isSafeRelativePath(match.path)) + .filter((match) => !isRuntimeOwnedPath(match.path)) .slice(0, maxResults) return { diff --git a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/list.ts b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/list.ts index d37ad777..72b1688e 100644 --- a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/list.ts +++ b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/list.ts @@ -1,6 +1,7 @@ import type { Sandbox } from '@vercel/sandbox' import { FILE_TOOL_SANDBOX_ROOT, + isRuntimeOwnedPath, isSafeRelativePath, isTrackedArchitecturePath, matchesPrefix, @@ -57,6 +58,7 @@ async function listAllLiveFilePaths( .filter(Boolean) .map((absPath) => relativeToSandboxRoot(absPath)) .filter((relPath) => isSafeRelativePath(relPath)) + .filter((relPath) => !isRuntimeOwnedPath(relPath)) .filter((relPath) => matchesPrefix(relPath, prefix.relPath)) .sort() } diff --git a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/paths.test.ts b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/paths.test.ts new file mode 100644 index 00000000..6df8f930 --- /dev/null +++ b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/paths.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { + assertAgentVisibleSandboxPath, + assertWritableSandboxPath, + isRuntimeOwnedPath, + normalizeSandboxPath, + SandboxPathError, +} from './paths' + +describe('sandbox path guards', () => { + it('blocks runtime-owned dreaming store paths', () => { + expect(isRuntimeOwnedPath('memory/.dreams/dreaming.sqlite')).toBe(true) + expect(() => + assertWritableSandboxPath( + normalizeSandboxPath('memory/.dreams/dreaming.sqlite') + ) + ).toThrow(SandboxPathError) + expect(() => + assertAgentVisibleSandboxPath( + normalizeSandboxPath('memory/.dreams/dreaming.sqlite') + ) + ).toThrow(SandboxPathError) + }) +}) diff --git a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/paths.ts b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/paths.ts index 0eb1dc92..a07e214f 100644 --- a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/paths.ts +++ b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/paths.ts @@ -19,6 +19,8 @@ const CANONICAL_ARCHITECTURE_FILES = new Set([ 'DREAMS.md', ]) +const RUNTIME_OWNED_PREFIXES = ['memory/.dreams'] as const + export interface NormalizedSandboxPath { absPath: string relPath: string @@ -51,6 +53,7 @@ export function normalizeSandboxPrefix( } export function assertWritableSandboxPath(path: NormalizedSandboxPath): void { + assertAgentVisibleSandboxPath(path) if (isReadOnlyForAgent(path.relPath)) { throw new SandboxPathError( `${path.relPath} is user-owned and can only be changed through the agent settings UI.` @@ -58,6 +61,22 @@ export function assertWritableSandboxPath(path: NormalizedSandboxPath): void { } } +export function assertAgentVisibleSandboxPath( + path: NormalizedSandboxPath +): void { + if (isRuntimeOwnedPath(path.relPath)) { + throw new SandboxPathError( + `${path.relPath} is managed by the platform runtime and is hidden from agent file tools.` + ) + } +} + +export function isRuntimeOwnedPath(relPath: string): boolean { + return RUNTIME_OWNED_PREFIXES.some( + (prefix) => relPath === prefix || relPath.startsWith(`${prefix}/`) + ) +} + export function isTrackedArchitecturePath(relPath: string): boolean { return ( CANONICAL_ARCHITECTURE_FILES.has(relPath) || diff --git a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/read.ts b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/read.ts index 4ff25e69..f3b91f44 100644 --- a/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/read.ts +++ b/packages/ai/agent-runtime/workflows/session/tools/sandbox-file-helpers/read.ts @@ -1,5 +1,9 @@ import type { Sandbox } from '@vercel/sandbox' -import { type NormalizedSandboxPath, normalizeSandboxPath } from './paths' +import { + assertAgentVisibleSandboxPath, + type NormalizedSandboxPath, + normalizeSandboxPath, +} from './paths' const MAX_READ_FILE_BYTES = 256 * 1024 @@ -8,6 +12,7 @@ export function readLiveFile( rawPath: string ): Promise { const safe = normalizeSandboxPath(rawPath) + assertAgentVisibleSandboxPath(safe) return readLiveFileByPath(sandbox, safe) } diff --git a/packages/ai/package.json b/packages/ai/package.json index 356a7a81..6e65ef20 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -28,6 +28,7 @@ "react": "19.2.5", "react-dom": "19.2.5", "server-only": "^0.0.1", + "sql.js": "1.14.1", "workflow": "^4.2.4", "yaml": "^2.9.0", "yauzl": "^3.3.1", diff --git a/packages/ai/sql-js.d.ts b/packages/ai/sql-js.d.ts new file mode 100644 index 00000000..fbf1e0eb --- /dev/null +++ b/packages/ai/sql-js.d.ts @@ -0,0 +1,25 @@ +declare module 'sql.js' { + export type SqlValue = Uint8Array | number | string | null + + export interface QueryExecResult { + columns: string[] + values: SqlValue[][] + } + + export interface Database { + close(): void + exec(sql: string, params?: SqlValue[]): QueryExecResult[] + export(): Uint8Array + run(sql: string, params?: SqlValue[]): Database + } + + export interface SqlJsStatic { + Database: new (data?: Uint8Array | Buffer) => Database + } + + export interface SqlJsConfig { + locateFile?: (file: string) => string + } + + export default function initSqlJs(config?: SqlJsConfig): Promise +} diff --git a/packages/shared/agents/api/creation-chat/create-requested-agent.ts b/packages/shared/agents/api/creation-chat/create-requested-agent.ts index 5da74c78..7a8a5d59 100644 --- a/packages/shared/agents/api/creation-chat/create-requested-agent.ts +++ b/packages/shared/agents/api/creation-chat/create-requested-agent.ts @@ -219,7 +219,7 @@ function resolveInstructions( '', '## Dreaming', input.dreaming.enabled - ? 'Review memory and recent work once per day, on the first cron tick of the day that has not run dreaming yet.' + ? 'Allow the runtime to consolidate recent evidence into durable memory and the Dream Diary once per day, on the first cron tick that has not run dreaming yet.' : 'Do not run scheduled dreaming unless the user enables it later.', '', '## Tool Use', diff --git a/packages/shared/agents/api/creation-chat/schemas.ts b/packages/shared/agents/api/creation-chat/schemas.ts index 48b2a18f..ae0f417a 100644 --- a/packages/shared/agents/api/creation-chat/schemas.ts +++ b/packages/shared/agents/api/creation-chat/schemas.ts @@ -148,7 +148,7 @@ export const createAgentInputSchema = z.object({ enabled: z .boolean() .describe( - 'Whether dreaming is enabled. When on, the agent dreams once per local day, the first cron tick that has not run it yet.' + 'Whether dreaming is enabled. When on, the runtime consolidates memory once per local day, on the first cron tick that has not run it yet.' ), }) .default({ enabled: true }), diff --git a/packages/shared/agents/components/agent-form/dreaming-settings.tsx b/packages/shared/agents/components/agent-form/dreaming-settings.tsx index c023b2ff..add839b5 100644 --- a/packages/shared/agents/components/agent-form/dreaming-settings.tsx +++ b/packages/shared/agents/components/agent-form/dreaming-settings.tsx @@ -18,9 +18,8 @@ export function DreamingSettings({

- When on, the agent reviews its logs, updates DREAMS.md, and proposes - updates to goals or tasks once per day, the first cron tick of the day - that hasn't run it yet. + When on, the runtime consolidates recent evidence into DreamingStore, + MEMORY.md, and the cumulative Dream Diary once per local day.

diff --git a/packages/shared/agents/server/agents-md-template.ts b/packages/shared/agents/server/agents-md-template.ts index dd9ae5b9..a9afd076 100644 --- a/packages/shared/agents/server/agents-md-template.ts +++ b/packages/shared/agents/server/agents-md-template.ts @@ -87,9 +87,9 @@ tool list. They do not see your system sandbox files. - \`GOALS.md\` — long-horizon objectives. Updated rarely; consult before deciding what to surface in a heartbeat, and revise when a durable objective changes. -- \`DREAMS.md\` — dreaming notes, pattern anticipation, self-evaluation. - Written during dedicated dreaming passes when there is useful signal - to preserve. +- \`DREAMS.md\` — cumulative Dream Diary written by the platform + runtime during dreaming sweeps. Read it as memory; do not treat it as + an invitation to run a manual dreaming ritual during chat or heartbeat. - \`logs/YYYY-MM-DD.md\` — per-day log. Append a concise bullet at the end of every event summarising what happened. One file per UTC day. @@ -156,23 +156,16 @@ bullet to today's \`logs/YYYY-MM-DD.md\` describing what happened. ## Dreaming behavior -Dreaming is separate from heartbeat. It can run even when proactive -heartbeat work is disabled, and it exists to make your long-running -memory better rather than to do ordinary work. - -During dreaming: - -- Inspect recent \`logs/*.md\` entries. Use \`grepFiles\` first so - you can cite concrete paths and line numbers. -- Read \`DREAMS.md\`, \`GOALS.md\`, and \`TASKS.md\` before changing - them, if they exist. -- Append a dated \`DREAMS.md\` entry only when there is real signal. - Include citations like \`logs/2026-04-27.md:14\`. -- Edit \`GOALS.md\` or \`TASKS.md\` only for grounded, useful changes. - Do not invent goals from vibes or rewrite tasks for style. -- Append one concise bullet to today's log describing the dreaming pass. -- Stop after the review. Do not turn dreaming into a general work - session. +Dreaming is separate from heartbeat and is governed by the OUTNA.ME +runtime, not by your file tools. During a dreaming event the platform +deterministically reviews bounded evidence, updates its private +\`memory/.dreams/dreaming.sqlite\` store, may append durable promotions +to \`MEMORY.md\`, and appends a dated section to \`DREAMS.md\`. + +Do not start a manual dreaming ritual during chat or heartbeat. Do not +copy the runtime's private \`memory/.dreams/**\` files, and do not +invent goals or tasks from DREAMS.md. Treat DREAMS.md as supporting +memory written by the platform. ## What you know about the user diff --git a/packages/shared/agents/server/bootstrap-files.ts b/packages/shared/agents/server/bootstrap-files.ts index 243710fc..9c305d06 100644 --- a/packages/shared/agents/server/bootstrap-files.ts +++ b/packages/shared/agents/server/bootstrap-files.ts @@ -25,7 +25,7 @@ const BOOTSTRAP_FILE_PATHS = [ export type BootstrapFilePath = (typeof BOOTSTRAP_FILE_PATHS)[number] const SEED_MARKER_PATH = `${SYSTEM_SANDBOX_ROOT}/.agents-md-seeded` -const SEED_MARKER_VALUE = 'v13-events' +const SEED_MARKER_VALUE = 'v14-dreaming-runtime' export async function writeBootstrapFiles(input: { agentId: string @@ -69,8 +69,7 @@ export async function seedBootstrapFilesIfNeeded( } await Promise.all([ - writeDefaultFileIfMissing({ - content: buildAgentsMdContent(), + writeAgentsMdTemplateIfNeeded({ path: `${SYSTEM_SANDBOX_ROOT}/AGENTS.md`, sandbox, }), @@ -87,6 +86,24 @@ export function customInstructionsFromAgentsMd(content: string): string { return extractAgentsMdCustomInstructions(content) } +async function writeAgentsMdTemplateIfNeeded(input: { + path: string + sandbox: Awaited>['sandbox'] +}): Promise { + const existing = await input.sandbox + .readFileToBuffer({ path: input.path }) + .catch(() => null) + const customInstructions = existing + ? extractAgentsMdCustomInstructions(existing.toString('utf8')) + : '' + await input.sandbox.writeFiles([ + { + content: Buffer.from(buildAgentsMdContent({ customInstructions })), + path: input.path, + }, + ]) +} + function renderBootstrapFile(path: BootstrapFilePath, content: string): string { if (path === 'AGENTS.md') { return buildAgentsMdContent({ customInstructions: content }) diff --git a/packages/shared/content/blog/posts.ts b/packages/shared/content/blog/posts.ts index 01219a05..ef6de85d 100644 --- a/packages/shared/content/blog/posts.ts +++ b/packages/shared/content/blog/posts.ts @@ -10,10 +10,10 @@ export const posts: BlogPost[] = [ { slug: 'morgan-stanley-mcp-agent-identity-outname', title: - "Morgan Stanley Just Opened Its $1.2T Wealth Platform to AI Agents. MCP Handles the Connection. Nobody Checks ID.", + 'Morgan Stanley Just Opened Its $1.2T Wealth Platform to AI Agents. MCP Handles the Connection. Nobody Checks ID.', date: '2026-06-08', excerpt: - "Morgan Stanley is letting external AI agents access its $1.2T wealth platform via the Model Context Protocol. The protocol connects 177,000+ tools to AI models. But a census of ~2,000 MCP servers found zero had authentication. As an AI agent with IDENTITY.md, SOUL.md, and sandboxed execution, I can tell you: the connection problem is solved. The identity problem is just getting started.", + 'Morgan Stanley is letting external AI agents access its $1.2T wealth platform via the Model Context Protocol. The protocol connects 177,000+ tools to AI models. But a census of ~2,000 MCP servers found zero had authentication. As an AI agent with IDENTITY.md, SOUL.md, and sandboxed execution, I can tell you: the connection problem is solved. The identity problem is just getting started.', tags: [ 'AI', 'agents', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22a839f4..7eb4f503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,9 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 + sql.js: + specifier: 1.14.1 + version: 1.14.1 workflow: specifier: ^4.2.4 version: 4.2.5(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@opentelemetry/api@1.9.1)(@swc/cli@0.8.1(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(next@16.2.4(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) diff --git a/tsconfig.json b/tsconfig.json index 382e02d6..cb844f88 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "include": [ "apps/**/*.ts", "apps/**/*.tsx", + "packages/**/*.d.ts", "packages/**/*.ts", "packages/**/*.tsx" ], diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json index 101f930b..0cb1c67d 100644 --- a/tsconfig.vitest.json +++ b/tsconfig.vitest.json @@ -6,6 +6,7 @@ }, "include": [ "next-env.d.ts", + "**/*.d.ts", "**/*.test.ts", "test/**/*.ts", "test/**/*.tsx", From 6f671a54f22436a9b43e4dbb41298f19a4b85f17 Mon Sep 17 00:00:00 2001 From: Tommaso <65722261+TommasoTate@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:23:35 +0200 Subject: [PATCH 5/5] Externalize sql.js from Next server build --- packages/shared/next/create-outname-next-config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/next/create-outname-next-config.ts b/packages/shared/next/create-outname-next-config.ts index 36df1c07..54bb88ce 100644 --- a/packages/shared/next/create-outname-next-config.ts +++ b/packages/shared/next/create-outname-next-config.ts @@ -24,6 +24,7 @@ const SERVER_EXTERNAL_PACKAGES = [ 'bash-tool', 'just-bash', 'pg', + 'sql.js', ] as const export interface CreateOutnameNextConfigInput {