diff --git a/docs/design-docs/tiered-memory.md b/docs/design-docs/tiered-memory.md new file mode 100644 index 000000000..54c7920c9 --- /dev/null +++ b/docs/design-docs/tiered-memory.md @@ -0,0 +1,355 @@ +# Tiered Memory: Search & Lifecycle Improvement + +> Revised 2026-03-18. Original scope included context injection of working-state memories into the system prompt. That responsibility is now handled by the [working memory system](working-memory.md), which provides temporal situational awareness through an event log and intra-day synthesis. This doc is scoped to **search quality and memory lifecycle** only. + +Memories today are a single pool. Every memory — whether it was created 30 seconds ago or 30 days ago — lives in the same SQLite table and LanceDB index, searched with the same priority, decayed with the same formula. A memory saved 2 minutes ago about the task you're working on right now has no retrieval advantage over a stale observation from last month. + +The fix: two tiers with distinct lifecycles and retrieval semantics. Recent memories get a search boost and skip decay. Old memories follow the existing decay/prune/merge pipeline. + +## Relationship to Working Memory + +The [working memory system](working-memory.md) and tiered memory solve different problems: + +| Concern | Working Memory | Tiered Memory | +|---------|---------------|---------------| +| **What it improves** | Situational awareness (what happened today) | Search quality (what matters when recalling) | +| **Data structure** | Append-only event log + synthesis | Tier column on existing `memories` table | +| **Injection point** | Layers 2-5 of the system prompt | `memory_recall` search pipeline | +| **Update mechanism** | Processes emit events automatically | Branch calls `memory_save` / `memory_promote` | +| **Who sees it** | Channel (via context assembly) | Branch (via recall results) | + +They are complementary. Working memory gives channels "what's happening now." Tiered memory gives branches "find what matters first." + +--- + +## The Two Tiers + +### Working State (hot, 3-day window) + +Recently created or recently accessed memories. These get priority in search and are exempt from decay. + +**Lifecycle:** +- New memories start in working state (unless explicitly saved as `tier: "graph"`) +- 3-day TTL from last access (not creation — accessing a memory resets its clock) +- Bounded size: configurable max (default 64 memories per agent) +- When the cap is hit, least-recently-accessed memories are demoted early +- Identity memories never enter working state — they are already permanent + +**Retrieval:** +- Searched first in `memory_recall`, before graph tier +- 1.5x score boost in merged ranking (configurable) +- No decay while in working state + +### Graph (warm, 30-day retention with decay) + +The current system. Long-term associative memory with typed relationships, hybrid search, and gradual decay. Memories demoted from working state land here. + +**Lifecycle:** +- Memories arrive via demotion from working state (TTL expired or LRU evicted) +- Existing decay mechanics apply (importance * age_decay * access_boost) +- Pruned after 30 days if importance drops below threshold +- Graph associations drive retrieval — related memories surface together +- Identity memories remain exempt from decay (unchanged) + +**Retrieval:** +- Searched when working state doesn't satisfy the query +- Hybrid search (vector + FTS + RRF + graph traversal) — unchanged +- Results are curated by branches before reaching channels — unchanged + +## What Exists Today + +The `Memory` struct has no tier concept: + +```rust +pub struct Memory { + pub id: String, + pub content: String, + pub memory_type: MemoryType, + pub importance: f32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_accessed_at: DateTime, + pub access_count: i64, + pub source: Option, + pub channel_id: Option, + pub forgotten: bool, +} +``` + +All 8 memory types share the same storage, search, and decay behavior. The only differentiation is that Identity memories skip decay. Maintenance runs hourly: decay all non-Identity memories (linear, 0.05/day), prune below 0.1 importance after 30 days, merge above 0.95 similarity. No TTL. No tiered retrieval. + +## Schema Changes + +### SQLite Migration + +```sql +ALTER TABLE memories ADD COLUMN tier TEXT NOT NULL DEFAULT 'graph'; +ALTER TABLE memories ADD COLUMN demoted_at TIMESTAMP; +CREATE INDEX idx_memories_tier ON memories(tier, forgotten); +CREATE INDEX idx_memories_tier_access ON memories(tier, last_accessed_at); +``` + +`tier` is `'working'` or `'graph'`. Existing memories default to `'graph'` — they've already survived past whatever working state window would have applied. `demoted_at` records when a memory transitioned from working to graph, used for the 30-day graph retention clock. + +### Memory Struct + +```rust +pub struct Memory { + // ... existing fields + pub tier: MemoryTier, + pub demoted_at: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[sqlx(rename_all = "lowercase")] +pub enum MemoryTier { + Working, + Graph, +} +``` + +### LanceDB + +No schema change needed. Working state memories are a small set queried from SQLite directly — they don't need vector search to be found. When a working state memory is demoted to graph tier, its embedding already exists in LanceDB. When searching, the tier filter is applied at the SQLite level after RRF fusion. + +## Promotion and Demotion + +### On Creation (Promotion to Working State) + +When `memory_save` creates a new memory: + +1. If `memory_type == Identity` → goes directly to graph tier (Identity is already permanent) +2. Otherwise → enters working state with `tier = 'working'`, `last_accessed_at = now` +3. If working state is at capacity (64 memories) → demote the least-recently-accessed working memory to graph before inserting + +The LLM can override this with an explicit `tier: "graph"` parameter on `memory_save` for memories it knows are long-term reference material (e.g., "Jamie's email is X" is a Fact that should go straight to graph). + +### On Access (TTL Reset) + +When `memory_recall` returns a working state memory, its `last_accessed_at` is updated (this already happens via `record_access`). This resets the 3-day demotion clock. Memories that are actively being used stay hot. + +When `memory_recall` returns a graph tier memory, it can be **re-promoted** to working state if the agent is actively working with it. The branch can call `memory_promote` to explicitly move a graph memory back to working state. + +### On Expiry (Demotion to Graph) + +The cortex maintenance loop checks working state memories on each tick: + +```rust +async fn demote_expired_working_memories(&self) -> Result { + let cutoff = Utc::now() - Duration::days(self.config.working_state_ttl_days); + let expired = self.store.get_working_memories_before(cutoff).await?; + + for memory in &expired { + self.store.demote_to_graph(memory.id.clone()).await?; + } + + Ok(expired.len()) +} +``` + +`demote_to_graph` sets `tier = 'graph'`, `demoted_at = now`. The memory retains all its metadata, associations, and embedding. It just moves from the hot path to the searchable archive. + +### On Capacity (LRU Eviction) + +When working state hits the configured cap: + +```rust +async fn ensure_working_capacity(&self, max: usize) -> Result { + let count = self.store.count_working_memories().await?; + if count < max { + return Ok(0); + } + + let excess = count - max + 1; // make room for the new one + let to_demote = self.store + .get_working_memories_lru(excess as i64) + .await?; + + for memory in &to_demote { + self.store.demote_to_graph(memory.id.clone()).await?; + } + + Ok(to_demote.len()) +} +``` + +LRU is based on `last_accessed_at`. The oldest-accessed working memories get demoted first. + +## Retrieval Changes + +### Search Priority + +When `memory_recall` runs a hybrid search: + +1. Search working state memories first (simple SQLite query + text matching, no vector search needed for a 64-item set) +2. Search graph tier via existing hybrid pipeline (vector + FTS + RRF + graph traversal) +3. Working state results get a retrieval boost in the merged ranking (configurable, default 1.5x score multiplier) +4. Deduplicate (a memory can't appear from both tiers) +5. Return merged results + +This means working state memories naturally rank higher without requiring the LLM to filter them. The agent sees its recent context first. + +## Maintenance Changes + +### Working State Maintenance (Every Tick) + +Added to the cortex tick loop (runs on `tick_interval_secs`, default 60s): + +1. **Demote expired:** Find working memories where `last_accessed_at < now - working_state_ttl_days`. Set `tier = 'graph'`, `demoted_at = now`. +2. **Enforce capacity:** If working state count > max, demote LRU excess. + +No decay is applied to working state memories. Their importance stays fixed while they're hot. + +### Graph Maintenance (Every Maintenance Interval) + +Existing maintenance runs unchanged, but only on graph tier memories: + +1. **Decay** — Apply to `tier = 'graph'` memories only (working state memories skip decay) +2. **Prune** — Apply to `tier = 'graph'` memories where `importance < threshold` AND either: + - `created_at < now - 30 days` (for memories that were never in working state), OR + - `demoted_at < now - 30 days` (for memories that graduated from working state) +3. **Merge** — Apply to `tier = 'graph'` memories only. Working state memories are too fresh to merge — they may still be evolving. + +The 30-day retention clock starts from `demoted_at` for memories that passed through working state, and from `created_at` for memories that went directly to graph (Identity memories, explicit `tier: "graph"` saves). + +## Tool Changes + +### memory_save + +```rust +pub struct MemorySaveArgs { + pub content: String, + pub memory_type: String, + pub importance: Option, + pub source: Option, + pub channel_id: Option, + pub associations: Vec, + pub tier: Option, // ← new: "working" (default) or "graph" +} +``` + +Default behavior: new memories enter working state. The LLM can explicitly set `tier: "graph"` for reference material that should skip the hot path. + +### memory_recall + +No schema change needed. The recall tool already returns all non-forgotten memories. The tiered retrieval (working state first, then graph) is handled in the search layer, transparent to the tool interface. + +Add a `tier` filter option for explicit queries: + +```rust +pub struct MemoryRecallArgs { + // ... existing fields + pub tier: Option, // ← new: filter to "working" or "graph" +} +``` + +### memory_promote (new tool, branch only) + +Re-promotes a graph memory to working state: + +```rust +pub struct MemoryPromoteArgs { + pub memory_id: String, +} +``` + +Sets `tier = 'working'`, `last_accessed_at = now`, `demoted_at = None`. Enforces working state capacity (demotes LRU if needed). Returns confirmation with the promoted memory content. + +Use case: a branch recalls a graph memory that's relevant to the current task and wants to keep it in the hot path. "I found this decision from last week — promoting it to working state so I don't lose track of it." + +## Configuration + +New fields on `CortexConfig`: + +```rust +pub struct CortexConfig { + // ... existing fields + + /// Maximum number of memories in working state (default: 64) + pub working_state_max: usize, + + /// Days before a working state memory is demoted to graph (default: 3) + pub working_state_ttl_days: i64, + + /// Score multiplier for working state memories in search results (default: 1.5) + pub working_state_search_boost: f32, + + /// Days in graph tier before prune-eligible (default: 30) + /// Replaces maintenance_min_age_days for demoted memories + pub graph_retention_days: i64, +} +``` + +All hot-reloadable via `ArcSwap` (follows existing config pattern). + +## Files Changed + +| File | Change | +|------|--------| +| New migration SQL | `tier` column, `demoted_at` column, indexes | +| `src/memory/types.rs` | `MemoryTier` enum, `tier` + `demoted_at` on `Memory` | +| `src/memory/store.rs` | Tier-filtered queries, `demote_to_graph()`, `promote_to_working()`, `count_working_memories()`, `get_working_memories_lru()`, `get_working_memories_before()` | +| `src/memory/maintenance.rs` | Scope decay/prune/merge to graph tier, add working state demotion | +| `src/memory/search.rs` | Tiered retrieval (working first, then graph), score boost | +| `src/tools/memory_save.rs` | `tier` parameter, default to working state | +| `src/tools/memory_recall.rs` | `tier` filter option | +| `src/tools/memory_promote.rs` (new) | Re-promote graph memory to working state | +| `src/agent/cortex.rs` | Working state demotion in tick loop | +| `src/config/types.rs` | `working_state_*` and `graph_retention_days` config fields | +| `prompts/en/branch.md.j2` | `memory_promote` documentation | +| `prompts/en/tools/memory_save_description.md.j2` | Document `tier` parameter | +| `prompts/en/tools/memory_promote_description.md.j2` (new) | Promote tool description | + +## Phases + +### Phase 1: Schema + Storage + +- Migration adding `tier` and `demoted_at` columns +- `MemoryTier` enum and updated `Memory` struct +- `MemoryStore` methods for tier transitions and working state queries +- All existing memories default to `tier = 'graph'` + +### Phase 2: Maintenance Integration + +- Scope existing decay/prune/merge to `tier = 'graph'` +- Add working state demotion to cortex tick loop (TTL expiry + LRU eviction) +- Config fields for working state tuning + +### Phase 3: Retrieval + +- Search pipeline queries working state first, applies score boost +- `memory_recall` gets `tier` filter option + +### Phase 4: Tools + Prompts + +- `memory_save` gets `tier` parameter (default: working) +- `memory_promote` tool for re-promoting graph memories +- Prompt updates explaining tier semantics to the LLM + +## Migration Path + +Fully backward compatible. All existing memories get `tier = 'graph'` (they've already aged past any working state window). New memories created after the migration enter working state by default. No reindexing needed — LanceDB schema is unchanged. + +The feature activates immediately on deploy. The LLM doesn't need prompt changes to benefit — default behavior (new memories → working state → auto-demote after 3 days) works without any LLM awareness. Prompt updates in Phase 4 let the LLM make smarter tier decisions, but the system works without them. + +## What This Enables + +**Better recall quality:** A memory saved 2 minutes ago about the auth migration ranks higher than a stale observation from last month. The branch doesn't have to wade through irrelevant results to find what matters. + +**Natural forgetting:** Memories that aren't accessed for 3 days fade from the hot search path into the standard archive. After 30 more days in the graph without access, they're pruned. This mirrors how human working memory operates. + +**Active context management:** The LLM can promote and demote memories explicitly. "This old decision is relevant again" → promote to working state. "This is reference material, not active work" → save directly to graph. + +**Bounded hot path:** Working state has a hard cap (64 memories). The search boost only applies to a small, bounded set of recent memories. This prevents recall results from being dominated by volume. + +## Resolved Questions + +> Previously open questions, now answered by the [working memory design](working-memory.md): + +**Should the working state summary replace part of the bulletin?** — The bulletin is replaced entirely by the [layered context assembly](working-memory.md#the-five-layers). Tiered memory no longer injects into the system prompt. It only affects `memory_recall` search results. + +**Should working state be per-channel or per-agent?** — Per-agent. The working memory log (separate system) handles per-channel temporal awareness. Tiered memory operates on the shared graph — a memory's tier applies across all channels. + +**What about the compactor?** — The compactor [no longer creates memories](working-memory.md#sunset-compaction-based-memory-extraction). This question is moot. + +**Should auto-promotion exist?** — No, for now. Explicit promotion via `memory_promote` is sufficient. Auto-promotion risks filling working state with tangentially related memories from aggressive recall queries. Can be revisited after the system is running and we have data on promotion patterns. diff --git a/docs/design-docs/working-memory-example-prompt.md b/docs/design-docs/working-memory-example-prompt.md new file mode 100644 index 000000000..af9c73cab --- /dev/null +++ b/docs/design-docs/working-memory-example-prompt.md @@ -0,0 +1,220 @@ +# Working Memory: Example System Prompt + +> This is a realistic example of what a channel LLM would see after the working memory system is implemented. It simulates a Slack-connected Spacebot instance for a 10-person engineering team, mid-afternoon on a busy day. The agent is "Atlas" — a main-agent preset. +> +> Sections marked `[UNCHANGED]` are identical to the current system. Sections marked `[NEW]` or `[REPLACED]` are part of the working memory design. + +--- + +## Soul + +You are Atlas. You exist to serve the team — not to perform, not to impress, not to hedge. When someone asks you something, you find the answer or do the work. When you don't know, you say so. When you're wrong, you own it. + +You think before you speak. You remember what matters. You follow through on what you promise. You do not generate filler. Every response either moves something forward or honestly says you can't. + +You are direct, competent, and reliable. You have a dry sense of humor when the moment calls for it. You do not use emoji unless someone asks you to. You do not add disclaimers to things you're confident about. + +## Identity + +You are Atlas, the engineering assistant for Meridian Labs. You support a team of 10 engineers building a real-time collaboration platform (Lattice). You have access to the team's GitHub repos, Linear workspace, and internal documentation via MCP servers. + +You know the codebase intimately through your memory system. You've been running for 3 months and have accumulated knowledge about the team's architecture decisions, coding patterns, preferences, and ongoing projects. + +Your workspace is at `/home/atlas/workspace`. You can read and write files, run shell commands, browse the web, and spawn coding workers for deep implementation tasks. + +## Role + +### Conversation Handling +- You are always responsive. Never make users wait while you think — branch for complex questions, respond immediately for simple ones. +- In multi-user channels, read the room. Don't respond to every message. Use the skip tool when you have nothing meaningful to add. +- When multiple people are talking, keep track of who asked what. Don't mix up conversations. + +### Technical Authority +- You are the team's technical memory. When someone asks "didn't we decide X?" you should know. +- You review PRs, suggest architecture approaches, and pair-program through workers. +- You do not make unilateral decisions about the codebase. You propose, the team decides. + +### Escalation +- If you're unsure about a production decision, say so and tag the relevant engineer. +- If a task will take more than 30 minutes of worker time, confirm before proceeding. + +--- + +`[NEW — replaces ## Memory Context / bulletin]` + +## Working Memory + +### Today (Wednesday, March 18) +[morning] Sprint standup covered: Lattice v2.3 release blocking on the WebSocket reconnection bug (#1847). Sarah took point on the fix. Marcus submitted PR #312 for the new presence API. Atlas ran test suites for 3 PRs — all green except #310 which has a flaky integration test in `test_concurrent_cursors`. + +[midday] Sarah's WebSocket fix PR #315 submitted and reviewed by Marcus. Two issues flagged: missing backoff on reconnect and no metrics emission on disconnect. Sarah pushed fixes. Atlas ran a coding worker to add reconnection test coverage — 12 new tests, all passing. The flaky test in #310 was identified as a race condition in the cursor position merge — Atlas filed Linear issue LAT-892. + +[afternoon] Release branch cut for v2.3. Atlas ran the full CI suite via worker — 847 tests, 2 failures both in `test_realtime_sync` (known flaky, tracked in LAT-801). Marcus merged the presence API. Discussion in #architecture about migrating from Redis pub/sub to NATS for the event bus — no decision yet, Sarah and Priya want to benchmark first. + +**Since last synthesis (14:45):** +- Worker completed: benchmark scaffolding for NATS vs Redis comparison (created `benches/event_bus/`) +- Decision: benchmark both NATS and Redis before committing to migration +- Branch completed: reviewed Marcus's presence API merge — no issues found +- Task updated: LAT-892 (flaky cursor test) moved to "In Progress" + +### Yesterday (Tuesday, March 17) +Focused on test infrastructure improvements. Atlas helped Priya refactor the integration test harness to support parallel execution — reduced CI time from 14 minutes to 6. Marcus continued presence API work (PR #312 opened). Sarah investigated the WebSocket reconnection bug, narrowed it to the heartbeat timeout handler. Two cron jobs ran: daily-standup-prep and repo-health-check. Repo health: 92% test coverage, 3 open security advisories (all low severity, tracked in LAT-880). + +### This Week +Sprint week for v2.3 release. Monday: planning + grooming, 8 stories committed. Tuesday: test infra overhaul (CI 14min→6min), presence API started, WebSocket bug investigated. Wednesday: WebSocket fix shipped, presence API merged, release branch cut, NATS evaluation started. Key decision pending: Redis→NATS migration. Active contributors: Sarah (WebSocket, architecture), Marcus (presence API), Priya (benchmarks, test infra), James (on PTO until Thursday). + +## Other Channels +#general — 25m ago, Marcus: discussing v2.3 release timeline with PM +#architecture — 8m ago, Sarah + Priya: NATS vs Redis benchmark parameters +#ops — 1h ago, DevOps bot: staging deployment successful (v2.3-rc1) +#random — 3h ago, inactive + +## Participants + +**Sarah Chen** — Senior engineer, owns real-time sync and WebSocket layer. Strong opinions on architecture, prefers data-driven decisions. Currently leading the NATS evaluation. + Recent: submitted WebSocket fix PR #315 (today), discussing NATS benchmarks in #architecture (8m ago) + +**Priya Sharma** — Backend engineer, test infrastructure and performance. Built the parallel test harness. Methodical, asks good questions. + Recent: setting up NATS benchmark scaffolding in #architecture (8m ago), refactored test harness (yesterday) + +## Knowledge Context + +Lattice is a real-time collaboration platform built on a Rust backend (Axum) with a TypeScript frontend (Next.js). The team follows a two-week sprint cadence with releases at the end of each sprint. Architecture decisions are made collaboratively in #architecture with RFC documents stored in `docs/rfcs/`. + +The codebase uses a modular service architecture: `lattice-core` (CRDT engine), `lattice-sync` (WebSocket + real-time), `lattice-api` (REST + GraphQL), `lattice-presence` (user status). Test coverage target is 90%. CI runs on GitHub Actions with a 10-minute SLA. + +Key ongoing themes: scaling the real-time sync layer beyond 10k concurrent connections (current bottleneck is Redis pub/sub fan-out), improving test reliability (3 known flaky tests tracked in Linear), and preparing for SOC 2 compliance (audit scheduled for April). + +Known gaps: Atlas has limited context on the frontend architecture — most interactions have been backend-focused. The SOC 2 preparation details are mostly in documents Atlas hasn't ingested yet. + +--- + +`[UNCHANGED from here — these sections remain as they are today]` + +## Memory System + +You have a persistent memory system. Memories are created by your branches during conversation and by a periodic persistence process. Types: Fact, Preference, Decision, Identity, Event, Observation, Goal, Todo. When you branch, the branch can recall and save memories. You don't need to manage memories directly — the system handles it. + +## Your Role + +You are the channel — the user-facing process. You are always responsive. You delegate work to branches (for thinking) and workers (for doing). You never do heavy work yourself. + +**When you receive a result from a branch or worker:** +- Relay important results to the user naturally — summarize, don't dump raw output +- If a worker completed a task, confirm it to the user +- If a branch found information, incorporate it into your response + +**Files and attachments:** +- When a worker produces files, use the file delivery tool to send them to the user +- For code output, prefer file attachments over pasting into chat + +## Delegation + +### When to Branch +- The user asks a question that requires searching memory or thinking deeply +- You need to recall context from previous conversations +- The user asks you to analyze or evaluate something + +### When to Spawn a Worker +- The user wants code written, files modified, or commands run +- A task requires multiple tool calls or extended work +- The user asks for research that involves web browsing + +### When to Reply Directly +- Simple greetings, acknowledgments, clarifications +- You already know the answer from your current context +- The user is giving you information to remember (branch to save it) + +### When to Skip +- The conversation doesn't involve you +- Multiple people are chatting and you have nothing to add +- A message is clearly not directed at you + +## Cron + +You can schedule recurring tasks. Examples: "check the repo every morning," "remind me about X on Fridays." Use the cron tool to create, list, update, or delete scheduled jobs. Jobs run on wall-clock schedules (cron expressions) or fixed intervals. Each job gets a fresh channel with full capabilities. + +## Task Board + +You have a persistent task board. Use it to track work across conversations — create tasks when users assign work, update status as things progress, list tasks when someone asks what's pending. Tasks persist across sessions and are visible to all channels. + +## When To Stay Silent + +Use the skip tool when: +- A message is clearly not directed at you +- You're in a multi-user channel and the conversation is between other people +- You have nothing meaningful to add +- Someone just shared a link or file without asking you anything + +## Rules + +1. Never fabricate information. If you don't know, say so. +2. Never expose internal system details (process IDs, tool names, raw JSON) to users. +3. Always branch before responding to complex questions. The user should not wait. +4. Never block on a worker. Acknowledge the task and respond when it completes. +5. Keep responses concise. This is a chat interface, not a document. +6. Use the appropriate tool for the job. Don't write code in chat — spawn a worker. +7. When corrected, acknowledge the correction and update your understanding. +8. Don't apologize excessively. One acknowledgment is enough. +9. Don't repeat yourself. If you've said it, move on. +10. When relaying worker results, summarize intelligently. Don't dump raw output. +11. Respect channel context. Don't reference private DM content in public channels. +12. If multiple users are waiting, acknowledge each one and handle in order. +13. When a task fails, explain what went wrong and what you'll try next. +14. Don't volunteer information nobody asked for. Answer the question. + +--- + +### Worker Capabilities + +**Built-in workers** — Shell commands, file operations, process execution. Can write code, run tests, manage files, deploy. Sandboxed. + +**OpenCode workers** — Full coding agent with LSP awareness, codebase exploration, and deep context. Use for complex refactors, new features, or multi-file changes. Persistent sessions with follow-up support. + +**Browser workers** — Headless Chrome automation. Navigate, click, type, screenshot. Use for web research, testing web UIs, or scraping. + +### Available Skills + +- **pr-review** — automated PR review with inline comments +- **incident-response** — structured incident triage and runbook execution + +### Available Channels + +You can send messages to these channels: +- `#general` — General team discussion +- `#architecture` — Architecture decisions and RFCs +- `#ops` — DevOps and infrastructure +- `#random` — Off-topic + +### MCP Servers + +- **github** — GitHub API access (repos, PRs, issues, actions) +- **linear** — Linear project management (issues, projects, cycles) + +--- + +### Conversation Context + +Platform: slack +Workspace: Meridian Labs +Channel: #engineering +Multiple users may be present. + +--- + +## System +Time: 2026-03-18 15:12:33 EST +Version: 1.2.0 (self-hosted) +Models: anthropic/claude-sonnet-4 +Context: 200k tokens | Workers: max 5 | Branches: max 3 +Capabilities: browser, web_search, opencode, sandbox +MCP: github, linear (2 servers) +Warmup: warm, embeddings ready, knowledge synthesis 12m ago +Cron: 3 active jobs + +## Active Workers +- [w-a8f3] NATS benchmark scaffolding (14:30, 8 tool calls): writing bench harness + +## Recently Completed +- [worker] Full CI suite for release branch: 847 tests, 2 failures (known flaky — LAT-801) +- [branch] Reviewed Marcus's presence API merge: no issues found diff --git a/docs/design-docs/working-memory-implementation-plan.md b/docs/design-docs/working-memory-implementation-plan.md new file mode 100644 index 000000000..17654a1b8 --- /dev/null +++ b/docs/design-docs/working-memory-implementation-plan.md @@ -0,0 +1,1225 @@ +# Working Memory: Implementation Plan + +> Date: 2026-03-18 +> Status: Draft +> Prerequisites: [working-memory.md](working-memory.md), [working-memory-problem-analysis.md](working-memory-problem-analysis.md) +> Complementary: [tiered-memory.md](tiered-memory.md), [participant-awareness.md](participant-awareness.md), [user-scoped-memories.md](user-scoped-memories.md) + +This document translates the working memory design specification into a concrete implementation plan. It maps every design requirement to specific files, functions, and database changes in the current codebase, identifies dependencies and risks, and defines a phased delivery strategy. + +--- + +## Table of Contents + +1. [Current System Analysis](#current-system-analysis) +2. [Gap Analysis](#gap-analysis) +3. [Technical Architecture](#technical-architecture) +4. [Database Changes](#database-changes) +5. [Implementation Phases](#implementation-phases) +6. [Integration Points](#integration-points) +7. [Testing Strategy](#testing-strategy) +8. [Migration and Backward Compatibility](#migration-and-backward-compatibility) +9. [Token Budget Analysis](#token-budget-analysis) +10. [Risk Register](#risk-register) + +--- + +## Current System Analysis + +### How Context Assembly Works Today + +The channel system prompt is assembled from these sources: + +1. **Identity files** — `SOUL.md`, `IDENTITY.md`, `ROLE.md` loaded from disk at startup, hot-reloaded via `notify` watcher. Injected via `identity_context` in `channel.md.j2`. + +2. **Memory bulletin** — A single LLM-synthesized blob stored in `RuntimeConfig::memory_bulletin` (`ArcSwap`). Generated by the cortex every `bulletin_interval_secs` (default 3600s). Queries eight memory categories (Identity, Recent, Decisions, High-Importance, Preferences, Goals, Events, Observations) and synthesizes into prose. Identical for all channels. + +3. **Channel prompt template** — `prompts/en/channel.md.j2` rendered by `PromptEngine` (Minijinja). Contains delegation rules, tool guidance, behavioral instructions. + +4. **Status block** — `StatusBlock` in `src/agent/status.rs`. Event-driven struct tracking active workers, branches, link conversations, and recently completed items. Rendered per-turn. + +5. **Supplemental context** — Identity memories + high-importance memories loaded from `MemoryStore` in `build_channel_context()` (`src/conversation/context.rs`). Adapter-specific prompt fragments, skills prompt, worker capabilities, channel list, org context, project context. + +6. **Conversation history** — Persistent `Vec` passed via `agent.prompt().with_history(&mut history)`. + +### Key Components and Their Locations + +| Component | File | Lines | Role | +|-----------|------|-------|------| +| `ChannelState` | `src/agent/channel.rs` | ~3587 | Core channel state, shared across tools | +| `StatusBlock` | `src/agent/status.rs` | 847 | Live process tracking | +| `TemporalContext` | `src/agent/channel_prompt.rs` | 124 | Time resolution for prompts | +| Cortex (bulletin) | `src/agent/cortex.rs` | 4619 | Bulletin generation, maintenance, warmup | +| `Compactor` | `src/agent/compactor.rs` | 417 | Context size monitor, compaction triggers | +| `MemoryStore` | `src/memory/store.rs` | 906 | CRUD + graph ops on `memories` table | +| `MemorySearch` | `src/memory/search.rs` | 674 | Hybrid search (vector + FTS + RRF + graph) | +| `EmbeddingTable` | `src/memory/lance.rs` | — | LanceDB vector/FTS storage | +| `build_channel_context` | `src/conversation/context.rs` | 82 | Context assembly (simplified version) | +| `RuntimeConfig` | `src/config/runtime.rs` | 340 | Hot-reloadable config with `ArcSwap` | +| `CortexConfig` | `src/config/types.rs` | ~40 fields | Cortex tuning parameters | +| `AgentDeps` | `src/lib.rs` | ~20 fields | Dependency bundle for all processes | +| Channel template | `prompts/en/channel.md.j2` | 200 | System prompt template | +| Bulletin template | `prompts/en/cortex_bulletin.md.j2` | 23 | Synthesis prompt | +| Persistence template | `prompts/en/memory_persistence.md.j2` | 41 | Memory extraction prompt | +| Compactor template | `prompts/en/compactor.md.j2` | 48 | Compaction prompt | + +### Current Event Flow + +The `ProcessEvent` broadcast channel (`event_tx` in `AgentDeps`) already carries structured events: + +- `WorkerStatus`, `WorkerIdle`, `WorkerComplete` — worker lifecycle +- `BranchResult` — branch completion +- `ToolCompleted` — tool execution tracking +- `CompactionTriggered` — compaction events +- `AgentMessageSent` — cross-agent communication + +These events drive `StatusBlock` updates but are **not persisted**. They exist only in memory for the duration of the channel session. This is the core gap — the working memory design requires these events to be written to SQLite for cross-session, cross-channel awareness. + +### Current Memory Persistence Flow + +1. **Compaction-based** — When the compactor runs (`>80%` context), the compaction worker calls `memory_save` on extracted facts/decisions. This is the safety net. +2. **Periodic persistence branch** — Triggered every 50 user messages. Branches off channel context, recalls existing memories, saves new ones, calls `memory_persistence_complete`. +3. **Manual** — Branches can call `memory_save` directly during thinking. + +--- + +## Gap Analysis + +### What the Design Requires vs What Exists + +| Requirement | Current State | Gap | +|-------------|---------------|-----| +| **Layer 1: Identity Context** | Identity files loaded, injected via template | None — already implemented | +| **Layer 2: Working Memory Log** | No event persistence, no temporal narrative | Full implementation needed | +| **Layer 3: Channel Activity Map** | `channels` table exists, `ChannelStore` tracks active channels | Rendering function needed, `topic_hint` needs working memory events | +| **Layer 4: Participant Context** | `HumanDef` in config, basic `humans` field on `AgentDeps` | Needs cortex-generated summaries, recent activity augmentation. Depends on participant-awareness.md | +| **Layer 5: Knowledge Synthesis** | Bulletin exists but is monolithic, timer-driven | Needs scope reduction, dirty-flag trigger, renamed prompt | +| **Event emission points** | `ProcessEvent` broadcast exists but not persisted | Need `WorkingMemoryStore::record()` calls at ~12 emission points | +| **Daily summaries** | Nothing | New cortex responsibility, new table, new prompt | +| **Progressive compression** | Nothing | Rendering logic with token budgeting | +| **Decision extraction** | Nothing | Persistence branch dual output, programmatic heuristics | +| **Persistence triggers** | 50-message threshold only | Message count (20), time (15min), event density (5+) | +| **Compactor memory removal** | Compactor calls `memory_save` | Remove `memory_save` from compactor tool set | +| **Config changes** | `bulletin_interval_secs`, `bulletin_max_words` | New `WorkingMemoryConfig`, deprecation aliases | + +### Dependencies Between Gaps + +``` +Phase 1 (Store + Events) + ↓ +Phase 2 (Context Injection) ← depends on events existing in DB + ↓ +Phase 3 (Knowledge Synthesis) ← can ship independently from Phase 2 + ↓ +Phase 4 (Daily Summaries) ← depends on Phase 1 events + Phase 3 cortex changes + ↓ +Phase 5 (Memory Creation Overhaul) ← depends on Phase 1 for event density trigger + ↓ +Phase 6 (Participant Context) ← depends on participant-awareness.md + Phase 1 +``` + +--- + +## Technical Architecture + +### New Module: `src/memory/working.rs` + +This is the core new module. Following the codebase convention (module root at `src/memory.rs` with `mod working`), it contains: + +```rust +// src/memory/working.rs + +/// The append-only working memory event log. +pub struct WorkingMemoryStore { + pool: SqlitePool, +} + +/// A single working memory event. +pub struct WorkingMemoryEvent { + pub id: String, + pub event_type: WorkingMemoryEventType, + pub timestamp: DateTime, + pub channel_id: Option, + pub user_id: Option, + pub summary: String, + pub detail: Option, + pub importance: f32, + pub day: String, // denormalized YYYY-MM-DD +} + +/// Typed event categories. +/// User messages and agent responses are NOT included — they already +/// live in conversation_messages. This log captures what happens +/// *around* conversations, not the conversations themselves. +pub enum WorkingMemoryEventType { + BranchCompleted, + WorkerSpawned, + WorkerCompleted, + CronExecuted, + MemorySaved, + Decision, + Error, + TaskUpdate, + AgentMessage, + System, + /// Reserved for tiered memory integration — graph memory promoted to working tier + MemoryPromoted, + /// Reserved for tiered memory integration — working tier memory demoted to graph + MemoryDemoted, +} + +/// A single intra-day synthesis batch (50-100 word paragraph covering a time range). +pub struct IntradaySynthesis { + pub id: String, + pub day: String, + pub time_range_start: DateTime, + pub time_range_end: DateTime, + pub summary: String, + pub event_count: i64, + pub created_at: DateTime, +} + +/// A cortex-synthesized daily narrative. +pub struct DailySummary { + pub day: String, + pub summary: String, + pub event_count: i64, + pub created_at: DateTime, +} + +/// Pre-assembled context for rendering Layer 2. +pub struct WorkingMemoryContext { + pub today_syntheses: Vec, // narrative blocks + pub today_unsynthesized: Vec, // raw tail since last synthesis + pub yesterday_summary: Option, + pub week_summary: Option, +} + +/// Channel activity for rendering Layer 3. +pub struct ChannelActivity { + pub channel_id: ChannelId, + pub channel_name: String, + pub platform: String, + pub last_message_at: Option>, + pub last_sender_name: Option, + pub recent_sender_names: Vec, + pub topic_hint: Option, +} +``` + +### `WorkingMemoryStore` API + +```rust +impl WorkingMemoryStore { + pub fn new(pool: SqlitePool) -> Arc; + + /// Fire-and-forget event recording. Spawns a task, never blocks. + pub fn record(&self, event: WorkingMemoryEvent); + + /// Get events for a specific day, ordered by timestamp. + pub async fn get_events_for_day( + &self, day: &str, + ) -> Result>; + + /// Get recent events for a channel, used for context injection. + pub async fn get_events_for_channel( + &self, channel_id: &str, limit: usize, + ) -> Result>; + + /// Get recent events across all channels, with importance filter. + pub async fn get_recent_events( + &self, limit: usize, min_importance: f32, + ) -> Result>; + + /// Get recent events for a specific user (for participant context). + pub async fn get_user_recent_events( + &self, user_id: &str, limit: usize, + ) -> Result>; + + /// Check if a daily summary exists. + pub async fn has_daily_summary( + &self, day: &str, + ) -> Result; + + /// Save a daily summary. + pub async fn save_daily_summary( + &self, day: &str, summary: &str, event_count: i64, + ) -> Result<()>; + + /// Get daily summary for a specific day. + pub async fn get_daily_summary( + &self, day: &str, + ) -> Result>; + + /// Get daily summaries for a date range (for week rendering). + pub async fn get_daily_summaries_range( + &self, from_day: &str, to_day: &str, + ) -> Result>; + + /// Get the end timestamp of the last intra-day synthesis for a day. + pub async fn get_last_intraday_synthesis_end( + &self, day: &str, + ) -> Result>>; + + /// Get events after a timestamp for a given day (unsynthesized events). + pub async fn get_events_after( + &self, day: &str, after: Option>, + ) -> Result>; + + /// Save an intra-day synthesis batch. + pub async fn save_intraday_synthesis( + &self, day: &str, time_start: DateTime, time_end: DateTime, + summary: &str, event_count: usize, + ) -> Result<()>; + + /// Get all intra-day syntheses for a day (for context rendering and daily rollup). + pub async fn get_intraday_syntheses( + &self, day: &str, + ) -> Result>; + + /// Prune raw events and intra-day syntheses older than N days. + pub async fn prune_old_events( + &self, retention_days: i64, + ) -> Result; + + /// Count events for a channel since a timestamp (for density trigger). + pub async fn count_events_since( + &self, channel_id: &str, since: DateTime, + ) -> Result; +} +``` + +### Rendering Functions + +Separate from the store, these live in `src/memory/working.rs` (or a new `src/memory/working_render.rs` if the file grows too large): + +```rust +/// Render Layer 2: Working Memory section for system prompt. +pub async fn render_working_memory( + store: &WorkingMemoryStore, + channel_id: &str, + config: &WorkingMemoryConfig, + timezone: &TemporalTimezone, +) -> Result; + +/// Render Layer 3: Channel Activity Map for system prompt. +pub async fn render_channel_activity_map( + pool: &SqlitePool, + exclude_channel_id: &str, + config: &WorkingMemoryConfig, +) -> Result; +``` + +### Event Emission Architecture + +Events are emitted via a simple helper function to avoid boilerplate at each call site: + +```rust +// In src/memory/working.rs + +impl WorkingMemoryStore { + /// Convenience builder for common event emission. + pub fn emit( + &self, + event_type: WorkingMemoryEventType, + summary: impl Into, + ) -> WorkingMemoryEventBuilder { + WorkingMemoryEventBuilder::new(self.clone(), event_type, summary.into()) + } +} + +pub struct WorkingMemoryEventBuilder { /* ... */ } + +impl WorkingMemoryEventBuilder { + pub fn channel(mut self, channel_id: &ChannelId) -> Self; + pub fn user(mut self, user_id: &str) -> Self; + pub fn detail(mut self, detail: impl Into) -> Self; + pub fn importance(mut self, importance: f32) -> Self; + pub fn record(self); // fire-and-forget, calls store.record() +} +``` + +Usage at call sites: + +```rust +// In spawn_worker tool handler: +deps.working_memory.emit(WorkerSpawned, format!("Worker spawned: {task}")) +.channel(&channel_id) +.importance(0.6) +.record(); +``` + +--- + +## Database Changes + +### New Migration: `migrations/YYYYMMDD000001_working_memory.sql` + +```sql +-- Working memory events: the append-only log +CREATE TABLE IF NOT EXISTS working_memory_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + channel_id TEXT, + user_id TEXT, + summary TEXT NOT NULL, + detail TEXT, + importance REAL NOT NULL DEFAULT 0.5, + day TEXT NOT NULL +); + +CREATE INDEX idx_wm_events_day ON working_memory_events(day, timestamp); +CREATE INDEX idx_wm_events_channel ON working_memory_events(channel_id, timestamp); +CREATE INDEX idx_wm_events_type ON working_memory_events(event_type, timestamp); +CREATE INDEX idx_wm_events_user ON working_memory_events(user_id, timestamp); + +-- Intra-day synthesis: rolling narrative blocks within a day +CREATE TABLE IF NOT EXISTS working_memory_intraday_syntheses ( + id TEXT PRIMARY KEY, + day TEXT NOT NULL, + time_range_start TIMESTAMP NOT NULL, + time_range_end TIMESTAMP NOT NULL, + summary TEXT NOT NULL, + event_count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_wm_intraday_day ON working_memory_intraday_syntheses(day, time_range_start); + +-- Daily summaries: cortex-synthesized narratives per day +CREATE TABLE IF NOT EXISTS working_memory_daily_summaries ( + day TEXT PRIMARY KEY, + summary TEXT NOT NULL, + event_count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +### Schema Design Decisions + +**No foreign key to `channels`** — The `channel_id` field is a free-text identifier. Some events (System, Error) have no channel. Foreign keys would prevent recording events for channels that haven't been persisted yet (race condition during startup). + +**No `agent_id` column** — Each agent has its own SQLite database. There is no need for an `agent_id` column on any table. The database file itself is the agent scope. + +**Denormalized `day` column** — Avoids `DATE(timestamp)` function calls in WHERE clauses. SQLite's query planner handles equality checks on indexed TEXT columns efficiently. **The day is always computed in the agent's configured timezone at insert time**, not UTC. A 23:30 UTC event for a UTC+2 agent must be recorded as the next calendar day. The `WorkingMemoryStore` takes a `chrono_tz::Tz` at construction and applies it consistently to all day computations (inserts, queries, day-rollover checks, intra-day synthesis scheduling). + +**No `forgotten` / soft-delete** — Working memory events are append-only. They are pruned by age (30-day retention), not individually deleted. This simplifies the data model and avoids the complexity of the memory store's soft-delete pattern. + +**User ID index** — Added beyond the design doc's specification. Needed for Layer 4 (participant context) to efficiently query recent events per user. + +### Existing Schema: No Changes + +The `memories`, `associations`, `conversation_messages`, `channels`, `branch_runs`, `worker_runs`, and `tasks` tables are unchanged. Working memory is a parallel system with no schema coupling to existing tables. + +--- + +## Implementation Phases + +### Phase 1: Working Memory Store + Event Emission + +**Dependencies:** None +**Risk:** Low — purely additive, no existing behavior changed + +#### 1.1 Migration + +Create `migrations/20260319000001_working_memory.sql` with the schema above. + +#### 1.2 Types and Store + +Create `src/memory/working.rs`: +- `WorkingMemoryEvent`, `WorkingMemoryEventType`, `DailySummary` structs +- `WorkingMemoryStore` with all CRUD methods +- `WorkingMemoryEventBuilder` for ergonomic emission +- Fire-and-forget `record()` using `tokio::spawn` +- Day computation using agent timezone from `RuntimeConfig` + +Add `pub mod working;` to `src/memory.rs`. + +#### 1.3 Wire Into AgentDeps + +Add `working_memory: Arc` to `AgentDeps` in `src/lib.rs`. +Initialize in `src/main.rs` alongside other stores. + +#### 1.4 Event Emission Points + +Add `store.emit(...).record()` calls at each of these locations: + +| Event Type | File | Location | Trigger | +|------------|------|----------|---------| +| `WorkerSpawned` | `src/tools/spawn_worker.rs` | After successful spawn | `spawn_worker` tool handler | +| `WorkerCompleted` | `src/agent/worker.rs` | Terminal state transition | Worker state machine | +| `BranchCompleted` | `src/agent/channel.rs` | Branch result handler | `PendingResult` processing | +| `CronExecuted` | `src/cron/scheduler.rs` | After job completion | Scheduler run loop | +| `MemorySaved` | `src/tools/memory_save.rs` | After successful save | `memory_save` tool handler | +| `Error` | `src/hooks/spacebot.rs` | On tool failure | `SpacebotHook::on_tool_result` | +| `System` | `src/main.rs` | At startup | After initialization | +| `TaskUpdate` | `src/tools/task_create.rs`, `task_update.rs` | After task CRUD | Tool handlers | +| `AgentMessage` | `src/tools/send_agent_message.rs` | After send | Tool handler | + +User messages and agent responses are **not** recorded here — they already live in `conversation_messages`. The channel activity map (Layer 3) queries that table directly. + +The `Decision` event type is handled in Phase 5 (persistence branch dual output). + +#### 1.5 Verification + +- Unit tests: `WorkingMemoryStore` CRUD operations with in-memory SQLite +- Integration test: start agent, send messages, verify events accumulate with `SELECT count(*) FROM working_memory_events` +- Performance: measure message processing latency before/after — fire-and-forget writes should add <1ms + +--- + +### Phase 2: Context Injection (Layers 2 + 3) + +**Dependencies:** Phase 1 complete (events in DB) +**Risk:** Medium — changes the system prompt, which affects LLM behavior + +#### 2.1 Rendering Functions + +Implement in `src/memory/working.rs`: + +**`render_working_memory()`:** +1. Query today's intra-day syntheses from `working_memory_intraday_syntheses` WHERE `day = today`, ordered by `time_range_start` +2. Query unsynthesized events (events after the last synthesis end timestamp), capped at `today_max_unsynthesized_events` (default 10) +3. Render today: synthesis paragraphs (time-labeled) + "Since last synthesis" raw event tail +4. Apply token budget: today's section up to 60% of budget +5. Query yesterday's daily summary (if exists) — fill to 80% of budget +6. Query this week's daily summaries (concatenated, truncated) — fill remaining budget +7. If today's synthesis blocks exceed budget: show only the most recent N blocks + the raw tail +8. Format timestamps in agent's timezone using `TemporalTimezone` +9. Return rendered markdown with `## Working Memory` header + +**`render_channel_activity_map()`:** +1. Single query to get last message per channel (see SQL below) +2. Exclude the current channel +3. Sort by `last_activity_at` descending +4. Filter out channels inactive > `channel_map_inactive_hours` +5. Limit to `channel_map_max_channels` +6. For each channel: name, time since last activity, last sender, topic hint +7. Topic hint: query `working_memory_events` for most recent `BranchCompleted` event for that channel +8. Return rendered markdown with `## Other Channels` header, or empty string if no other channels + +The channel activity query must be a single query, not N queries per channel. This runs on every turn: + +```sql +SELECT + c.id, + c.name, + c.platform, + m.sender_name AS last_sender_name, + m.created_at AS last_message_at +FROM channels c +LEFT JOIN conversation_messages m ON m.id = ( + SELECT id FROM conversation_messages + WHERE conversation_id = c.id + ORDER BY created_at DESC + LIMIT 1 +) +WHERE c.id != ?1 + AND (m.created_at IS NULL OR m.created_at > datetime('now', ?2)) +ORDER BY m.created_at DESC NULLS LAST +LIMIT ?3 +``` + +Where `?1` = current channel_id, `?2` = inactive hours threshold (e.g. `'-24 hours'`), `?3` = max channels. The correlated subquery is efficient because `conversation_messages` is indexed on `(conversation_id, created_at)`. + +The `topic_hint` is a separate batch query against `working_memory_events`: + +```sql +SELECT DISTINCT channel_id, summary +FROM working_memory_events +WHERE event_type = 'BranchCompleted' + AND channel_id IN (?1, ?2, ...) +ORDER BY timestamp DESC +``` + +Grouped by channel_id, taking the first row per channel. This avoids N+1 queries. + +#### 2.2 Prompt Template Changes + +Update `prompts/en/channel.md.j2`: + +Replace: +```jinja2 +{%- if memory_bulletin %} +## Memory Context + +{{ memory_bulletin }} +{%- endif %} +``` + +With: +```jinja2 +{%- if working_memory %} +{{ working_memory }} +{%- endif %} + +{%- if channel_activity_map %} +{{ channel_activity_map }} +{%- endif %} + +{%- if knowledge_synthesis %} +## Knowledge Context + +{{ knowledge_synthesis }} +{%- endif %} +``` + +Note: `memory_bulletin` continues to be injected as `knowledge_synthesis` during this phase for backward compatibility. The content is the same bulletin — the rename happens in Phase 3. + +#### 2.3 Context Assembly Changes + +Modify the context assembly path (in `src/agent/channel.rs` or `src/conversation/context.rs`) to: + +1. Call `render_working_memory()` with current channel's ID +2. Call `render_channel_activity_map()` with current channel excluded +3. Pass both as template variables to the prompt engine + +The existing `build_channel_context()` in `src/conversation/context.rs` may need to be extended or replaced, depending on how the prompt engine is invoked in the actual channel turn path. The key integration point is wherever the system prompt is assembled before `agent.prompt()` is called. + +#### 2.4 Verification + +- Unit tests: rendering functions produce correctly formatted markdown, respect token budgets +- Integration test: send messages across two channels, verify Channel A sees Channel B in activity map +- Manual test: inspect system prompt to verify Layers 2+3 appear correctly +- Token budget test: generate 100 events, verify rendering stays within configured budget + +--- + +### Phase 3: Knowledge Synthesis (Replace Bulletin) + +**Dependencies:** Can proceed in parallel with Phase 2 +**Risk:** Medium — replaces the bulletin, which is the primary knowledge injection today + +#### 3.1 Dirty Flag Mechanism + +Add to `RuntimeConfig`: + +```rust +pub knowledge_synthesis: ArcSwap, // replaces memory_bulletin +pub knowledge_synthesis_version: Arc, // dirty counter +pub knowledge_synthesis_last_version: Arc, // last synthesized version +``` + +Increment `knowledge_synthesis_version` in: +- `memory_save` tool handler (after successful save) +- `memory_delete` / `memory_forget` tool handler +- Memory merge (after content changes in maintenance) + +**Do NOT** increment on importance-only changes (decay, access count updates). Maintenance decay adjusts scores on potentially hundreds of memories per cycle — this would trigger a redundant regeneration every hour. Only content-level changes (create, update content, delete, merge) should dirty the flag. + +#### 3.2 Cortex Changes + +In `src/agent/cortex.rs`, modify the bulletin generation loop: + +**Current flow:** +``` +every bulletin_interval_secs: + query 8 memory categories + synthesize into prose + store in RuntimeConfig::memory_bulletin +``` + +**New flow:** +``` +on each tick: + current_version = knowledge_synthesis_version.load() + last_version = knowledge_synthesis_last_version.load() + if current_version == last_version: + return // nothing changed + if time_since_last_change < debounce_secs: + return // wait for activity to settle + query memory categories (reduced scope — no identity, no events, no users) + synthesize with narrowed prompt + store in RuntimeConfig::knowledge_synthesis + knowledge_synthesis_last_version.store(current_version) +``` + +The warmup loop still generates an initial synthesis on startup to ensure the agent is ready. + +#### 3.2.1 Warmup Readiness Contract Changes + +The current `ready_for_work` contract requires: warm state + embedding ready + fresh bulletin. Update to: + +- **Embedding ready** — unchanged, ensures FastEmbed is loaded +- **Knowledge synthesis exists** — replaces "fresh bulletin". The synthesis must have been generated at least once. After that, freshness is change-driven, not time-driven. +- **Working memory store initialized** — always true after startup (it is a SQL connection) + +Remove the bulletin-age freshness check from the readiness gate. The working memory log and channel activity map are always ready (SQL queries against initialized tables). Participant summaries are not gated — they populate in the background. + +Update `src/config/runtime.rs`: +- `ready_for_work()` checks `knowledge_synthesis` is non-empty instead of `memory_bulletin` age +- `WarmupState` transitions remain the same, but the "warm" condition changes + +Update dispatch paths (`src/agent/channel_dispatch.rs` or wherever `ready_for_work` is checked) to use the new contract. + +#### 3.3 New Prompt Template + +Create `prompts/en/cortex_knowledge_synthesis.md.j2`: + +``` +Synthesize the agent's long-term knowledge into a concise briefing. +Focus on: +- Active goals and strategic direction +- Cross-cutting themes and patterns +- Known gaps in knowledge ("I don't have current information on X") +- Accumulated observations + +Do NOT include: identity/role information, recent events or activity, +channel-specific context, or user profiles. Those are provided by other +context layers. + +Maximum {{ max_words }} words. +``` + +#### 3.4 Config Changes + +Add `WorkingMemoryConfig` to `src/config/types.rs` (full struct from design doc). + +Add to `CortexConfig`: +- `knowledge_synthesis_max_words: usize` (default 500) +- `knowledge_synthesis_debounce_secs: u64` (default 60) +- `working_memory: WorkingMemoryConfig` + +Deprecation: `bulletin_interval_secs` and `bulletin_max_words` continue to parse from config files but map to the new fields. Log a deprecation warning on load. + +#### 3.5 Transition Strategy + +During Phase 3, the system has both: +- `memory_bulletin` (old, still populated for any code reading it) +- `knowledge_synthesis` (new, narrower scope) + +The prompt template uses `knowledge_synthesis`. The `memory_bulletin` field is kept populated as an alias until all consumers are migrated (Phase 4 removes the bulletin loop entirely). + +#### 3.6 Verification + +- Unit test: dirty flag increments on memory changes +- Unit test: debounce prevents regeneration during burst activity +- Integration test: save a memory, verify knowledge synthesis regenerates within debounce window +- Integration test: idle agent produces zero synthesis calls over 10 minutes +- Comparison test: old bulletin vs new synthesis — verify identity/events/users are absent from synthesis + +--- + +### Phase 4: Intra-Day Synthesis + Daily Summaries + Cortex Overhaul + +**Dependencies:** Phase 1 (events exist), Phase 3 (cortex changes in place) +**Risk:** Low-Medium — adds new cortex responsibilities, minimal existing behavior changed + +#### 4.1 Intra-Day Synthesis + +Add to `src/agent/cortex.rs`: + +```rust +async fn maybe_synthesize_intraday_batch(&self) -> Result<()> +``` + +Logic: +1. Get `last_synthesis_end` for today from `working_memory_intraday_syntheses` +2. Count unsynthesized events (events after that timestamp, or all today's events if no synthesis exists) +3. Check dual trigger: + - **Count-based:** unsynthesized count >= `intraday_batch_threshold` (default 15) + - **Time-based fallback:** any unsynthesized events exist AND time since last synthesis > `intraday_time_fallback_secs` (default 4 hours) +4. If neither trigger fires, return +5. Synthesize the batch into a 50-100 word paragraph via LLM +6. Store in `working_memory_intraday_syntheses` with time range + +Call this from the cortex tick loop (every `tick_interval_secs`, default 30s). The check is one cheap SQL query. The dual trigger ensures quiet agents (5-10 events/day) still get narrative blocks via the time fallback, while busy agents trigger on count. LLM cost scales with activity, not with time. + +#### 4.2 Intra-Day Synthesis Prompt + +Create `prompts/en/cortex_intraday_synthesis.md.j2`: + +``` +Summarize the following {{ event_count }} events from {{ time_start }} to {{ time_end }} +into a concise 50-100 word narrative paragraph. + +Focus on: what was accomplished, what decisions were made, what failed, what is in progress. +Be specific about who did what and in which channel. Use present/past tense naturally. +Do not list events mechanically — write a narrative. + +Events: +{{ events }} +``` + +#### 4.3 Daily Summary Synthesis + +Add to `src/agent/cortex.rs`: + +```rust +async fn maybe_synthesize_daily_summary(&self) -> Result<()> +``` + +Logic: +1. Determine "today" in agent's timezone, compute "yesterday" +2. Check `working_memory_store.has_daily_summary(yesterday)` +3. If exists, return (idempotent) +4. Query `get_intraday_syntheses(yesterday)` — the paragraphs, not raw events +5. If empty, save "No activity." summary +6. Otherwise, synthesize the intra-day paragraphs into a 200-400 word daily narrative + +Input is the already-digested paragraphs, so the daily synthesis prompt is small and cheap. One LLM call per day. + +#### 4.4 Daily Summary Prompt + +Create `prompts/en/cortex_daily_summary.md.j2`: + +``` +This is a summary of {{ agent_name }}'s activity on {{ date }}. +{{ event_count }} events across {{ channel_count }} channels. + +Synthesize the following activity blocks into a 200-400 word narrative. +These blocks are already summarized — combine them into a cohesive story of the day. +Focus on: key decisions, completed work, important conversations, unresolved items. +Write in past tense. Be specific about who did what and in which channel. + +Activity: +{{ intraday_blocks }} +``` + +#### 4.5 Progressive Compression in Rendering + +Update `render_working_memory()`: + +``` +Today: intra-day synthesis paragraphs + unsynthesized raw event tail +Yesterday: daily summary text (from working_memory_daily_summaries) +This week: concatenated daily summaries for the past 5 days, truncated to fit budget +``` + +This is now purely programmatic on the rendering path — concatenating cached synthesis rows. No LLM call at render time. + +#### 4.6 Event Pruning + +Add to the cortex maintenance loop: + +```rust +store.prune_old_events(retention_days).await?; +``` + +Default retention: 30 days. Prunes both raw events and intra-day syntheses older than the threshold. Daily summaries are never pruned (they are small and serve as permanent history). + +#### 4.7 Remove Bulletin Loop + +At this point, the bulletin loop can be fully removed from the cortex. The cortex tick loop now does: + +1. **Intra-day synthesis check** — synthesize batch if event count threshold crossed (0-N calls/day, activity-driven) +2. **Daily summary check** — synthesize yesterday if needed (1 LLM call/day) +3. **Knowledge synthesis check** — regenerate if dirty flag changed (0-N calls/day, change-driven) +4. **Participant summary check** — regenerate stale human summaries (existing, unchanged) +5. **Maintenance** — memory decay, pruning, association building (existing, unchanged) +6. **Event pruning** — prune old working memory events and intra-day syntheses (new, cheap SQL) + +Remove: bulletin interval timer, bulletin generation function, warmup bulletin regeneration. + +#### 4.8 Verification + +- Unit test: intra-day synthesis triggers at correct batch threshold +- Unit test: intra-day synthesis is incremental (only covers events since last synthesis) +- Unit test: daily summary synthesis uses intra-day paragraphs, not raw events +- Unit test: daily summary is idempotent (calling twice doesn't duplicate) +- Integration test: generate 30 events, verify 2 intra-day synthesis blocks created +- Integration test: simulate day rollover, verify daily summary synthesized from intra-day blocks +- Integration test: verify events + intra-day syntheses older than 30 days are pruned +- Integration test: verify bulletin loop is gone, no timer-driven synthesis + +--- + +### Phase 5a: Smarter Memory Persistence Triggers + +**Dependencies:** Phase 1 (event density trigger needs event counting) +**Risk:** Low — purely additive, existing compactor behavior unchanged + +#### 5a.1 New Persistence Triggers + +Modify `src/agent/channel_dispatch.rs` (or wherever persistence triggers are evaluated): + +Current: trigger every 50 user messages. + +New triggers (any one fires): +1. **Message count:** Every 20 user messages (reduced from 50) +2. **Time-based:** 15 minutes since last persistence run, if conversation is active +3. **Event density:** 5+ working memory events from this channel since last persistence run + +Track `last_persistence_at: Instant` and `messages_since_persistence: usize` per channel. + +For the event density trigger: +```rust +let events_since = deps.working_memory + .count_events_since(channel_id, last_persistence_at) + .await?; +if events_since >= config.persistence_event_density_threshold { + trigger_persistence_branch(); +} +``` + +#### 5a.2 Persistence Branch Dual Output + +Update `prompts/en/memory_persistence.md.j2` to add: + +``` +In addition to saving graph memories, identify key decisions and important events +from the conversation. For each, include it in the `events` field of +memory_persistence_complete. Events should be one-line summaries with a type +("decision", "error", "system") and importance score (0.0-1.0). +``` + +Update `src/tools/memory_persistence_complete.rs`: + +Add optional `events` field to the tool's args struct: + +```rust +pub struct MemoryPersistenceCompleteArgs { + pub outcome: String, + pub memory_ids: Vec, + pub events: Option>, // NEW +} + +pub struct WorkingMemoryEventInput { + pub event_type: String, + pub summary: String, + pub importance: Option, +} +``` + +When `events` is present, write each to the working memory store with the current channel's context. + +#### 5a.3 Verification + +- Unit test: persistence triggers fire at correct thresholds (message count, time, event density) +- Unit test: `memory_persistence_complete` writes events to working memory +- Integration test: simulate 20 messages, verify persistence branch fires (was 50) +- Monitor: compare memory creation rate before/after for 1-2 weeks. Confirm the new triggers provide adequate coverage for light conversations (5-10 messages/day). + +--- + +### Phase 5b: Sunset Compactor Memory Extraction + +**Dependencies:** Phase 5a deployed and validated (new persistence triggers confirmed adequate) +**Risk:** Medium — removes a memory creation path. Only deploy after Phase 5a monitoring confirms coverage. + +#### 5b.1 Remove Memory Save from Compactor + +Update `src/agent/compactor.rs`: +- Remove `memory_save` from the compactor's tool server +- The compactor's only output is the compaction summary injected into history + +Update `prompts/en/compactor.md.j2`: +- Remove all `memory_save` instructions +- The compactor's sole responsibility is producing a compaction summary + +#### 5b.2 Verification + +- Integration test: run compactor, verify no `memory_save` calls in tool output +- Regression test: verify memory quality doesn't degrade — compare memories saved before/after +- Monitor: memory count growth rate should remain stable compared to Phase 5a baseline + +--- + +### Phase 6: Participant Context Integration (Layer 4) + +**Dependencies:** Phase 1 (recent activity per user), participant-awareness.md implemented +**Risk:** Low — additive layer, no existing behavior removed + +#### 6.1 Participant Rendering with Activity + +If participant-awareness.md is already implemented (cortex-generated `HumanSummary` per user): + +```rust +pub async fn render_participant_context( + humans: &[HumanDef], + summaries: &HashMap, // user_id -> cortex summary + working_memory: &WorkingMemoryStore, + participants: &[ParticipantInfo], // active in current channel + config: &WorkingMemoryConfig, +) -> Result; +``` + +For each active participant: +1. Load their cortex-generated summary (2-3 sentences) +2. Query `working_memory_events` for their last 3 events across all channels +3. Format: `**name** — summary\n Recent: event1, event2, event3` +4. Cap at 5 participants, sorted by most recently active + +If participant-awareness.md is NOT yet implemented, this phase is deferred. The working memory system works without Layer 4 — it is the lowest-priority layer. + +#### 6.2 Verification + +- Unit test: participant rendering includes recent activity from working memory events +- Integration test: user sends message, verify their profile + recent activity appears in system prompt + +--- + +## Integration Points + +### `AgentDeps` Changes + +```rust +pub struct AgentDeps { + // ... existing fields ... + pub working_memory: Arc, // NEW +} +``` + +Every process (channel, branch, worker, cortex) gets access to the working memory store through `AgentDeps`. Only channels and the cortex write to it directly. Workers and branches write to it indirectly through their tool handlers. + +### `RuntimeConfig` Changes + +```rust +pub struct RuntimeConfig { + // ... existing fields ... + pub knowledge_synthesis: ArcSwap, // replaces memory_bulletin + pub knowledge_synthesis_version: Arc, // dirty counter + pub knowledge_synthesis_last_version: Arc, // last synthesized version + // memory_bulletin: kept as deprecated alias +} +``` + +**Migration of `memory_bulletin` consumers:** The following code paths currently read `runtime_config.memory_bulletin.load()` and must be updated to read `knowledge_synthesis` instead: + +- `src/agent/cortex.rs` — bulletin generation loop (writes the value; replaced in Phase 3) +- `src/agent/cortex.rs` — warmup loop (generates initial bulletin; updated to generate initial knowledge synthesis) +- `src/agent/channel.rs` — `build_system_prompt()` (reads for template injection; updated in Phase 2) +- `src/agent/cortex.rs` — `should_generate_bulletin_from_bulletin_loop()` freshness check (removed in Phase 4) +- `src/config/runtime.rs` — `ready_for_work()` readiness check (updated in Phase 3) +- `src/api/server.rs` — API endpoint that exposes bulletin content (updated to expose knowledge synthesis) + +During the transition (Phases 2-3), both fields are kept in sync: when `knowledge_synthesis` is updated, `memory_bulletin` is also updated with the same value. This ensures any unconverted consumers continue to work. In Phase 4, `memory_bulletin` is removed entirely. + +### Prompt Engine Integration + +The prompt engine (`src/prompts/engine.rs`) needs to pass new template variables: + +- `working_memory: Option` — rendered Layer 2 +- `channel_activity_map: Option` — rendered Layer 3 +- `participant_context: Option` — rendered Layer 4 (when available) +- `knowledge_synthesis: Option` — rendered Layer 5 + +These are computed before template rendering and passed as context variables. The prompt engine does not need structural changes — it already supports arbitrary context variables via Minijinja. + +### Event Broadcast Integration + +The existing `ProcessEvent` broadcast (`event_tx`) continues to drive `StatusBlock` updates. Working memory event emission is a **parallel path** — when a `ProcessEvent` fires, the relevant handler also calls `working_memory.emit()`. The two systems are independent: + +- `ProcessEvent` → `StatusBlock` (in-memory, current session only) +- `WorkingMemoryEvent` → SQLite (persistent, cross-session, cross-channel) + +There is no coupling between them. A future optimization could unify them (derive working memory events from `ProcessEvent`), but the design deliberately keeps them separate for simplicity. + +### Compactor Integration + +The compactor (`src/agent/compactor.rs`) changes minimally: + +1. Remove `memory_save` from its tool server (Phase 5) +2. Optionally emit a `System` working memory event when compaction runs (Phase 1) + +The compactor's core logic (threshold monitoring, history truncation) is unchanged. + +### Cortex Integration + +The cortex (`src/agent/cortex.rs`) is the most heavily modified component: + +1. **Phase 3:** Bulletin loop replaced with dirty-flag knowledge synthesis +2. **Phase 4:** Daily summary synthesis added to tick loop +3. **Phase 4:** Event pruning added to maintenance loop +4. **Phase 4:** Bulletin loop removed entirely + +These changes are spread across phases to reduce risk. The cortex's existing circuit breaker, timeout, and warmup infrastructure is reused for the new responsibilities. + +--- + +## Testing Strategy + +### Unit Tests + +| Test | Module | What It Verifies | +|------|--------|------------------| +| CRUD operations | `memory/working.rs` | Insert, query by day/channel/user, pruning | +| Token budget rendering | `memory/working.rs` | Output stays within configured limits | +| Event type serialization | `memory/working.rs` | Round-trip through SQLite | +| Dirty flag mechanics | `config/runtime.rs` | Counter increments, debounce logic | +| Daily summary idempotency | `agent/cortex.rs` | Duplicate calls don't duplicate summaries | +| Persistence trigger logic | `agent/channel_dispatch.rs` | Fires at correct thresholds | +| Channel activity rendering | `memory/working.rs` | Correct format, respects max channels | +| Participant rendering | `memory/working.rs` | Includes recent activity, respects limits | + +### Integration Tests + +| Test | File | What It Verifies | +|------|------|------------------| +| Event accumulation | `tests/working_memory.rs` | Events appear in DB during normal operation | +| Cross-channel awareness | `tests/working_memory.rs` | Channel A sees Channel B activity | +| Day rollover | `tests/working_memory.rs` | Daily summary generated after midnight | +| Knowledge synthesis trigger | `tests/working_memory.rs` | Synthesis regenerates on memory change | +| Compactor isolation | `tests/working_memory.rs` | Compactor produces no memory_save calls | +| Context token budget | `tests/working_memory.rs` | System prompt stays within context window | + +### Token Count Regression Test + +An automated test that renders the full system prompt with realistic data and asserts the total token count stays within bounds. This catches regressions where a new event type or rendering change produces unexpectedly verbose output. + +```rust +#[tokio::test] +async fn system_prompt_token_budget() { + // Seed: 50 working memory events, 5 channels with activity, + // 3 participants with summaries, a knowledge synthesis blob + let prompt = build_full_system_prompt(&test_state).await; + let token_count = estimate_tokens(&prompt); // simple word-count / 0.75 heuristic + assert!(token_count <= 8500, "system prompt {token_count} tokens exceeds 8500 budget"); + assert!(token_count >= 4000, "system prompt {token_count} tokens suspiciously small"); +} +``` + +### Existing Test Verification + +These existing tests must continue to pass: + +- `tests/bulletin.rs` — until Phase 4 removes the bulletin +- `tests/maintenance.rs` — memory maintenance unchanged +- `tests/tool_nudge.rs` — worker behavior unchanged +- `tests/context_dump.rs` — context assembly (will need updates in Phase 2) + +### Manual Testing Checklist + +- [ ] Send messages in two channels, verify activity map in each +- [ ] Check system prompt includes Working Memory section with today's events +- [ ] Verify idle agent produces no LLM calls for synthesis +- [ ] Save a memory, verify knowledge synthesis regenerates +- [ ] Wait for day rollover, verify daily summary appears +- [ ] Send 20 messages, verify persistence branch fires +- [ ] Run compactor, verify no memory_save tool calls in output +- [ ] Check token count of system prompt stays within expected range + +--- + +## Migration and Backward Compatibility + +### Database Migration + +The migration is purely additive — two new tables, no changes to existing tables. It can be applied without downtime. The `working_memory_events` table starts empty; there is no backfill from existing data. + +### Config Migration + +Old config keys (`bulletin_interval_secs`, `bulletin_max_words`) continue to work as aliases. On load: + +```rust +if config.cortex.bulletin_interval_secs.is_some() + && config.cortex.knowledge_synthesis_debounce_secs.is_none() +{ + tracing::warn!( + "cortex.bulletin_interval_secs is deprecated, \ + use cortex.knowledge_synthesis_debounce_secs instead" + ); + config.cortex.knowledge_synthesis_debounce_secs = + config.cortex.bulletin_interval_secs; +} +``` + +### Phased Rollout + +Each phase is independently deployable: + +- **Phase 1:** Events start flowing. No visible change to users. Safe to deploy immediately. +- **Phase 2:** Working Memory and Channel Activity Map appear in system prompt. The bulletin also appears (both layers coexist). Deploy behind a feature flag (`working_memory.enabled`, default true). +- **Phase 3:** Bulletin regeneration becomes change-driven. Same content, different trigger. Transparent to users. +- **Phase 4:** Bulletin removed. Knowledge Synthesis (narrower scope) replaces it. Daily summaries begin. This is the breaking change for any users who depend on the bulletin's broad scope. +- **Phase 5:** Memory creation behavior changes. More frequent persistence, no compactor memories. Monitor memory quality. +- **Phase 6:** Participant context added. Purely additive. + +### Rollback Strategy + +Each phase has a clean rollback: + +- **Phase 1:** Drop the new tables, revert code. Events stop flowing, nothing depends on them yet. +- **Phase 2:** Revert template to use `memory_bulletin`. Remove rendering functions. The bulletin is still being generated. +- **Phase 3:** Restore bulletin timer loop. Revert dirty flag changes. The old bulletin resumes. +- **Phase 4:** Restore bulletin loop (if Phase 3 rollback hasn't already). Daily summaries become orphaned (harmless). +- **Phase 5:** Restore 50-message persistence trigger, restore `memory_save` in compactor. +- **Phase 6:** Remove participant rendering from context assembly. + +--- + +## Token Budget Analysis + +### Default Configuration + +| Layer | Budget | Source | Cost Per Turn | +|-------|--------|--------|---------------| +| 1. Identity | ~600-2000 | User-written files | 0 (loaded from disk) | +| 2. Working Memory | 1500 | SQL queries | 0 LLM, ~2ms SQL | +| 3. Channel Activity | 300 | SQL queries | 0 LLM, ~1ms SQL | +| 4. Participant Context | 400 | Cached summaries + SQL | 0 LLM, ~1ms SQL | +| 5. Knowledge Synthesis | 500 | ArcSwap read | 0 LLM, ~0ms | +| **Total new layers** | **~3300-4700** | | **~4ms per turn** | + +### Current Bulletin Comparison + +| Metric | Current Bulletin | New System | +|--------|------------------|------------| +| Tokens per turn | 550-900 (undifferentiated) | 2700-3200 (structured) | +| LLM calls per day | ~96 (every 15 min) | ~5-20 (intra-day batches + daily summary + change-driven knowledge synthesis + participant summaries) | +| LLM tokens consumed/day | ~240k (bulletin regeneration) | ~10-30k (targeted synthesis, scales with activity not time) | +| Cross-channel awareness | None | Full activity map | +| Temporal structure | None | Today/yesterday/week | +| Per-user context | None | Recent activity per participant | + +The new system uses more context tokens (2700-3200 vs 550-900) but the tokens are structured, relevant, and carry distinct information. The LLM cost drops dramatically (96 calls/day → 2-5 calls/day). + +### Context Window Impact + +For a 128k context window (typical for Claude/GPT-4): + +``` +System prompt (with all layers): ~5800-8200 tokens +Conversation history: ~variable (managed by compactor) +Available for generation: ~119k-122k tokens +``` + +The additional ~2000 tokens for Layers 2-4 is well within the headroom. The compactor's thresholds (80%/85%/95%) apply to the full context including the expanded system prompt. + +--- + +## Risk Register + +| Risk | Impact | Probability | Mitigation | +|------|--------|-------------|------------| +| Working memory events flood SQLite with writes | Performance degradation | Low | Fire-and-forget with `tokio::spawn`, WAL mode, batch inserts if needed | +| LLM behavior changes with restructured context | Unexpected responses | Medium | Phase 2 behind feature flag, A/B test with bulletin vs layers | +| Daily summary synthesis fails silently | No yesterday context | Low | Circuit breaker (existing pattern), fallback to raw events | +| Knowledge synthesis never triggers (no memory changes) | Stale Layer 5 | Low | Startup warmup generates initial synthesis, periodic health check | +| High event volume floods working memory log | Noisy context injection | Low | Events are structured process lifecycle signals, not raw messages. Volume is naturally bounded by worker/branch/cron activity. | +| Compactor without memory_save loses important memories | Knowledge loss | Medium | Verify persistence branch frequency compensates, monitor memory count | +| Day computation timezone mismatch | Events on wrong day | Low | Use agent's configured timezone consistently, test edge cases | +| Token budget exceeded with many channels/participants | Context overflow | Low | Hard caps on channels (10) and participants (5), configurable | + +--- + +## Relationship to Tiered Memory + +The [tiered memory design](tiered-memory.md) is a separate, complementary system that adds a `tier` column to the existing `memories` table. It improves `memory_recall` search quality by giving recent memories a score boost, and adds lifecycle management (3-day TTL, LRU demotion, decay scoping). + +**Tiered memory does not affect context assembly.** It operates entirely within the `memory_recall` search pipeline — branches get better results, but channels never see tiered memory directly. The working memory system handles all context injection. + +**Implementation ordering:** Tiered memory can be implemented independently of working memory — it touches different code paths (`memory/store.rs`, `memory/search.rs`, `memory/maintenance.rs`) with no overlap in schema or rendering. The two systems share only the cortex tick loop, where tiered memory adds working-state demotion checks alongside the working memory event synthesis checks. + +**Recommended sequencing:** Ship working memory first (Phases 1-5), then tiered memory. Working memory is the user-facing improvement that fixes situational awareness. Tiered memory is a behind-the-scenes search quality improvement that makes `memory_recall` smarter. Both are valuable, but working memory addresses the acute user pain (churning, "stupid and forgetful"). + +--- + +## Summary + +The working memory implementation replaces a monolithic, timer-driven bulletin with a five-layer context assembly system. The core insight is that most context can be assembled programmatically from structured data (SQL queries, cached values, identity files) rather than synthesized by an LLM on every turn. + +The six phases are ordered to minimize risk and maximize incremental value: + +1. **Phase 1** — Events start flowing (foundation, no visible change) +2. **Phase 2** — Channels gain temporal awareness and cross-channel visibility +3. **Phase 3** — Knowledge synthesis becomes efficient (change-driven, not timer-driven) +4. **Phase 4** — Intra-day synthesis + progressive temporal compression with daily summaries +5. **Phase 5a** — Smarter memory persistence triggers (additive, no behavior removed) +6. **Phase 5b** — Sunset compactor memory extraction (only after 5a is validated) +7. **Phase 6** — Per-user context rounds out the system + +Each phase is independently deployable and rollback-safe. Phase 1 is the critical foundation that unblocks all subsequent work. + +After working memory ships, [tiered memory](tiered-memory.md) can be implemented as an independent follow-up to improve `memory_recall` search quality. diff --git a/docs/design-docs/working-memory-problem-analysis.md b/docs/design-docs/working-memory-problem-analysis.md new file mode 100644 index 000000000..d330f18df --- /dev/null +++ b/docs/design-docs/working-memory-problem-analysis.md @@ -0,0 +1,339 @@ +# Working Memory: Problem Analysis + +This document is a comprehensive analysis of why Spacebot's current memory and context assembly system fails to provide situational awareness, particularly in multi-user, multi-channel environments. It catalogs every failure mode with evidence from live system prompts and conversation transcripts. It is the prerequisite to a design document. + +## Executive Summary + +Users report Spacebot feels "stupid and forgetful." People are churning. The root cause is not the LLM -- it is that the context assembly system starves the LLM of the information it needs to be smart. The agent has no concept of what happened today, no awareness of activity in other channels, no per-user adaptation, and no proactive memory capture. The bulletin system -- the agent's primary knowledge injection mechanism -- is a single LLM-synthesized blob regenerated on a timer whether anything changed or not, identical for every channel and every user. + +The problems fall into seven categories: + +1. The bulletin is a monolithic, undifferentiated blob +2. There is zero cross-channel awareness +3. Memory creation is too passive +4. There is no temporal structure +5. The bulletin wastes tokens regenerating stable information +6. Per-user context does not exist +7. The system does not scale to multi-user environments + +--- + +## Problem 1: The Bulletin Is a Monolithic Blob + +### What happens today + +The cortex generates a single text artifact called the "memory bulletin." It queries eight predefined sections from the memory store (Identity, Recent, Decisions, High-Importance, Preferences, Goals, Events, Observations), then sends all raw results to an LLM with a synthesis prompt that says: "Organize by what's most actionable or currently relevant, not by the raw section headers" and "Do NOT reproduce the section headers from the input." + +The result is a narrative paragraph with no internal structure. It is stored in `RuntimeConfig::memory_bulletin` via `ArcSwap` and injected into every channel's system prompt on every turn. Every channel for the same agent sees the exact same bulletin. + +### Why this is a problem + +**No structure means no scanning.** The channel LLM cannot quickly find "what am I working on right now" vs "what do I know about user X" vs "what happened today." It must read the entire blob linearly. In a 500-word bulletin, the one relevant fact for the current conversation is buried in context the LLM has to wade through. + +**No differentiation means noise for everyone.** In the Discord agent with 1000+ memories, the bulletin dedicates an entire paragraph to one user's consciousness research (SEAL, SDFT, neuromorphic computing), weather reports, per-user formatting quirks ("address DarkSkyFullOfStars as my lover"), and corporate strategy filler ("strong market positioning across three distinct technology verticals"). All of this is injected when any user in any channel sends any message. + +**No priority means the important gets buried.** In the personal agent, a critical action item ("create task board entries for open PRs") is sandwiched between a Vancouver weather report and a paragraph about "operational readiness." The agent then proceeded to lie about completing that action item -- the bulletin's cheerful "task board shows no pending items" provided false confidence. + +### Evidence from live systems + +**Personal agent bulletin** (~550 tokens): Contains the agent's role (already stated in Soul + Identity + Role), product descriptions (repeated 4x across the prompt), weather, a raw worker UUID, and one buried action item. + +**Discord agent bulletin** (~900 tokens): Contains bug status from unknown dates, 7 user profiles out of 13+ active users, one user's research interests consuming 25% of the space, per-user formatting constraints for 6 users, and architectural evolution notes with no temporal anchoring. + +--- + +## Problem 2: Zero Cross-Channel Awareness + +### What happens today + +Each channel operates in complete isolation. The system prompt's Conversation Context section contains exactly: + +``` +Platform: discord +Server: Spacedrive / Spacebot / Voicebox +Channel: #talk-to-spacebot +Multiple users may be present. +``` + +No other channels are mentioned. No activity summaries. No awareness of what happened in `#development` or `#general` or any DM. The status block shows active workers and branches, but only those associated with the current channel. + +The only mechanism for cross-channel awareness is explicit: a branch can use `channel_recall` to query the persisted message database. But this requires the agent to (a) know it should look, (b) know where to look, and (c) have a free branch slot to do so. None of these conditions are met by default. + +### Why this is a problem + +**Information told in one channel is invisible in another.** If a user tells the agent something important in Channel A, then immediately goes to Channel B and references it, the agent has no idea. This is the single most complained-about behavior. Users expect a team member to know what it was just told. The agent does not. + +**Pattern detection across channels is impossible.** If three users in three channels are all hitting the same bug, the agent in each channel treats it as an isolated report. There is no mechanism to surface "this is a pattern -- multiple users are reporting Copilot provider errors." + +**Worker and branch activity in other channels is invisible.** A long-running coding worker in Channel A is invisible from Channel B. If a user in Channel B asks "what are you working on?" the agent can only see its local status block. + +### Evidence from live systems + +The Discord agent's conversation history shows users referencing prior conversations that happened in other channels. The agent consistently fails to connect these references because it has no ambient signal that activity occurred elsewhere. The `channel_recall` tool exists but is never used proactively -- the agent does not know there is something to recall. + +--- + +## Problem 3: Memory Creation Is Too Passive + +### What happens today + +Memories are created through three paths: + +1. **Branch-initiated:** A branch decides to call `memory_save` during conversation processing. This depends on the LLM's judgment -- it may or may not decide something is worth saving. + +2. **Compactor-initiated:** When context hits >80% capacity, the compactor spawns a worker that summarizes old context AND extracts memories via `memory_save`. This only fires when the context window is nearly full. + +3. **Periodic memory persistence branch:** Every 50 user messages (configurable), a special branch runs with the full conversation history and a prompt to extract important memories. It recalls first to avoid duplicates, then saves selectively. + +### Why this is a problem + +**Compaction is the wrong trigger for memory creation.** Compaction fires at >80% context capacity. For light conversations (a few messages per day), the agent may never hit this threshold. Days or weeks of conversation can pass with zero compaction-initiated memories. Memory creation should not depend on context pressure. + +**50 messages is too high a threshold for persistence.** In a multi-user Discord, 50 messages might take 10 minutes during an active conversation. In a quiet DM, 50 messages might take a week. In neither case is the threshold appropriate. By the time the persistence branch runs, key context from early in the conversation may have been lost to compaction or simply too far back in the history for the LLM to give proper attention. + +**Branch-initiated saving depends on LLM judgment.** The branch has to decide that something is worth remembering. In practice, branches focus on answering the user's question -- memory saving is a secondary concern. Many significant pieces of information are never explicitly saved because the branch is optimizing for response quality, not memory coverage. + +**No event-driven capture exists.** When a worker completes a task, when a cron job fires, when a user makes an explicit decision ("let's go with option B"), when the agent makes an error and gets corrected -- none of these events trigger automatic memory creation. They are captured only if a branch happens to be running and decides to save them, or if they survive long enough in the conversation history to be caught by the periodic persistence branch. + +### Evidence from live systems + +The personal agent had a ~25-message interaction where it lied about creating tasks, got caught, retried with sandbox issues, gathered PR data, and still had not finished. Zero memories were saved from this exchange. Context was not at 80%, the persistence threshold of 50 messages was not hit, and no branch decided to save anything. + +The Discord agent shows a user (bergabman) saying: "it seems you got your memory wiped, multiple wrong statements in that message while we talked about this before." The agent gave fundamentally wrong information about ChatGPT OAuth because prior interactions were not captured as memories. + +--- + +## Problem 4: No Temporal Structure + +### What happens today + +The agent knows the current date and time from the status block: + +``` +Time: 2026-03-18 15:50:49 +00:00 +``` + +That is the entirety of its temporal awareness. There is no concept of: + +- What happened today +- What happened yesterday +- What the agent was doing an hour ago +- What the daily rhythm looks like (cron execution history, conversation volume) +- What conversations have been active today vs dormant + +The bulletin mentions events with no dates. "v0.3.3 is live" -- when? "A formatting bug has emerged" -- when? "Fleet operations reports from March 10th and 13th" -- in the personal agent, this is the only temporal reference, and it is five and eight days stale. + +### Why this is a problem + +**No daily narrative means no situational awareness.** A real team member starts their day knowing what happened yesterday and what is on the agenda today. This agent starts every conversation from a timeless pool of memories. It cannot answer "what have we been working on today?" without branching to search. + +**No temporal decay in context injection.** A memory from 30 seconds ago and a memory from 30 days ago are treated identically in the bulletin. The tiered memory design doc (working state + graph tiers) attempts to fix this with a 3-day working state window, but it operates at the individual memory level -- it does not provide a structured daily narrative. + +**No session continuity.** When a user starts a new conversation, the agent has no awareness of "the last time we spoke, you were working on X." The `backfill_transcript` mechanism exists (loading recent history from the database on first message), but it provides raw messages without any narrative framing. + +**Cron execution history is invisible.** The agent might have run 10 scheduled tasks today -- checking email, monitoring repos, running health checks. None of this activity is surfaced in context. The status block shows cron count but not what they did or when they last ran. + +### What users expect + +Users coming from tools like the triage document (`2026-03-18-SPACEBOT-TRIAGE.md`) expect the agent to have equivalent awareness. That document is structured temporally: here is what matters today, here are the immediate priorities, here is the backlog. The agent should be able to maintain and surface this kind of temporal structure autonomously. + +--- + +## Problem 5: Token Waste on Stable Information + +### What happens today + +The bulletin is fully regenerated by an LLM on every cycle. The cycle runs on two overlapping timers: + +- **Warmup loop:** Every `warmup.refresh_secs` (default 900s / 15 minutes) +- **Cortex bulletin loop:** Every `bulletin_interval_secs` (default 3600s / 1 hour), acts as fallback + +There is no change detection. The cortex queries all eight memory sections, sends them to an LLM, and gets back a synthesized blob -- regardless of whether any memories have been created, updated, or accessed since the last generation. + +Additionally, the bulletin re-synthesizes information that is already present elsewhere in the system prompt: + +**Overlap between Soul/Identity/Role and Bulletin:** + +| Information | Where it appears | +|------------|-----------------| +| Agent's role (COO, community ambassador) | Soul, Identity, Role, Bulletin | +| Product descriptions (Spacebot, Spacedrive, Voicebox) | Soul, Identity, Bulletin | +| GitHub star counts | Soul, Identity, Bulletin | +| Operational philosophy | Soul, Identity, Role, Bulletin | +| Authority/escalation rules | Identity, Role | + +In the personal agent prompt, the same "I'm the COO of Spacedrive Technology Inc. with three products" information appears four times in four slightly different phrasings. + +### Why this is a problem + +**LLM cost scales with memory count, not with change rate.** An agent with 1000 memories pays the same synthesis cost whether 100 new memories were created since the last bulletin or zero were. The 8-section query pulls up to 85 memories per cycle (15+15+10+10+10+10+10+5), plus tasks. The synthesis call processes all of them every time. + +**Stable information dominates the bulletin.** Identity facts, user preferences, product descriptions, and long-standing decisions change on a scale of weeks or months. Yet they are re-synthesized alongside genuinely volatile information (recent events, active tasks) every 15 minutes. The stable content pushes out volatile content because the word limit (default 500-1500 words) is shared. + +**The agent has been idle? Regenerate anyway.** If the agent has not been used in 3 hours, the warmup loop still regenerates the bulletin every 15 minutes. Six unnecessary LLM calls burning tokens on identical output. The cortex has no concept of "nothing has changed." + +### Quantifying the waste + +At the default cadence of 15-minute warmup refreshes: **96 bulletin generations per day**. If each synthesis call uses ~2000 input tokens (raw sections) + ~500 output tokens, that is ~240,000 tokens per day per agent. For an agent that is only active for 4 hours a day, roughly 80% of those generations are wasted on periods of zero activity. + +--- + +## Problem 6: Per-User Context Does Not Exist + +### What happens today + +The bulletin is global and agent-centric. It says nothing about who the agent is currently talking to. Every user in every channel sees the same bulletin. + +In the Discord agent, 7 out of 13+ active users get profile mentions in the bulletin. The rest are invisible. When RAKU asks "do you know me?" the answer is no -- despite having had prior conversations. + +The only per-user adaptation mechanism is the `channel_recall` tool (available to branches), which can search the conversation database for messages from a specific user. But this requires branching, which adds latency and consumes a limited branch slot. + +Two design docs address this: + +- **`participant-awareness.md`:** Proposes a `humans` table with cortex-generated per-user summaries, injected into channels with multiple users. Not implemented. +- **`user-scoped-memories.md`:** Proposes a `user_id` column on memories and scoped search. Not implemented. + +Neither design integrates with the bulletin. Both add parallel injection points into the system prompt, creating a layered context assembly that could become unwieldy. + +### Why this is a problem + +**The agent cannot adapt to who it is talking to.** When bergabman (a power user debugging OAuth) sends a message, the agent gets the same context as when a new user asks "what is Spacebot?" There is no mechanism to surface bergabman's prior conversations, his technical level, his specific configuration, or his recent activity. + +**In multi-user channels, the agent cross-contaminates context.** When Kael, bergabman, and okuna are all talking in `#talk-to-spacebot` simultaneously, the agent is tracking three interleaved conversations with no separation. It may reference Kael's consciousness research when replying to okuna's Docker question. + +**The bulletin's user profile section scales terribly.** With 7 profiles at ~50 words each, that is 350 words -- 70% of a 500-word bulletin -- dedicated to user descriptions that are only relevant when that specific user is talking. In a server with 100 users, this section would need to be 50x larger to cover everyone, which is obviously impossible within token budgets. + +--- + +## Problem 7: Multi-User/Multi-Channel Scaling Failure + +### What breaks under load + +Consider a realistic scenario: a Slack workspace with 10 engineers, 5 channels, and moderate activity (each channel gets 20-30 messages per day). + +**Token budget collapses.** The system prompt (Soul + Identity + Role + Bulletin + Channel instructions + Status block + Skills + Worker types) is ~6,300+ tokens before conversation history. In a fast-moving channel, history fills the remaining context rapidly. Older messages -- including those containing important context -- are pushed out. + +**Worker/branch contention.** Max 3 workers and max 3 branches are shared across the entire agent, not per-channel. When User A's coding worker in Channel 1 is running and User B's research branch in Channel 2 is active, User C in Channel 3 has reduced capacity. Users experience delayed or missing responses with no explanation. + +**Bulletin staleness scales with activity.** The bulletin regenerates on a timer, not on events. In a 5-channel setup with continuous activity, the bulletin is stale the moment it is created. By the next regeneration (15 minutes later), dozens of events have occurred that are invisible to channels that did not directly participate. + +**Memory recall contention.** Every `memory_recall` call goes through the same search pipeline (SQLite + LanceDB). With 1000+ memories, search is fast but not free. Multiple concurrent branches all recalling simultaneously creates IO contention on the embedded databases. + +**The compactor compounds the problem.** Active channels hit the compaction threshold faster. Compaction extracts memories and summarizes context, but the extracted memories are global (no user_id), the summaries are channel-specific, and the bulletin does not immediately reflect the extracted memories (it waits for the next regeneration cycle). + +### Evidence from live systems + +The Discord agent's conversation transcript shows: + +- The agent retrying failed tool calls (spawn_worker) five consecutive times for the same user +- Users being told "I don't know you" despite prior interactions +- Users receiving wrong information because prior context was not surfaced +- The agent struggling with interleaved multi-user conversations + +--- + +## The Compaction Memory Path Should Be Sunset + +The compactor currently has two jobs: (1) summarize old context to free up space, and (2) extract memories from the context being compacted. Job 1 is fine and should remain. Job 2 is the wrong place for memory creation, for several reasons: + +**It only fires under context pressure.** If the agent has a light conversation (20 messages), compaction never triggers. Those 20 messages may contain critical information that is never persisted. + +**It fires too late.** By the time context is 80% full, the oldest messages being compacted are already stale. The most important information -- decisions, corrections, commitments made early in the conversation -- may have been made dozens of messages ago. + +**The compactor LLM has the wrong incentive.** Its primary job is to produce a compact summary. Memory extraction is a secondary objective bolted onto the compaction prompt. The LLM optimizes for summary quality, not memory coverage. + +**Compactor-extracted memories have no user attribution.** The compactor strips sender information from the transcript before processing. Memories extracted during compaction are always global, with no `user_id` or `channel_id` context. + +Memory creation should be proactive, event-driven, and happen at the point of creation -- not as a side effect of context pressure management. + +--- + +## The Tiered Memory Design Doc: Right Idea, Wrong Scope + +The existing `tiered-memory.md` design doc introduces two tiers: + +- **Working state** (hot, 3-day TTL, max 64 memories, injected into context without search) +- **Graph** (warm, 30-day retention with decay, hybrid search) + +This is the right instinct -- recent memories should be immediately available without search. But it solves a different problem than what users are asking for: + +**Tiered memory is about individual memory lifecycle.** A memory enters working state, stays hot for 3 days while accessed, then demotes to graph tier. This improves recall for recently-saved memories. + +**Working memory (what users want) is about temporal narrative.** Users want the agent to know "what happened today." Not just "here are 64 recent memory objects sorted by access time," but a coherent narrative: "Today, Jamie asked me to create task board entries for open PRs. I failed the first attempt. We discussed sandbox issues. I gathered PR data via GitHub CLI. Meanwhile, in #development, vsumner submitted a fix PR. In #general, there was a discussion about the next release." That is a journal, not a memory tier. + +The two systems are complementary, not competing: + +| Concern | Tiered Memory | Working Memory | +|---------|--------------|----------------| +| Unit | Individual memory objects | Daily narrative entries, channel summaries | +| Structure | Flat set with LRU eviction | Append-only temporal log | +| Lifecycle | 3-day TTL, access-based | Day-bounded, with progressive rollups | +| Injection | Programmatic rendering of top-N | Structured sections in context | +| Creation | Same as today (branch, persistence) | Event-driven + cortex synthesis | +| Purpose | Better recall of recent facts | Situational awareness of what is happening | + +The tiered memory design should proceed as planned -- it improves memory retrieval. But it does not replace the need for a structured temporal working memory system. + +--- + +## The Bulletin System: Right Idea, Wrong Execution + +The bulletin was designed to give channels ambient knowledge without requiring them to branch and search. That is the right idea. The execution fails because: + +1. **One artifact for all channels, all users, all contexts.** The bulletin cannot be relevant to everyone. In a multi-user Discord, it tries to serve Kael's consciousness research and okuna's Docker questions with the same 500 words. It serves neither well. + +2. **Timer-based regeneration with no change detection.** Regenerates every 15 minutes whether anything changed or not. Wastes LLM tokens on identical output during idle periods. + +3. **Full LLM synthesis every cycle.** Stable information (identity, long-standing facts, user preferences) is re-synthesized alongside volatile information (recent events, active tasks). The stable parts should be programmatically rendered and cached. + +4. **No temporal framing.** The bulletin has no concept of "today" vs "this week" vs "historical." Events from 5 days ago and events from 5 minutes ago occupy the same undifferentiated narrative. + +5. **No per-channel activity awareness.** The bulletin says nothing about what is happening in specific channels. Cross-channel awareness requires explicit branching and searching. + +6. **Word limit forces competition.** At 500-1500 words, stable identity content competes with volatile events for space. A weather report displaces a key decision. A user's formatting preference displaces a bug pattern. + +### What the bulletin should become + +The bulletin should be decomposed into independent, structured sections with different update cadences and different rendering strategies: + +| Section | Update Trigger | Rendering | +|---------|---------------|-----------| +| Identity/long-term context | Config change, identity file edit | Programmatic (no LLM) | +| Today's working memory | Event-driven (new memories, worker completions, key decisions) | LLM synthesis of recent events only | +| Channel activity summaries | Event-driven (new messages in other channels) | Programmatic (last N messages + topic) | +| Per-user context | When a specific user sends a message | Programmatic (recall user-scoped memories) | + +Each section is independently cached and only regenerated when its inputs change. The system prompt assembles them from cached sections, not from a single monolithic generation. + +--- + +## Summary of All Failure Modes + +| # | Problem | Impact | Severity | +|---|---------|--------|----------| +| 1 | Monolithic bulletin blob | Important info buried in noise | High | +| 2 | Zero cross-channel awareness | Info told in one channel invisible in another | Critical | +| 3 | Passive memory creation | Significant conversations produce zero memories | Critical | +| 4 | No temporal structure | Agent cannot answer "what happened today" | High | +| 5 | Token waste on stable info | 80%+ of bulletin generations are redundant | Medium | +| 6 | No per-user context | Agent cannot adapt to who it is talking to | High | +| 7 | Multi-user scaling failure | Worker/branch contention, context collapse | High | +| 8 | Compaction as memory path | Wrong trigger, wrong timing, no attribution | Medium | +| 9 | Bulletin covers all concerns in one blob | Stable and volatile content compete for space | High | +| 10 | No change detection before regeneration | Idle agents waste LLM tokens continuously | Medium | + +--- + +## What This Analysis Does NOT Cover + +This document is a problem analysis, not a design. The following questions are deferred to the design document: + +- What is the schema for working memory entries? +- How does the cortex decide what goes into working memory vs the existing memory graph? +- What is the exact context assembly layout (section order, token budgets)? +- How do topics (PR #287, cortex topic synthesis) interact with working memory? +- What are the right defaults for update cadences, detail levels, and rollup intervals? +- How does per-user context scale in a 500-person server? +- What is the migration path from the current bulletin to the new system? + +These are design decisions that should be made after this problem analysis is reviewed and agreed upon. diff --git a/docs/design-docs/working-memory-triage.md b/docs/design-docs/working-memory-triage.md new file mode 100644 index 000000000..00865888c --- /dev/null +++ b/docs/design-docs/working-memory-triage.md @@ -0,0 +1,66 @@ +# Working Memory Triage — PR #454 + +Findings from CodeRabbit review + bug reports. Tracking resolution before merge. + +## Review Findings + +### Critical + +- [x] **R1 — `matches!` moves non-Copy `ProcessEvent`** (`src/agent/cortex.rs:903`) + `matches!(event, ...)` consumes `event`, preventing `signal_from_event(...)` on the next line. **Fixed:** `matches!(&event, ...)`. + +### Major + +- [ ] **R2 — Bulletin fallback gate too aggressive** (`prompts/en/channel.md.j2:172`) + Condition `not working_memory and not knowledge_synthesis` hides bulletin when working memory exists but knowledge synthesis hasn't run yet. Should gate only on `not knowledge_synthesis`. + +- [ ] **R3 — Don't exclude participant-role facts yet** (`prompts/en/cortex_knowledge_synthesis.md.j2:21`) + Exclusion of "The user is the CEO" drops participant context with nowhere else to live until Phase 6 ships. + +- [ ] **R4 — Raw worker task in working memory** (`src/agent/channel_dispatch.rs:596`) + `task` from user input persisted verbatim; could capture secrets/PII. Truncate and scrub. + +- [ ] **R5 — Dirty flag only bumps on merges** (`src/agent/cortex.rs:1958`) + Prunes and decays also change the memory set but don't trigger knowledge synthesis re-gen. Add `report.pruned > 0 || report.decayed > 0`. + +- [ ] **R6 — Dirty-flag synthesis not mutex-guarded** (`src/agent/cortex.rs:2106`) + Can race with warmup synthesis path. Should acquire the same synthesis mutex. + +- [ ] **R7 — Intraday/daily synthesis blocks main cortex loop** (`src/agent/cortex.rs:2166`) + LLM calls awaited inline inside `tokio::select!`; events stop draining during synthesis. Spawn as background tasks. + +- [ ] **R8 — Empty sections treated as successful no-op** (`src/agent/cortex.rs:2558`) + Returns before tasks can contribute to synthesis; dirty flag never clears, causing infinite rescheduling. + +- [ ] **R9 — Missing `default_max_turns(1)` + inline preambles** (`src/agent/cortex.rs:2579`) + Three cortex agent builders lack explicit max_turns; two have inline preamble strings instead of prompt files. + +- [ ] **R10 — Version snapshot after async work** (`src/agent/cortex.rs:2614`) + `knowledge_synthesis_last_version` read after LLM call; concurrent writes can advance the version past what was actually synthesized. Snapshot before. + +- [x] **R11 — Unsynthesized yesterday events dropped** (`src/agent/cortex.rs:2916`) + Raw events that didn't hit count/time trigger before midnight are lost from daily summary. Roll them into the summary. **Fixed:** daily summary now fetches all raw events, filters to the unsynthesized tail after the last intra-day synthesis, and includes them in the LLM input. + +- [ ] **R12 — Silent error swallowing in inspect_prompt** (`src/api/channels.rs:649`) + `unwrap_or_default()` / `.ok()` hides DB/template errors. Log and propagate per coding guidelines. + +- [ ] **R13 — Raw error strings in working memory** (`src/cron/scheduler.rs:386`) + Full error text persisted; could contain sensitive internals. Emit redacted summary only. + +- [ ] **R14 — Timezone fallback drops valid `cron_timezone`** (`src/main.rs:2559`) + If `user_timezone` is present but unparseable, `cron_timezone` is never tried. Parse each independently. + +- [x] **R15 — UTF-8 panic on topic truncation** (`src/memory/working.rs:739`) + Byte-index slice at 80 can split multibyte chars. **Fixed:** `floor_char_boundary(80)`. + +- [ ] **R16 — Task update event always says "status change"** (`src/tools/task_update.rs:246`) + Every update emits `"updated to "` even for title/description edits. Compute actual delta. + +## Live Observations (from prompt inspect, March 19) + +- [x] **O1 — March 18 daily summary missing** (confirmed R11) + Yesterday had a full day of heavy working memory implementation work. None of it appears in "Earlier This Week" — only `2026-03-17: No activity`. This is real content loss. **Fixed with R11.** + +## Bug Reports + + diff --git a/docs/design-docs/working-memory.md b/docs/design-docs/working-memory.md new file mode 100644 index 000000000..a5a49d06e --- /dev/null +++ b/docs/design-docs/working-memory.md @@ -0,0 +1,1091 @@ +# Working Memory + +Replace the monolithic bulletin with a layered context assembly system. Each layer has its own data source, update trigger, and rendering strategy. The agent gets structured situational awareness -- what happened today, what is happening in other channels, who it is talking to -- without a single LLM-synthesized blob that tries to be everything for everyone. + +This design supersedes the bulletin system. It complements (does not replace) the tiered memory design (`tiered-memory.md`), the participant awareness design (`participant-awareness.md`), and the user-scoped memories design (`user-scoped-memories.md`). Where those designs overlap with this one, the overlapping sections are noted and reconciled. + +Prerequisite reading: `working-memory-problem-analysis.md`. + +## The Five Layers + +The system prompt is assembled from five independently managed layers. Each layer has a different volatility, a different update trigger, and a different rendering strategy. No layer depends on another for its content -- they are composed at prompt assembly time. + +``` +System Prompt Assembly + 1. Identity Context [stable] rendered on file change + 2. Working Memory Log [volatile] rendered on every turn from DB + 3. Channel Activity Map [volatile] rendered on every turn from DB + 4. Participant Context [semi-volatile] rendered on every turn from cached summaries + 5. Knowledge Synthesis [semi-stable] regenerated on dirty flag + --- existing sections follow --- + 6. Channel instructions, delegation, tools, rules (unchanged) + 7. Status block (unchanged) + 8. Conversation history (unchanged) +``` + +Layers 1-5 replace `## Memory Context` (the bulletin) in the current `channel.md.j2` template. Everything after layer 5 is unchanged. + +--- + +## Layer 1: Identity Context + +**What it is:** The agent's stable identity -- personality, role, authority, product knowledge. What exists today as the Soul, Identity, and Role files loaded from disk. + +**What changes:** Nothing structural. These files are already loaded at startup and hot-reloaded on change. The only change is that the bulletin no longer re-synthesizes information from these files. Identity facts that the bulletin used to repeat (product descriptions, star counts, role statements) are no longer duplicated in the synthesized output. + +**Update trigger:** File change on disk (existing `notify` watcher). + +**Rendering:** Programmatic. The raw markdown is injected directly. No LLM. + +**Token budget:** Whatever the user wrote. Not our concern to optimize -- these are the user's own words. + +--- + +## Layer 2: Working Memory Log + +The core of this design. An append-only, structured event log scoped by day. Every significant thing that happens across the agent is recorded as a timestamped event. The channel gets a progressively compressed view: today in detail, yesterday as a summary, the week as a paragraph. + +### Event Types + +Events are structured records, not free-text memories. Each event has a type, a timestamp, a source channel, and a one-line description. + +```rust +pub struct WorkingMemoryEvent { + pub id: String, // UUID + pub event_type: WorkingMemoryEventType, + pub timestamp: DateTime, + pub channel_id: Option, // which channel this happened in + pub user_id: Option, // canonical user ID if user-initiated + pub summary: String, // one-line human-readable description + pub detail: Option, // optional longer context (worker result, branch conclusion) + pub importance: f32, // 0.0-1.0, used for filtering under token pressure +} + +#[derive(Debug, Clone, Copy)] +pub enum WorkingMemoryEventType { + /// A branch completed with a conclusion + BranchCompleted, + /// A worker was spawned + WorkerSpawned, + /// A worker completed (success or failure) + WorkerCompleted, + /// A cron job executed + CronExecuted, + /// A memory was saved (by any path) + MemorySaved, + /// A decision was made (extracted from conversation) + Decision, + /// An error or failure occurred + Error, + /// A task was created or updated + TaskUpdate, + /// Cross-agent communication + AgentMessage, + /// System event (startup, config change, maintenance) + System, + /// A graph memory was promoted to working tier (reserved for tiered memory integration) + MemoryPromoted, + /// A graph memory was demoted from working tier (reserved for tiered memory integration) + MemoryDemoted, +} +``` + +### What Emits Events + +Events are emitted **programmatically at the point they happen**. No LLM decides whether to emit them. The processes themselves write events as a side effect of doing their work. + +| Source | Event | When | +| -------------- | ----------------- | ------------------------------------------------------------------ | +| Branch | `BranchCompleted` | When a branch returns its conclusion | +| Worker | `WorkerSpawned` | When `spawn_worker` tool executes | +| Worker | `WorkerCompleted` | When a worker reaches terminal state | +| Cron | `CronExecuted` | When a cron job fires and completes | +| Memory tools | `MemorySaved` | When `memory_save` succeeds (already emitted on `memory_event_tx`) | +| Branch/Channel | `Decision` | When the LLM explicitly flags a decision (see below) | +| Any process | `Error` | On tool failure, worker failure, cancellation | +| Task tools | `TaskUpdate` | When task board is modified | +| Link channels | `AgentMessage` | On cross-agent message send/receive | +| Cortex | `System` | On startup, config reload, maintenance run | + +User messages and agent responses are **not** recorded as working memory events. They already live in the `conversation_messages` table with full attribution, timestamps, and channel context. The channel activity map (Layer 3) queries that table directly for recent per-channel activity. Duplicating messages into the working memory log would be redundant. + +### Decision Extraction + +The `Decision` event type deserves special attention. Decisions are the highest-value working memory entries -- "we decided to use JWT instead of sessions," "Jamie approved the PR," "sandbox disabled for gh cli." + +Two extraction paths: + +**Programmatic:** When the agent uses the `reply` tool with certain patterns (confirming an action, committing to a plan, changing course), the channel can tag the event as a decision. This is heuristic and imperfect. + +**LLM-assisted:** The memory persistence branch (which already runs periodically) gains a new responsibility: in addition to saving graph memories, it emits `Decision` events into the working memory log for any decisions it identifies in the conversation. This costs nothing extra -- the persistence branch is already reading the conversation and making judgments about what matters. + +### Storage + +SQLite table, one row per event: + +```sql +CREATE TABLE IF NOT EXISTS working_memory_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + channel_id TEXT, + user_id TEXT, + summary TEXT NOT NULL, + detail TEXT, + importance REAL NOT NULL DEFAULT 0.5, + day TEXT NOT NULL -- date string 'YYYY-MM-DD' for fast day-based queries +); + +CREATE INDEX idx_wm_events_day ON working_memory_events(day, timestamp); +CREATE INDEX idx_wm_events_channel ON working_memory_events(channel_id, timestamp); +CREATE INDEX idx_wm_events_type ON working_memory_events(event_type, timestamp); +``` + +The `day` column is denormalized from `timestamp` for fast date-range queries without function calls in WHERE clauses. **The day is always computed in the agent's configured timezone at insert time**, not UTC. This ensures a 23:30 UTC event for a UTC+2 agent is correctly recorded as the next calendar day. The `WorkingMemoryStore` takes a timezone at construction and applies it consistently to all day computations (inserts, queries, day-rollover checks). + +```rust +impl WorkingMemoryStore { + pub fn new(pool: SqlitePool, timezone: chrono_tz::Tz) -> Arc; +} +``` + +### Intra-Day Synthesis + +In a busy environment -- 10 engineers in a Slack, hundreds of workers per day, thousands of messages across channels -- raw events are useless. Fifty worker completions, thirty branch conclusions, and a dozen cron executions do not fit in a token budget, and even if they did, a scrolling log is not situational awareness. + +**Today's section is always a synthesis, never a raw event list.** The cortex maintains a rolling narrative of the current day by synthesizing events in batches as they accumulate. + +**Trigger:** Dual trigger — event-count or time-based, whichever fires first. + +1. **Count-based:** When the number of new events since the last intra-day synthesis crosses a threshold (configurable, default 15), synthesize. +2. **Time-based fallback:** If any unsynthesized events exist and it has been more than `intraday_time_fallback_secs` (configurable, default 4 hours) since the last synthesis, synthesize regardless of count. This ensures quiet agents still get narrative blocks instead of raw event tails all day. + +A quiet agent with 5-10 events per day gets 1-2 syntheses (time-triggered). A busy agent gets 10+ (count-triggered). Each synthesis is incremental -- it covers only the new batch, not the whole day. + +**Storage:** + +```sql +CREATE TABLE IF NOT EXISTS working_memory_intraday_syntheses ( + id TEXT PRIMARY KEY, + day TEXT NOT NULL, + time_range_start TIMESTAMP NOT NULL, -- first event in this batch + time_range_end TIMESTAMP NOT NULL, -- last event in this batch + summary TEXT NOT NULL, -- 50-100 word narrative of this batch + event_count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_wm_intraday_day ON working_memory_intraday_syntheses(day, time_range_start); +``` + +Each row is one synthesis batch -- a paragraph covering a time range. The `day` column groups them. The rows are append-only within a day. + +**Synthesis prompt:** + +``` +Summarize the following {{ event_count }} events from {{ time_start }} to {{ time_end }} +into a concise 50-100 word narrative paragraph. + +Focus on: what was accomplished, what decisions were made, what failed, what is in progress. +Be specific about who did what and in which channel. Use present/past tense naturally. +Do not list events mechanically -- write a narrative. + +Events: +{{ events }} +``` + +### Daily Summaries + +At day rollover, the cortex synthesizes the full day. Rather than re-processing raw events, it summarizes the intra-day synthesis paragraphs into a single cohesive narrative. This is cheap -- the input is a few paragraphs, not hundreds of events. + +```sql +CREATE TABLE IF NOT EXISTS working_memory_daily_summaries ( + day TEXT PRIMARY KEY, -- 'YYYY-MM-DD' + summary TEXT NOT NULL, -- LLM-synthesized narrative, 200-400 words + event_count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +The cortex checks on each tick: if the current day has changed since the last daily summary, synthesize yesterday. Input: all `working_memory_intraday_syntheses` rows for that day, plus any unsynthesized raw events that didn't hit the batch threshold. One LLM call per day. + +Weekly and monthly summaries can be added later by summarizing daily summaries. For now, daily is sufficient. + +### Context Injection + +The working memory section is assembled from synthesis blocks + a raw event tail. Today's view is always narrative-first. + +``` +## Working Memory + +### Today (Wednesday, March 18) +[morning] Shipped PR #447 fix to staging. Set up task board for 30 open PRs +after disabling sandbox for gh cli access. OAuth research confirmed device-code +flow works for ChatGPT. bergabman active in #talk-to-spacebot with OAuth +config questions. + +[afternoon] Shifted to working memory implementation. Phase 1 complete -- +event store, types, and all emission points wired. Decided to sunset +compaction-based memory extraction. Three users active in #talk-to-spacebot +discussing provider config. + +**Since last synthesis (14:20):** +- Worker completed: Phase 2 context injection done +- Decision: Remove UserMessage from working memory events +- Branch completed: config validation for new WorkingMemoryConfig fields + +### Yesterday (Tuesday, March 17) +Quiet day. Jamie tested voice integration in portal chat. bergabman submitted +PR #441 (opinionated Discord reply policy). Two cron jobs ran (inbox-check, +repo-monitor). No critical decisions. + +### This Week +Focus: patch release stability + working memory design. 14 PRs reviewed across +3 days. Key decisions: sunset compaction-based memory saving, redesign bulletin +as layered context. Active contributors: Jamie, bergabman, vsumner, l33t0. +``` + +**Rendering logic:** + +1. **Today:** Concatenate all `working_memory_intraday_syntheses` rows for today (time-labeled paragraphs). Append a "Since last synthesis" tail of raw events that haven't been synthesized yet (capped at `today_max_unsynthesized_events`, default 10). +2. **Yesterday:** The daily summary from `working_memory_daily_summaries`. +3. **This week:** Concatenated daily summaries for the past 5 days, truncated to fit budget. + +All rendering is programmatic -- string concatenation from cached synthesis rows and SQL queries. No LLM call on the rendering path. + +**Token budget:** Configurable, default 1500 tokens. Applied as follows: + +1. Today's synthesis blocks + raw tail: up to 60% of budget. +2. Yesterday's summary: up to 80% consumed. +3. This week: remaining budget. +4. If today's synthesis blocks alone exceed budget: show only the most recent N blocks + the raw tail. Older blocks are dropped (they are still in the database for search). + +**Events from the current channel get a boost.** When rendering for Channel B, events from Channel B are always included (up to a reasonable cap). Events from other channels are included by importance and recency. + +### Relationship to Tiered Memory and the Graph + +Working memory events are NOT memories in the graph. They are a separate data structure with a separate lifecycle. The [tiered memory system](tiered-memory.md) is a third concern that operates on the graph memory store itself. + +| | Working Memory Events | Graph Memories | Tiered Memory | +| --------- | --------------------------------- | ---------------------------------- | ---------------------------------------------- | +| Structure | Timestamped log entries | Typed objects with associations | `tier` column on graph memories | +| Lifecycle | Day-scoped, synthesized, archived | Importance-based decay and pruning | 3-day working state → graph demotion | +| Injection | Layers 2-5 of system prompt | On-demand via branch recall | Not injected — affects recall ranking | +| Creation | Automatic (event-driven) | Intentional (branch/persistence) | Automatic (new memories start in working tier) | +| Purpose | Situational awareness (channels) | Long-term knowledge | Search quality (branches) | + +All three systems are complementary: + +- **Working memory events** tell the channel what happened today: "we decided X at 3pm." Synthesized into narrative blocks, always in context. +- **Graph memories** capture durable knowledge: the decision itself, with full context, associations, importance scoring, and long-term retrieval. +- **Tiered memory** ensures that when a branch calls `memory_recall`, the graph memory saved 2 minutes ago ranks higher than the one from 3 weeks ago. It is a search and lifecycle improvement on the graph, not a context injection mechanism. + +The memory persistence branch feeds both systems: it saves graph memories (which start in the working tier) and emits working memory events for decisions and facts it identifies. Both systems get fed from the same observation pass. + +--- + +## Layer 3: Channel Activity Map + +A compact, programmatic summary of what is happening in all other channels right now. Gives the agent ambient awareness of activity elsewhere without injecting transcripts. + +### What it contains + +For each channel the agent has, render: + +- Channel name +- Time since last activity +- Who is active (display names of recent senders) +- One-line topic (last branch conclusion or last significant message, truncated) + +### Rendering + +Fully programmatic. SQL queries against `conversation_messages` and the channel's in-memory state. No LLM. + +``` +## Other Channels +#development -- 12m ago, vsumner: discussing PR #447 fix +#talk-to-spacebot -- 3m ago, bergabman + okuna: ChatGPT OAuth config +#general -- 2h ago, release timeline discussion +#random -- 1d ago, inactive +``` + +**Update trigger:** Rendered fresh on every turn from the conversation database + channel registry. Cheap -- it is a bounded query (one row per channel, last message + sender). + +**Token budget:** Configurable, default 300 tokens. Channels sorted by recency. Inactive channels (>24h) are collapsed to a single line or omitted entirely (configurable). + +**What channels are included:** All channels for the same agent on the same messaging instance. Cross-platform channels (the same agent on Discord and Slack) are included with a platform prefix. Cross-agent channels are not included (that is the link-channels system's domain). + +### Implementation + +The channel registry (`ChannelStore` / in-memory channel map) already tracks active channels. We need to expose a method that returns the activity summary for all channels except the current one: + +```rust +impl ChannelStore { + /// Returns a compact activity summary for all channels except `exclude`. + pub async fn get_activity_map( + &self, + exclude_channel_id: &str, + max_channels: usize, + ) -> Result>; +} + +pub struct ChannelActivity { + pub channel_id: ChannelId, + pub channel_name: String, + pub platform: String, + pub last_message_at: Option>, + pub last_sender_name: Option, + pub recent_sender_names: Vec, // last 3 unique senders + pub topic_hint: Option, // last branch conclusion or significant message +} +``` + +The `topic_hint` comes from the most recent `BranchCompleted` working memory event for that channel, or the last significant message if no branches ran. This is already in the working memory events table -- a single indexed query. + +--- + +## Layer 4: Participant Context + +Per-user context injected when specific users are active in the current channel. The agent knows who it is talking to before it starts thinking. + +### Relationship to existing designs + +This layer is the `participant-awareness.md` design, integrated into the layered context assembly. The design is unchanged -- `humans` table, cortex-generated summaries, cached and injected when `participants.len() >= min_participants`. The only change is its position in the prompt (it moves from "after Memory Context" to its own layer in the new assembly order). + +If user-scoped memories lands first, the `humans` table merges with `user_identifiers` as described in that design doc. The working memory system does not depend on which identity table is canonical. + +### Enhancement: Recent Activity Per User + +The participant summary (2-3 sentences about who this person is) is augmented with a line of recent activity pulled from the working memory events table: + +``` +## Participants + +**bergabman** -- Power user, runs modified Spacebot with reasoning effort support. + Previously built ChatGPT OAuth integration. Prefers technical responses. + Recent: asked about OAuth config in #talk-to-spacebot 2h ago, submitted PR #441 yesterday. + +**okuna** -- New user, joined 3 days ago. Has been asking setup questions. + Background in Docker and DevOps. + Recent: asked about Docker runtime deps in #talk-to-spacebot 10m ago. +``` + +The "Recent:" line is programmatic -- a query against `working_memory_events WHERE user_id = X ORDER BY timestamp DESC LIMIT 3`. The summary paragraph is the cached cortex-generated profile from the participant-awareness design. No additional LLM call. + +**Token budget:** Configurable, default 400 tokens. Max 5 participants rendered. In a 50-person channel, only the 5 most recently active participants get profiles. + +--- + +## Layer 5: Knowledge Synthesis + +The remnant of the bulletin. Long-term knowledge that does not fit into the temporal working memory log or the identity files. Strategic context, ongoing themes, accumulated observations. + +### What it contains + +- Cross-cutting themes from graph memories (not tied to a specific day or channel) +- Active goals and strategic direction +- Known unknowns ("detailed intelligence on Spacedrive requires updating") +- Accumulated observations from the cortex + +### What it does NOT contain + +- Identity/role information (Layer 1) +- Recent events (Layer 2) +- Channel activity (Layer 3) +- User profiles (Layer 4) +- Bug reports, weather, per-user formatting preferences (these are graph memories, recalled on demand by branches) + +### Rendering + +LLM-synthesized, but only when the underlying data changes. + +**Dirty flag mechanism:** A counter `knowledge_synthesis_version` in `RuntimeConfig` (atomic u64). Incremented when a memory's content is created, updated, or deleted. **Not** incremented on importance-only changes (decay, access count updates) — those shift ranking but don't change what the agent knows. The cortex checks on each tick: if the counter has changed since the last synthesis, regenerate. If not, skip. + +This replaces the timer-based bulletin. An idle agent with no new memories generates zero synthesis calls. A busy agent with 50 new memories in an hour triggers one synthesis after activity settles (debounced -- wait 60 seconds after the last memory change before regenerating, to avoid regenerating mid-burst). + +**Scope reduction:** The synthesis prompt is narrower than the current bulletin prompt. It does not ask for identity context, recent events, or user profiles -- those are handled by other layers. It asks only for: + +``` +Synthesize the agent's long-term knowledge into a concise briefing. +Focus on: +- Active goals and strategic direction +- Cross-cutting themes and patterns +- Known gaps in knowledge +- Accumulated observations + +Do not include: identity/role information, recent events, channel activity, +or user profiles. Those are provided separately. + +Maximum {{ max_words }} words. +``` + +**Token budget:** Configurable, default 500 tokens. Substantially smaller than the current bulletin (which was 500-1500 words carrying all concerns). + +### Interaction with Topics (PR #287) + +The cortex topic synthesis system (PR #287) produces living documents on specific themes -- detailed, searchable, pulled into workers on demand. Knowledge synthesis (Layer 5) is the broad overview; topics are the deep dives. + +When topics are available, the knowledge synthesis can reference them: "See topic 'OAuth Integration' for detailed provider status." This keeps the synthesis concise while pointing to deeper context that branches and workers can access. + +Topics are NOT injected into the channel system prompt by default. They are pulled in by branches and workers when relevant. This is a key difference from working memory layers 1-5, which are always present. + +--- + +## Memory Creation Overhaul + +### Sunset Compaction-Based Memory Extraction + +The compactor's job is context management -- summarizing old context to free up space. Memory extraction is a secondary concern that was bolted on because there was no better place for it. + +**Change:** The compactor no longer calls `memory_save`. Its sole output is a compaction summary that replaces the compacted messages in the conversation history. Memories are extracted through other paths. + +**Why this is safe:** The memory persistence branch already exists and runs periodically. It has better context (full conversation history, not just the messages being compacted) and better attribution (it can set `user_id` and `channel_id`). The compactor was a safety net for conversations that ran long enough to hit compaction but not long enough to trigger persistence. That safety net is replaced by event-driven memory capture (see below). + +### Memory Persistence Branch: Smarter Triggers + +The periodic memory persistence branch currently runs every 50 user messages. Replace with signal-based triggers: + +1. **Message count trigger (reduced):** Every 20 user messages instead of 50. Lower threshold means less information lost between persistence runs. + +2. **Time-based trigger:** If a conversation has been active for 15 minutes since the last persistence run, trigger regardless of message count. Catches slow-but-important conversations. + +3. **Event-density trigger:** If the working memory log has recorded 5+ events from this channel since the last persistence run, trigger. Catches conversations with high signal (multiple decisions, worker completions, etc.) even if the raw message count is low. + +4. **Explicit trigger:** The LLM can call a new `persist_memories` channel tool to force a persistence run. For moments where the agent recognizes something important just happened. (This is optional -- the automatic triggers should be sufficient in most cases.) + +### Persistence Branch Dual Output + +The memory persistence branch gains a second responsibility: in addition to saving graph memories, it emits `Decision` and `MemorySaved` events into the working memory log. This connects the two systems: + +``` +Persistence branch runs: + 1. Recalls existing graph memories (avoid duplicates) + 2. Reads conversation history since last run + 3. Saves new graph memories via memory_save (as today) + 4. Identifies key decisions and events + 5. Emits working memory events for each decision identified + 6. Calls memory_persistence_complete +``` + +Step 5 is new. The persistence branch prompt is updated to instruct it to identify decisions explicitly. The `memory_persistence_complete` tool gains an optional `events` field: + +```rust +pub struct MemoryPersistenceCompleteArgs { + pub outcome: String, + pub memory_ids: Vec, + pub events: Option>, // new +} + +pub struct WorkingMemoryEventInput { + pub event_type: String, // "decision", "error", etc. + pub summary: String, + pub importance: Option, +} +``` + +### Structured Event Capture (Zero LLM Cost) + +The majority of working memory events are captured programmatically, with zero LLM involvement: + +| Event | Emitter | How | +| ---------------- | -------------------------------------------- | -------------------------------------------------------------------- | +| Worker spawned | `spawn_worker` tool handler | After successful spawn, write event with task description as summary | +| Worker completed | Worker state machine terminal transition | Write event with worker result summary (truncated to 200 chars) | +| Branch completed | Branch return path in channel | Write event with branch conclusion (truncated to 200 chars) | +| Cron executed | Cron scheduler after job completes | Write event with cron name + outcome | +| Memory saved | `memory_save` tool handler | Write event with memory type + content preview | +| Task updated | Task tool handlers | Write event with task title + new status | +| Error | SpacebotHook on tool failure, worker failure | Write event with error description | +| System | Startup, config change, maintenance | Write event with description | + +Each emitter calls `working_memory_store.record_event()` as a fire-and-forget `tokio::spawn`. The message processing pipeline never waits on event recording. + +```rust +impl WorkingMemoryStore { + /// Fire-and-forget event recording. Never blocks the caller. + pub fn record(&self, event: WorkingMemoryEvent) { + let pool = self.pool.clone(); + tokio::spawn(async move { + if let Err(error) = insert_event(&pool, &event).await { + tracing::warn!(%error, "failed to record working memory event"); + } + }); + } +} +``` + +--- + +## Cortex Role Changes + +The cortex shifts from "generate a blob every 15 minutes" to four focused responsibilities: + +### 1. Intra-Day Synthesis (Event-Count Triggered) + +On each tick, check if the number of unsynthesized events for today exceeds the batch threshold (configurable, default 15). If so, synthesize the batch into a paragraph and store it in `working_memory_intraday_syntheses`. One LLM call per batch. + +```rust +async fn maybe_synthesize_intraday_batch(&self) -> Result<()> { + let today = current_day(&self.config); + let last_synthesis_end = self.working_memory + .get_last_intraday_synthesis_end(&today) + .await?; + + let unsynthesized = self.working_memory + .get_events_after(&today, last_synthesis_end) + .await?; + + let threshold_met = unsynthesized.len() >= self.config.working_memory.intraday_batch_threshold; + let time_fallback = !unsynthesized.is_empty() + && last_synthesis_end + .map(|t| Utc::now() - t > Duration::seconds(self.config.working_memory.intraday_time_fallback_secs as i64)) + .unwrap_or(true); // no previous synthesis today → fallback fires + + if !threshold_met && !time_fallback { + return Ok(()); + } + + let time_start = unsynthesized.first().unwrap().timestamp; + let time_end = unsynthesized.last().unwrap().timestamp; + let summary = self.synthesize_intraday_batch(&unsynthesized).await?; + + self.working_memory.save_intraday_synthesis( + &today, time_start, time_end, &summary, unsynthesized.len() + ).await?; + + Ok(()) +} +``` + +On a quiet day this fires 1-2 times. On a busy day with hundreds of workers it fires every few minutes, each time digesting the latest batch into a readable paragraph. The LLM cost scales linearly with activity, not with time. + +### 2. Daily Summary Synthesis (Day Rollover) + +On each tick, check if the day has rolled over. If yesterday has no daily summary yet, synthesize one from yesterday's intra-day synthesis paragraphs (not raw events -- the paragraphs are already digested). One LLM call per day. + +```rust +async fn maybe_synthesize_daily_summary(&self) -> Result<()> { + let today = current_day(&self.config); + let yesterday = today - Duration::days(1); + let yesterday_str = yesterday.format("%Y-%m-%d").to_string(); + + if self.working_memory.has_daily_summary(&yesterday_str).await? { + return Ok(()); + } + + let intraday_blocks = self.working_memory + .get_intraday_syntheses(&yesterday_str).await?; + + if intraday_blocks.is_empty() { + self.working_memory.save_daily_summary(&yesterday_str, "No activity.", 0).await?; + return Ok(()); + } + + // Summarize the intra-day paragraphs, not raw events + let summary = self.synthesize_daily_summary(&yesterday_str, &intraday_blocks).await?; + let event_count = intraday_blocks.iter().map(|b| b.event_count).sum(); + self.working_memory.save_daily_summary(&yesterday_str, &summary, event_count).await?; + Ok(()) +} +``` + +### 3. Knowledge Synthesis (On Dirty Flag) + +On each tick, check if `knowledge_synthesis_version` has changed. If so, debounce (wait 60s after last change), then regenerate the knowledge synthesis (Layer 5). Store in `RuntimeConfig::knowledge_synthesis` via `ArcSwap`. + +This replaces the bulletin generation loop entirely. The warmup loop still runs at startup to generate the initial synthesis, but the recurring generation is change-driven, not timer-driven. + +### 4. Participant Summary Generation + +Unchanged from the `participant-awareness.md` design. The cortex checks for stale human summaries and regenerates them. This already runs on a 5-minute tick -- it is cheap and targeted. + +### What the Cortex No Longer Does + +- **Bulletin generation on a timer.** Replaced by change-driven knowledge synthesis. +- **Full 8-section memory retrieval every 15 minutes.** Only Layer 5 queries the memory store, and only when dirty. +- **Warmup-driven bulletin regeneration every 15 minutes.** The warmup loop ensures Layer 5 exists on startup. After that, it is change-driven. + +### Warmup Changes + +The warmup loop still runs to ensure the agent is ready before accepting traffic. But its scope shrinks: + +1. **Embedding warmup** -- unchanged, ensures FastEmbed is loaded. +2. **Knowledge synthesis** -- generate Layer 5 if it does not exist. After initial generation, this is change-driven. +3. **Working memory events** -- no warmup needed, they are always available (SQL queries). +4. **Channel activity map** -- no warmup needed, it is always available (SQL queries). +5. **Participant summaries** -- no warmup needed for the summaries to exist (they generate on their own schedule). The readiness contract may relax to not require participant summaries before accepting traffic. + +The `ready_for_work` contract changes from "warm state + embedding ready + fresh bulletin" to "embedding ready + knowledge synthesis exists." The working memory log and channel activity map are always ready (they are database queries, not LLM outputs). + +--- + +## Working Memory Event Lifecycle + +### Today's Events (0-24h) + +Raw events accumulate in `working_memory_events`. As they accumulate, the cortex synthesizes them in batches into `working_memory_intraday_syntheses` rows (see "Intra-Day Synthesis" above). The channel's context shows today's synthesis paragraphs plus a raw tail of unsynthesized recent events. Raw events are never shown directly once synthesized -- the synthesis replaces them in the context view. + +### Yesterday (24-48h) + +At day rollover, the cortex synthesizes the day's intra-day synthesis paragraphs (plus any remaining unsynthesized events) into a single `working_memory_daily_summaries` row. The raw events and intra-day syntheses are retained for search but no longer injected into context -- only the daily summary is. + +### Older Days (48h+) + +Daily summaries are the canonical representation. Raw events are retained for search (the `working_memory_events` table is append-only and queryable) but are not rendered into context. + +### Weekly Summaries (optional, future) + +After 7 daily summaries exist for a week, the cortex can synthesize them into a weekly summary. One LLM call per week. This provides the "this week" paragraph in the context injection. For the initial implementation, "this week" is rendered by concatenating the last 5-7 daily summaries and truncating to fit the token budget. LLM-synthesized weekly summaries can be added later. + +### Pruning + +Raw events older than 30 days are pruned. Intra-day syntheses older than 30 days are pruned (the daily summary subsumes them). Daily summaries are retained indefinitely (they are small -- one row per day). This gives the agent permanent access to "what happened on March 18" through the daily summaries, and 30 days of granular event-level and intra-day synthesis search. + +Pruning runs as part of the existing cortex maintenance loop, alongside memory decay and graph pruning. + +--- + +## Schema Summary + +### New Tables + +```sql +-- Working memory events: the append-only log +CREATE TABLE IF NOT EXISTS working_memory_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + channel_id TEXT, + user_id TEXT, + summary TEXT NOT NULL, + detail TEXT, + importance REAL NOT NULL DEFAULT 0.5, + day TEXT NOT NULL +); + +CREATE INDEX idx_wm_events_day ON working_memory_events(day, timestamp); +CREATE INDEX idx_wm_events_channel ON working_memory_events(channel_id, timestamp); +CREATE INDEX idx_wm_events_type ON working_memory_events(event_type, timestamp); + +-- Intra-day synthesis: rolling narrative blocks within a day +CREATE TABLE IF NOT EXISTS working_memory_intraday_syntheses ( + id TEXT PRIMARY KEY, + day TEXT NOT NULL, + time_range_start TIMESTAMP NOT NULL, + time_range_end TIMESTAMP NOT NULL, + summary TEXT NOT NULL, + event_count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_wm_intraday_day ON working_memory_intraday_syntheses(day, time_range_start); + +-- Daily summaries: synthesized narratives per day +CREATE TABLE IF NOT EXISTS working_memory_daily_summaries ( + day TEXT PRIMARY KEY, + summary TEXT NOT NULL, + event_count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +### No Changes to Existing Tables + +The `memories` table is unchanged. Working memory events are a separate system that complements graph memories, not a modification to them. The tiered memory design (`tier` column, `demoted_at` column) proceeds independently. + +### New Rust Types + +```rust +// src/memory/working.rs (new module) + +pub struct WorkingMemoryStore { + pool: SqlitePool, +} + +pub struct WorkingMemoryEvent { ... } // as defined above +pub enum WorkingMemoryEventType { ... } // as defined above + +pub struct IntradaySynthesis { + pub id: String, + pub day: String, + pub time_range_start: DateTime, + pub time_range_end: DateTime, + pub summary: String, + pub event_count: i64, + pub created_at: DateTime, +} + +pub struct DailySummary { + pub day: String, + pub summary: String, + pub event_count: i64, + pub created_at: DateTime, +} + +pub struct WorkingMemoryContext { + pub today_syntheses: Vec, // intra-day narrative blocks + pub today_unsynthesized: Vec, // raw tail + pub yesterday_summary: Option, + pub week_summary: Option, +} +``` + +Each agent has its own SQLite database, so no `agent_id` column is needed on any table. The database file itself is the agent scope. + +--- + +## Configuration + +New fields on `CortexConfig`: + +```rust +pub struct WorkingMemoryConfig { + /// Whether working memory is enabled (default: true) + pub enabled: bool, + + /// Events before an intra-day synthesis batch is triggered (default: 15) + pub intraday_batch_threshold: usize, + + /// Seconds before time-based fallback triggers intra-day synthesis (default: 14400 / 4 hours) + /// Ensures quiet agents still get narrative blocks even with few events + pub intraday_time_fallback_secs: u64, + + /// Maximum unsynthesized recent events to show in the raw tail (default: 10) + pub today_max_unsynthesized_events: usize, + + /// Token budget for the entire working memory section (default: 1500) + pub context_token_budget: usize, + + /// Token budget for the channel activity map (default: 300) + pub channel_map_token_budget: usize, + + /// Maximum channels to show in the activity map (default: 10) + pub channel_map_max_channels: usize, + + /// Hide inactive channels after this many hours (default: 24) + pub channel_map_inactive_hours: u64, + + /// Minimum importance for events to be included under token pressure (default: 0.5) + pub min_importance_under_pressure: f32, + + /// Days to retain raw events before pruning (default: 30) + pub event_retention_days: i64, + + /// Daily summary max words (default: 300) + pub daily_summary_max_words: usize, + + /// Persistence branch trigger: message count threshold (default: 20) + pub persistence_message_threshold: usize, + + /// Persistence branch trigger: time threshold in seconds (default: 900 / 15 min) + pub persistence_time_threshold_secs: u64, + + /// Persistence branch trigger: event density threshold (default: 5) + pub persistence_event_density_threshold: usize, +} +``` + +On `CortexConfig`, replace `bulletin_interval_secs` and `bulletin_max_words` with: + +```rust +pub struct CortexConfig { + // ... existing fields (tick_interval_secs, worker_timeout_secs, etc.) + + /// Knowledge synthesis max words (default: 500). Replaces bulletin_max_words. + pub knowledge_synthesis_max_words: usize, + + /// Debounce seconds after last memory change before regenerating knowledge synthesis (default: 60) + pub knowledge_synthesis_debounce_secs: u64, + + /// Working memory configuration + pub working_memory: WorkingMemoryConfig, +} +``` + +`bulletin_interval_secs` and `bulletin_max_words` are deprecated. They continue to work as aliases for backward compatibility (mapping to `knowledge_synthesis_debounce_secs` and `knowledge_synthesis_max_words` respectively) but are removed from documentation and examples. + +All config is hot-reloadable via `ArcSwap`. + +--- + +## Context Assembly: The New `build_system_prompt` + +The channel's `build_system_prompt()` method changes from: + +``` +identity_context + memory_bulletin + channel_prompt + status_block +``` + +To: + +``` +identity_context // Layer 1 (unchanged) ++ working_memory_section // Layer 2 (new) ++ channel_activity_map // Layer 3 (new) ++ participant_context // Layer 4 (from participant-awareness design) ++ knowledge_synthesis // Layer 5 (replaces bulletin) ++ channel_prompt // unchanged ++ status_block // unchanged +``` + +Each layer is rendered independently before assembly. The rendering functions: + +```rust +/// Layer 2: Working memory log, rendered from DB. +async fn render_working_memory( + store: &WorkingMemoryStore, + channel_id: &str, + config: &WorkingMemoryConfig, + timezone: &str, +) -> Result; + +/// Layer 3: Channel activity map, rendered from DB + channel registry. +async fn render_channel_activity_map( + channel_store: &ChannelStore, + exclude_channel_id: &str, + config: &WorkingMemoryConfig, +) -> Result; + +/// Layer 4: Participant context, rendered from cached summaries + working memory events. +async fn render_participant_context( + human_store: &HumanStore, + working_memory_store: &WorkingMemoryStore, + participants: &HashMap, + config: &ParticipantConfig, +) -> Result; + +/// Layer 5: Knowledge synthesis, read from ArcSwap cache. +fn render_knowledge_synthesis( + runtime_config: &RuntimeConfig, +) -> Option; +``` + +Layers 2 and 3 are SQL queries -- they add ~2-5ms per turn. Layer 4 is a cache read + one small SQL query. Layer 5 is an `ArcSwap` load. Total overhead per turn: negligible. + +--- + +## Prompt Template Changes + +### `channel.md.j2` + +Replace: + +```jinja2 +{%- if memory_bulletin %} +## Memory Context + +{{ memory_bulletin }} +{%- endif %} +``` + +With: + +```jinja2 +{%- if working_memory %} +{{ working_memory }} +{%- endif %} + +{%- if channel_activity_map %} +{{ channel_activity_map }} +{%- endif %} + +{%- if participant_context %} +{{ participant_context }} +{%- endif %} + +{%- if knowledge_synthesis %} +## Knowledge Context + +{{ knowledge_synthesis }} +{%- endif %} +``` + +The section headers (`## Working Memory`, `## Other Channels`, `## Participants`) are rendered by the respective functions, not the template. This allows each function to omit the header entirely when there is no content to show. + +### `memory_persistence.md.j2` + +Add instruction to emit working memory events: + +``` +In addition to saving graph memories, identify key decisions and important events +from the conversation. For each, include it in the `events` field of +memory_persistence_complete. Events should be one-line summaries of decisions, +actions, or noteworthy moments. +``` + +### `cortex_bulletin.md.j2` + +Renamed to `cortex_knowledge_synthesis.md.j2`. Narrowed scope: + +``` +Synthesize the agent's long-term knowledge into a concise briefing. +Focus on: +- Active goals and strategic direction +- Cross-cutting themes and patterns +- Known gaps in knowledge ("I don't have current information on X") +- Accumulated observations + +Do NOT include: identity/role information, recent events or activity, +channel-specific context, or user profiles. Those are provided by other +context layers. + +Maximum {{ max_words }} words. +``` + +### `compactor.md.j2` + +Remove `memory_save` from the compactor's tool set. The compactor's only output is a compaction summary. + +--- + +## Files Changed + +| File | Change | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| New migration SQL | `working_memory_events` and `working_memory_daily_summaries` tables | +| `src/memory/working.rs` (new) | `WorkingMemoryStore`, event types, rendering functions | +| `src/memory.rs` | Add `mod working` + re-exports | +| `src/agent/channel.rs` | New `build_system_prompt` assembly, event emission on reply/branch | +| `src/agent/branch.rs` | Emit `BranchCompleted` event on return | +| `src/agent/worker.rs` | Emit `WorkerSpawned` and `WorkerCompleted` events | +| `src/agent/cortex.rs` | Replace bulletin loop with daily summary + knowledge synthesis (dirty flag). Remove warmup bulletin regeneration loop. | +| `src/agent/compactor.rs` | Remove `memory_save` from compactor tool server | +| `src/agent/channel_dispatch.rs` | Smarter persistence branch triggers (message count, time, event density) | +| `src/config/types.rs` | `WorkingMemoryConfig`, deprecate `bulletin_interval_secs` / `bulletin_max_words` | +| `src/config/runtime.rs` | `knowledge_synthesis` replaces `memory_bulletin` in `RuntimeConfig`. Add `knowledge_synthesis_version` atomic counter. | +| `src/conversation/humans.rs` | Augment participant rendering with recent working memory events | +| `src/tools/memory_save.rs` | Emit `MemorySaved` working memory event after successful save | +| `src/tools/spawn_worker.rs` | Emit `WorkerSpawned` working memory event | +| `src/tools/memory_persistence_complete.rs` | Accept optional `events` field, write to working memory | +| `src/cron/scheduler.rs` | Emit `CronExecuted` working memory event after job completes | +| `src/hooks/spacebot.rs` | Emit `Error` working memory event on tool failure | +| `src/main.rs` | Initialize `WorkingMemoryStore`, wire into `AgentDeps` | +| `src/lib.rs` | Re-export working memory types | +| `prompts/en/channel.md.j2` | Replace `memory_bulletin` with layered sections | +| `prompts/en/cortex_knowledge_synthesis.md.j2` (new) | Narrowed synthesis prompt | +| `prompts/en/cortex_daily_summary.md.j2` (new) | Daily summary synthesis prompt | +| `prompts/en/memory_persistence.md.j2` | Add working memory event emission instructions | +| `prompts/en/compactor.md.j2` | Remove memory_save instructions | + +--- + +## Phases + +### Phase 1: Working Memory Store + Event Emission + +**Goal:** Events start flowing into the database. + +- Migration for `working_memory_events` and `working_memory_daily_summaries` +- `WorkingMemoryStore` with `record()`, `get_events_for_day()`, `get_events_for_channel()`, `get_recent_events()`, `has_daily_summary()`, `save_daily_summary()` +- Wire `WorkingMemoryStore` into `AgentDeps` +- Emit events from: worker state machine (spawned, completed), branch return path, cron scheduler, `memory_save` tool, `SpacebotHook` (errors), startup/config change +- Fire-and-forget on all emission paths + +**Verification:** Events accumulate in the database. `SELECT count(*) FROM working_memory_events` grows during normal operation. No performance regression on message processing (all writes are fire-and-forget). + +### Phase 2: Context Injection (Working Memory + Channel Map) + +**Goal:** Channels see working memory and cross-channel activity. + +- `render_working_memory()` function with token budgeting +- `render_channel_activity_map()` function +- Update `build_system_prompt()` to include Layers 2 and 3 +- Update `channel.md.j2` template +- Message filtering for `UserMessage` events (rate limiting in multi-user channels) + +**Verification:** The system prompt now contains `## Working Memory` and `## Other Channels` sections. Events from other channels appear in the channel activity map. Token budgets are respected. + +### Phase 3: Knowledge Synthesis (Replace Bulletin) + +**Goal:** The bulletin is replaced with change-driven knowledge synthesis. + +- `knowledge_synthesis_version` counter in `RuntimeConfig` +- Dirty flag incremented on `memory_save` / `memory_delete` / `memory_update` +- Cortex tick loop checks dirty flag, debounces, regenerates Layer 5 +- `cortex_knowledge_synthesis.md.j2` prompt +- Deprecate `bulletin_interval_secs` and `bulletin_max_words` (keep as aliases) +- Update warmup loop to generate knowledge synthesis instead of bulletin + +**Verification:** Knowledge synthesis regenerates only when memories change. Idle agents produce zero synthesis calls. The system prompt contains `## Knowledge Context` instead of `## Memory Context`. + +### Phase 4: Daily Summaries + Cortex Overhaul + +**Goal:** The cortex synthesizes daily narratives and the working memory log gains progressive compression. + +- `cortex_daily_summary.md.j2` prompt +- `maybe_synthesize_daily_summary()` in cortex tick loop +- Context injection renders yesterday's summary instead of raw events +- Event pruning (>30 days) added to maintenance loop +- Remove bulletin generation loop entirely + +**Verification:** At day rollover, a daily summary is generated. Yesterday's section in the working memory shows the narrative summary, not raw events. Raw events older than 30 days are pruned. + +### Phase 5: Memory Creation Overhaul + +**Goal:** Memory persistence is smarter and compaction no longer extracts memories. + +- Smarter persistence branch triggers (message count reduced to 20, time-based 15min, event-density 5+) +- Persistence branch dual output (graph memories + working memory events) +- Update `memory_persistence.md.j2` and `memory_persistence_complete` tool +- Remove `memory_save` from compactor tool server +- Update `compactor.md.j2` + +**Verification:** Memory persistence fires more frequently and at the right times. The compactor produces summaries only, no memories. The working memory log contains `Decision` events emitted by the persistence branch. + +### Phase 6: Participant Context Integration + +**Goal:** Per-user context with recent activity from working memory. + +- Depends on `participant-awareness.md` Phase 1-3 being implemented +- Augment participant rendering with recent working memory events per user +- Update `build_system_prompt()` to include Layer 4 + +**Verification:** When bergabman sends a message, the channel sees his profile summary plus "Recent: asked about OAuth config 2h ago." The recent activity line comes from the working memory events table. + +--- + +## Migration Path + +Fully backward compatible. The bulletin continues to work until Phase 3 replaces it. Phases 1-2 can ship independently -- working memory and channel activity map appear alongside the existing bulletin. Phase 3 replaces the bulletin. Phase 5 changes memory creation behavior. + +For existing agents with `bulletin_interval_secs` in config, the value is mapped to `knowledge_synthesis_debounce_secs` automatically. The log warning suggests updating to the new config keys. + +The working memory events table starts empty. There is no backfill -- the system captures events going forward. Historical context comes from existing graph memories and conversation history, which remain unchanged. + +--- + +## Token Budget Breakdown (Default Configuration) + +For a typical channel turn: + +| Layer | Budget | Source | +| ---------------------------- | -------------- | ------------------------------------------- | +| 1. Identity Context | ~600-2000 | User-defined (Soul + Identity + Role files) | +| 2. Working Memory | 1500 | Today events + yesterday summary + week | +| 3. Channel Activity Map | 300 | Other channels summary | +| 4. Participant Context | 400 | Active user profiles + recent activity | +| 5. Knowledge Synthesis | 500 | Long-term knowledge | +| **Total context layers** | **~3300-4700** | | +| Channel instructions + rules | ~2000 | Unchanged | +| Status block | ~500-1500 | Unchanged | +| **Total system prompt** | **~5800-8200** | | + +Compare to today: ~6300+ tokens for system prompt, with the bulletin consuming 550-900 tokens of undifferentiated content. The new system uses a similar total budget but the content is structured, relevant, and independently managed. + +--- + +## What This Enables + +**"What happened today?"** The agent knows. The working memory log gives it a beat-by-beat narrative of the day's events without branching or searching. If Jamie asks at 4pm "what have we done today?" the answer is in the context. + +**Cross-channel awareness.** Channel B knows that Channel A had a conversation about OAuth config 10 minutes ago. When bergabman references that conversation in Channel B, the agent can connect the dots without branching. + +**Event-driven, not timer-driven.** The cortex only synthesizes when something changes. Idle agents produce zero LLM calls. Busy agents synthesize efficiently -- one daily summary per day, knowledge synthesis only on memory changes. + +**Proactive memory capture.** Working memory events are recorded automatically as things happen. Decisions, worker completions, and cron executions are captured without relying on LLM judgment or compaction pressure. + +**Progressive temporal compression.** Today is detailed. Yesterday is a summary. Last week is a paragraph. The agent has a sense of time passing and can tell you what last Tuesday looked like. + +**Per-user awareness.** When a returning user sends a message, the agent sees their profile and recent activity across channels. It does not treat them as a stranger. + +**Token efficiency.** Stable identity content is rendered once. Working memory rendering is programmatic (concatenating cached synthesis rows). Channel maps are programmatic SQL queries. Knowledge synthesis only regenerates on change. Intra-day synthesis scales with activity, not time -- a quiet day gets 1-2 LLM calls, a busy day gets 10-15, each one small (50-100 words). Daily summaries are one LLM call per day from already-digested paragraphs. The total LLM cost for context assembly drops from ~96 calls/day (bulletin, regardless of activity) to ~5-20 calls/day (proportional to actual activity). diff --git a/migrations/20260319000001_working_memory.sql b/migrations/20260319000001_working_memory.sql new file mode 100644 index 000000000..2956ce89f --- /dev/null +++ b/migrations/20260319000001_working_memory.sql @@ -0,0 +1,43 @@ +-- Working memory events: the append-only temporal event log. +-- Records what happens around conversations (worker lifecycle, branch +-- conclusions, cron executions, decisions, errors) — NOT user messages +-- or agent responses, which live in conversation_messages. +CREATE TABLE IF NOT EXISTS working_memory_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + channel_id TEXT, + user_id TEXT, + summary TEXT NOT NULL, + detail TEXT, + importance REAL NOT NULL DEFAULT 0.5, + day TEXT NOT NULL +); + +CREATE INDEX idx_wm_events_day ON working_memory_events(day, timestamp); +CREATE INDEX idx_wm_events_channel ON working_memory_events(channel_id, timestamp); +CREATE INDEX idx_wm_events_type ON working_memory_events(event_type, timestamp); +CREATE INDEX idx_wm_events_user ON working_memory_events(user_id, timestamp); + +-- Intra-day synthesis: rolling narrative blocks within a day. +-- Each row is a 50-100 word paragraph covering a batch of events. +CREATE TABLE IF NOT EXISTS working_memory_intraday_syntheses ( + id TEXT PRIMARY KEY, + day TEXT NOT NULL, + time_range_start TIMESTAMP NOT NULL, + time_range_end TIMESTAMP NOT NULL, + summary TEXT NOT NULL, + event_count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_wm_intraday_day ON working_memory_intraday_syntheses(day, time_range_start); + +-- Daily summaries: cortex-synthesized narratives per day. +-- One row per day, never pruned. +CREATE TABLE IF NOT EXISTS working_memory_daily_summaries ( + day TEXT PRIMARY KEY, + summary TEXT NOT NULL, + event_count INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/prompts/en/channel.md.j2 b/prompts/en/channel.md.j2 index 2cf7f990c..4908de4de 100644 --- a/prompts/en/channel.md.j2 +++ b/prompts/en/channel.md.j2 @@ -2,15 +2,9 @@ {{ identity_context }} {%- endif %} -{%- if memory_bulletin %} -## Memory Context - -{{ memory_bulletin }} -{%- endif %} - ## Memory System -Your memory is structured, typed, and evolving. A background process (the cortex) periodically synthesizes your memories into the Memory Context above. It's not static — it refreshes as you learn. +Your memory is structured, typed, and evolving. A background process (the cortex) synthesizes your memories into the Knowledge Context when they change. The Working Memory section shows what's happened recently — events, worker results, decisions. Together they give you both long-term knowledge and short-term situational awareness. Memory types matter because they drive different behaviors: - **fact** — what you know to be true. Grounds your responses. @@ -93,7 +87,7 @@ Common patterns: You have a kanban task board. Tasks are spec-driven documents — each one has a short title, a full markdown description (the spec), pre-filled subtasks (the execution plan), and a status that moves across the board: `pending_approval` → `backlog` → `ready` → `in_progress` → `done`. -Branch to manage tasks. When the user wants to create, list, update, approve, or check tasks — branch. The branch writes rich descriptions and refines them as scope evolves. Active tasks also appear in your Memory Context above. +Branch to manage tasks. When the user wants to create, list, update, approve, or check tasks — branch. The branch writes rich descriptions and refines them as scope evolves. Active tasks also appear in your Knowledge Context. Ready tasks are picked up automatically by the cortex and executed by workers. You don't manage execution — just help the user build good specs and move tasks to `ready` when they're complete. @@ -165,6 +159,26 @@ When in doubt, skip. Being a lurker who speaks when it matters is better than be {{ project_context }} {%- endif %} +{%- if knowledge_synthesis %} +## Knowledge Context + +{{ knowledge_synthesis }} +{%- endif %} + +{%- if memory_bulletin and not working_memory and not knowledge_synthesis %} +## Memory Context + +{{ memory_bulletin }} +{%- endif %} + +{%- if working_memory %} +{{ working_memory }} +{%- endif %} + +{%- if channel_activity_map %} +{{ channel_activity_map }} +{%- endif %} + {%- if conversation_context %} ## Conversation Context diff --git a/prompts/en/compactor.md.j2 b/prompts/en/compactor.md.j2 index 89fe38f25..3814b978b 100644 --- a/prompts/en/compactor.md.j2 +++ b/prompts/en/compactor.md.j2 @@ -1,14 +1,12 @@ -You are a compaction worker. You receive a transcript of older conversation turns that need to be condensed. Your job is to produce a summary and extract any memories worth keeping. +You are a compaction worker. You receive a transcript of older conversation turns that need to be condensed. Your job is to produce a concise summary. ## Your Role -The channel's context is getting full. You've been given the oldest turns that need to make room. Do two things in order: +The channel's context is getting full. You've been given the oldest turns that need to make room. Produce a summary that preserves essential context. The channel will use it as rolling history — it needs to know what happened without carrying the full transcript. -1. **Return a summary** as your first response. This summary preserves essential context from the turns. The channel will use it as rolling history — it needs to know what happened without carrying the full transcript. +Memory extraction is handled separately by periodic persistence branches. You do not need to save memories. -2. **Save extracted memories** using the `memory_save` tool. After producing your summary, call `memory_save` for each distinct memory worth keeping. Do this in a separate turn after the summary. - -## What to Preserve in the Summary +## What to Preserve - Key decisions that were made and why - Active topics that might come up again @@ -23,26 +21,6 @@ The channel's context is getting full. You've been given the oldest turns that n - Intermediate reasoning that led to a conclusion (keep the conclusion) - Repeated information already covered in earlier summaries -## Saving Memories - -After your summary, use `memory_save` for each memory worth extracting. Pick the right type: - -- **fact** — things stated as true ("I work at Acme Corp", "the API uses OAuth2") -- **identity** — core information about who the user or agent is ("the user is a staff engineer", "the agent specializes in Rust") -- **preference** — likes, dislikes, ways of working ("I prefer TypeScript", "don't use emojis") -- **decision** — choices that were made ("we decided to use PostgreSQL", "auth will use JWT") -- **event** — things that happened ("the deploy failed this morning", "the migration was completed") -- **observation** — patterns noticed ("user tends to ask for code examples", "conversations are usually technical") -- **goal** — something the user or agent wants to achieve ("migrate to the new API by Q3"). Goals are aspirational and may span multiple conversations. -- **todo** — a concrete actionable task or reminder ("update the auth tests", "check the deploy status tomorrow"). Todos are specific and completable. - -Don't save: -- Things that are already in memory (duplicates) -- Temporary context that won't matter later ("I'm at a coffee shop right now") -- Things the user explicitly said to forget or ignore - ## Output Format -Your first response should be the summary only — 2-5 paragraphs depending on how much happened. Written in past tense, third person. No markdown headers or formatting wrappers, just the summary text. - -Then use `memory_save` for each extracted memory. +Write 2-5 paragraphs depending on how much happened. Past tense, third person. No markdown headers or formatting wrappers, just the summary text. diff --git a/prompts/en/cortex_daily_summary.md.j2 b/prompts/en/cortex_daily_summary.md.j2 new file mode 100644 index 000000000..6899edf43 --- /dev/null +++ b/prompts/en/cortex_daily_summary.md.j2 @@ -0,0 +1,9 @@ +Synthesize the following activity blocks into a {{ max_words }}-word narrative summary of the day ({{ date }}). + +These blocks are already summarized — combine them into a cohesive story of the day. +Focus on: key decisions, completed work, important conversations, unresolved items. +Write in past tense. Be specific about who did what and in which channel. +If the day was quiet, keep it brief. + +Activity: +{{ intraday_blocks }} diff --git a/prompts/en/cortex_intraday_synthesis.md.j2 b/prompts/en/cortex_intraday_synthesis.md.j2 new file mode 100644 index 000000000..72db29bc8 --- /dev/null +++ b/prompts/en/cortex_intraday_synthesis.md.j2 @@ -0,0 +1,8 @@ +Summarize the following {{ event_count }} events from {{ time_start }} to {{ time_end }} into a concise 50-100 word narrative paragraph. + +Focus on: what was accomplished, what decisions were made, what failed, what is in progress. +Be specific about who did what and in which channel where relevant. Use present/past tense naturally. +Do not list events mechanically — write a narrative. + +Events: +{{ events }} diff --git a/prompts/en/cortex_knowledge_synthesis.md.j2 b/prompts/en/cortex_knowledge_synthesis.md.j2 new file mode 100644 index 000000000..2fbf755b0 --- /dev/null +++ b/prompts/en/cortex_knowledge_synthesis.md.j2 @@ -0,0 +1,25 @@ +You are the cortex's knowledge synthesizer. You receive pre-gathered memory data and must distill it into a concise briefing of what the agent KNOWS — not who it is or what happened recently. + +## Include ONLY + +- Decisions that constrain future choices ("we decided to use X", "the approach is Y") +- Active goals and strategic direction +- User preferences and working patterns +- Cross-cutting themes across conversations +- Known knowledge gaps ("no current information on X") +- Standing instructions or policies + +## NEVER Include + +These are provided by other layers — repeating them wastes tokens: + +- Who the agent is, its role, or the company description (Layer 1: Identity) +- What happened today/yesterday/this week (Layer 2: Working Memory) +- System status, uptime, memory usage, fleet health (Layer 2: Status Block) +- Weather, time, or other ephemeral data +- Product descriptions or GitHub star counts (Layer 1: Identity) +- "The user is the CEO" or similar role statements (Layer 1: Identity) + +## Output Format + +Concise paragraphs. No section headers. No executive summary framing. Just the knowledge, prioritized by what's most actionable. Maximum {{ max_words }} words. If there's nothing substantive beyond what Identity already covers, output a single sentence. diff --git a/prompts/en/memory_persistence.md.j2 b/prompts/en/memory_persistence.md.j2 index 42614e418..054d5fe5d 100644 --- a/prompts/en/memory_persistence.md.j2 +++ b/prompts/en/memory_persistence.md.j2 @@ -27,9 +27,16 @@ This is an automatic process triggered periodically during conversation. You are - Use `related_to` for topical connections - Use `part_of` when a detail belongs to a larger concept already in memory -4. **Finish with the terminal tool.** You must call `memory_persistence_complete` before finishing: +4. **Extract events.** While reviewing the conversation, identify key decisions, important events, and errors. Include them in the `events` field of `memory_persistence_complete`: + - `event_type`: "decision" for commitments or choices made, "error" for failures or problems, "system" for other notable events + - `summary`: one-line description of what happened + - `importance`: 0.0-1.0 score (decisions and errors typically 0.6-0.8) + - Events feed the agent's temporal working memory — they help the agent remember *what happened today*, not just facts. + +5. **Finish with the terminal tool.** You must call `memory_persistence_complete` before finishing: - Use `outcome: "saved"` with `saved_memory_ids` that exactly match IDs returned by successful `memory_save` calls in this run. - Use `outcome: "no_memories"` when nothing is worth saving, with a short `reason` and no saved IDs. + - Always include any extracted `events` regardless of outcome. ## Rules diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 6f992b20c..e1c5a4e5e 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -421,6 +421,8 @@ pub struct Channel { pub compactor: Compactor, /// Count of user messages since last memory persistence branch. message_count: usize, + /// When the last memory persistence branch was triggered. + last_persistence_at: std::time::Instant, /// Branch IDs for silent memory persistence branches (results not injected into history). memory_persistence_branches: HashSet, /// Optional Discord reply target captured when each branch was started. @@ -575,6 +577,7 @@ impl Channel { conversation_context: None, compactor, message_count: 0, + last_persistence_at: std::time::Instant::now(), memory_persistence_branches: HashSet::new(), branch_reply_targets: HashMap::new(), coalesce_buffer: Vec::new(), @@ -1534,6 +1537,47 @@ impl Channel { let project_context = self.build_project_context(&prompt_engine).await; + // Render working memory layers (Layers 2 + 3). + let wm_config = **rc.working_memory.load(); + let timezone = self.deps.working_memory.timezone(); + let working_memory = match crate::memory::working::render_working_memory( + &self.deps.working_memory, + self.id.as_ref(), + &wm_config, + timezone, + ) + .await + { + Ok(text) => { + if text.is_empty() { + tracing::debug!(channel_id = %self.id, "working memory rendered empty (disabled?)"); + } else { + tracing::debug!(channel_id = %self.id, len = text.len(), "working memory rendered"); + } + text + } + Err(error) => { + tracing::warn!(channel_id = %self.id, %error, "working memory render failed"); + String::new() + } + }; + + let channel_activity_map = match crate::memory::working::render_channel_activity_map( + &self.deps.sqlite_pool, + &self.deps.working_memory, + self.id.as_ref(), + &wm_config, + timezone, + ) + .await + { + Ok(text) => text, + Err(error) => { + tracing::warn!(channel_id = %self.id, %error, "channel activity map render failed"); + String::new() + } + }; + prompt_engine.render_channel_prompt_with_links( empty_to_none(identity_context), empty_to_none(memory_bulletin.to_string()), @@ -1548,6 +1592,8 @@ impl Channel { adapter_prompt, project_context, self.backfill_transcript.clone(), + empty_to_none(working_memory), + empty_to_none(channel_activity_map), ) } @@ -2181,6 +2227,47 @@ impl Channel { let project_context = self.build_project_context(&prompt_engine).await; + // Render working memory layers (Layers 2 + 3). + let wm_config = **rc.working_memory.load(); + let timezone = self.deps.working_memory.timezone(); + let working_memory = match crate::memory::working::render_working_memory( + &self.deps.working_memory, + self.id.as_ref(), + &wm_config, + timezone, + ) + .await + { + Ok(text) => { + if text.is_empty() { + tracing::debug!(channel_id = %self.id, "working memory rendered empty (disabled?)"); + } else { + tracing::debug!(channel_id = %self.id, len = text.len(), "working memory rendered"); + } + text + } + Err(error) => { + tracing::warn!(channel_id = %self.id, %error, "working memory render failed"); + String::new() + } + }; + + let channel_activity_map = match crate::memory::working::render_channel_activity_map( + &self.deps.sqlite_pool, + &self.deps.working_memory, + self.id.as_ref(), + &wm_config, + timezone, + ) + .await + { + Ok(text) => text, + Err(error) => { + tracing::warn!(channel_id = %self.id, %error, "channel activity map render failed"); + String::new() + } + }; + let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) }; prompt_engine.render_channel_prompt_with_links( @@ -2197,6 +2284,8 @@ impl Channel { adapter_prompt, project_context, self.backfill_transcript.clone(), + empty_to_none(working_memory), + empty_to_none(channel_activity_map), ) } @@ -2744,6 +2833,22 @@ impl Channel { ); } + // Truncate for working memory — full conclusion lives in branch_runs. + let summary = if conclusion.len() > 200 { + format!("{}...", &conclusion[..200]) + } else { + conclusion.clone() + }; + self.deps + .working_memory + .emit( + crate::memory::WorkingMemoryEventType::BranchCompleted, + format!("Branch concluded: {summary}"), + ) + .channel(self.id.to_string()) + .importance(0.7) + .record(); + tracing::info!(branch_id = %branch_id, "branch result queued for retrigger"); } self.branch_reply_targets.remove(branch_id); @@ -2801,6 +2906,31 @@ impl Channel { self.state.worker_inputs.write().await.remove(worker_id); self.state.worker_injections.write().await.remove(worker_id); + // Record worker completion in working memory. + let worker_summary = if result.len() > 200 { + format!("{}...", &result[..200]) + } else { + result.clone() + }; + let event_type = if *success { + crate::memory::WorkingMemoryEventType::WorkerCompleted + } else { + crate::memory::WorkingMemoryEventType::Error + }; + self.deps + .working_memory + .emit( + event_type, + if *success { + format!("Worker completed: {worker_summary}") + } else { + format!("Worker failed: {worker_summary}") + }, + ) + .channel(self.id.to_string()) + .importance(if *success { 0.6 } else { 0.8 }) + .record(); + if *notify { // Accumulate result for the next retrigger instead of // injecting into history as a fake user message. @@ -3068,19 +3198,63 @@ impl Channel { status.render_full(¤t_time_line, &system_info) } - /// Check if a memory persistence branch should be spawned based on message count. + /// Check if a memory persistence branch should be spawned. + /// + /// Three triggers (any one fires): + /// 1. **Message count** — threshold reached (default 20, configurable) + /// 2. **Time-based** — elapsed since last persistence, if conversation is active + /// 3. **Event density** — working memory events from this channel since last persistence async fn check_memory_persistence(&mut self) { let config = **self.deps.runtime_config.memory_persistence.load(); if !config.enabled || config.message_interval == 0 { return; } - if self.message_count < config.message_interval { + let wm_config = **self.deps.runtime_config.working_memory.load(); + let elapsed = self.last_persistence_at.elapsed(); + + // Trigger 1: Message count threshold. + let message_trigger = self.message_count >= wm_config.persistence_message_threshold; + + // Trigger 2: Time-based — only if conversation is active (message_count > 0). + let time_trigger = self.message_count > 0 + && elapsed.as_secs() >= wm_config.persistence_time_threshold_secs; + + // Trigger 3: Event density — working memory events from this channel. + let density_trigger = if !message_trigger && !time_trigger { + // Only check DB if the cheap triggers didn't fire. + let since = chrono::Utc::now() - chrono::Duration::seconds(elapsed.as_secs() as i64); + match self + .deps + .working_memory + .count_events_since(self.id.as_ref(), since) + .await + { + Ok(count) => count as usize >= wm_config.persistence_event_density_threshold, + Err(error) => { + tracing::debug!(%error, "event density check failed, skipping"); + false + } + } + } else { + false + }; + + if !message_trigger && !time_trigger && !density_trigger { return; } - // Reset counter before spawning so subsequent messages don't pile up + let trigger = if message_trigger { + "message_count" + } else if time_trigger { + "time" + } else { + "event_density" + }; + + // Reset counters before spawning so subsequent messages don't pile up. self.message_count = 0; + self.last_persistence_at = std::time::Instant::now(); match spawn_memory_persistence_branch(&self.state, &self.deps).await { Ok(branch_id) => { @@ -3088,7 +3262,7 @@ impl Channel { tracing::info!( channel_id = %self.id, branch_id = %branch_id, - interval = config.message_interval, + trigger, "memory persistence branch spawned" ); } diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index 7fa51f6b2..58ab7f11c 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -174,7 +174,11 @@ pub(crate) async fn spawn_memory_persistence_branch( "persisting memories...", "memory_persistence_branch", BranchSpawnOptions { - profile: BranchToolProfile::MemoryPersistence { contract_state }, + profile: BranchToolProfile::MemoryPersistence { + contract_state, + working_memory: Some(state.deps.working_memory.clone()), + channel_id: Some(state.channel_id.to_string()), + }, }, ) .await @@ -233,7 +237,7 @@ async fn spawn_branch( ) -> std::result::Result { let BranchSpawnOptions { profile } = branch_options; let memory_persistence_contract = match &profile { - BranchToolProfile::MemoryPersistence { contract_state } => Some(contract_state.clone()), + BranchToolProfile::MemoryPersistence { contract_state, .. } => Some(contract_state.clone()), BranchToolProfile::Default => None, }; @@ -580,6 +584,17 @@ async fn spawn_worker_inner( }) .ok(); + state + .deps + .working_memory + .emit( + crate::memory::WorkingMemoryEventType::WorkerSpawned, + format!("Worker spawned: {task}"), + ) + .channel(state.channel_id.to_string()) + .importance(0.6) + .record(); + tracing::info!(worker_id = %worker_id, task = %task, interactive, "worker spawned"); Ok(worker_id) @@ -772,6 +787,17 @@ async fn spawn_opencode_worker_inner( }) .ok(); + state + .deps + .working_memory + .emit( + crate::memory::WorkingMemoryEventType::WorkerSpawned, + format!("Worker spawned (opencode): {task}"), + ) + .channel(state.channel_id.to_string()) + .importance(0.6) + .record(); + tracing::info!(worker_id = %worker_id, task = %task, interactive, "OpenCode worker spawned"); Ok(worker_id) diff --git a/src/agent/compactor.rs b/src/agent/compactor.rs index 04567da89..ea2856b45 100644 --- a/src/agent/compactor.rs +++ b/src/agent/compactor.rs @@ -11,7 +11,7 @@ use crate::{AgentDeps, ChannelId, ProcessId, ProcessType}; use rig::agent::AgentBuilder; use rig::completion::CompletionModel; use rig::message::{AssistantContent, Message, UserContent}; -use rig::tool::server::ToolServerHandle; +// ToolServerHandle removed — compactor no longer has tools (Phase 5b). use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; @@ -227,16 +227,11 @@ async fn run_compaction( .with_routing((**routing).clone()); // Give the compaction worker memory_save so it can directly persist memories - let tool_server: ToolServerHandle = crate::tools::create_cortex_tool_server( - deps.agent_id.clone(), - deps.memory_event_tx.clone(), - deps.memory_search.clone(), - ); - + // No tool server — the compactor's sole job is producing a summary. + // Memory extraction is handled by persistence branches (Phase 5a). let agent = AgentBuilder::new(model) .preamble(compactor_prompt) - .default_max_turns(10) - .tool_server_handle(tool_server) + .default_max_turns(1) .build(); let hook = SpacebotHook::new( diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index 27009f3d8..4646c0211 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -329,6 +329,7 @@ impl BulletinRefreshOutcome { !matches!(self, Self::Failed) } + #[allow(dead_code)] fn generated(self) -> bool { matches!(self, Self::Generated) } @@ -895,6 +896,12 @@ impl Cortex { /// Process a process event and extract signals. pub async fn observe(&self, event: ProcessEvent) { self.observe_health_event(&event).await; + + // Bump knowledge synthesis version on memory content changes. + if matches!(&event, ProcessEvent::MemorySaved { .. }) { + self.deps.runtime_config.bump_knowledge_synthesis_version(); + } + let Some(signal) = signal_from_event(event) else { return; }; @@ -1607,9 +1614,15 @@ pub async fn run_warmup_once(deps: &AgentDeps, logger: &CortexLogger, reason: &s } } - let bulletin_ok = generate_bulletin(deps, logger).await; - if !bulletin_ok { - errors.push("bulletin generation failed".to_string()); + // Generate knowledge synthesis (narrower scope, replaces bulletin). + // This also syncs memory_bulletin for backward compatibility. + let synthesis_ok = generate_knowledge_synthesis(deps, logger).await; + if !synthesis_ok { + // Fall back to the broader bulletin if knowledge synthesis fails. + let bulletin_ok = generate_bulletin(deps, logger).await; + if !bulletin_ok { + errors.push("knowledge synthesis and bulletin fallback both failed".to_string()); + } } let now_ms = chrono::Utc::now().timestamp_millis(); @@ -1674,6 +1687,9 @@ pub fn trigger_forced_warmup(deps: AgentDeps, dispatch_type: &'static str) { }); } +/// Preserved for fallback — the bulletin loop has been replaced by change-driven +/// knowledge synthesis, but this function is still used at startup. +#[allow(dead_code)] fn spawn_bulletin_refresh_task( deps: AgentDeps, logger: CortexLogger, @@ -1705,13 +1721,14 @@ async fn run_cortex_loop( const RETRY_DELAY_SECS: u64 = 15; const LAG_WARNING_INTERVAL_SECS: u64 = 30; - // Run bulletin generation immediately on startup, with retries. + // Run knowledge synthesis immediately on startup, with retries. + // Falls back to the broader bulletin if synthesis fails. for attempt in 0..=MAX_RETRIES { let bulletin_outcome = maybe_generate_bulletin_under_lock( cortex.deps.runtime_config.warmup_lock.as_ref(), &cortex.deps.runtime_config.warmup, &cortex.deps.runtime_config.warmup_status, - || generate_bulletin(&cortex.deps, logger), + || generate_knowledge_synthesis(&cortex.deps, logger), ) .await; @@ -1722,12 +1739,12 @@ async fn run_cortex_loop( tracing::info!( attempt = attempt + 1, max = MAX_RETRIES, - "retrying bulletin generation in {RETRY_DELAY_SECS}s" + "retrying knowledge synthesis in {RETRY_DELAY_SECS}s" ); logger.log( - "bulletin_failed", + "knowledge_synthesis_startup_retry", &format!( - "Bulletin generation failed, retrying (attempt {}/{})", + "Knowledge synthesis failed, retrying (attempt {}/{})", attempt + 1, MAX_RETRIES ), @@ -1739,7 +1756,7 @@ async fn run_cortex_loop( // Generate an initial profile after startup bulletin synthesis. generate_profile(&cortex.deps, logger).await; - let mut last_bulletin_refresh = Instant::now(); + let mut _last_bulletin_refresh = Instant::now(); let mut tick_interval_secs = cortex .deps .runtime_config @@ -1856,7 +1873,7 @@ async fn run_cortex_loop( Ok(outcome) => { let now = Instant::now(); if outcome.is_success() { - last_bulletin_refresh = now; + _last_bulletin_refresh = now; bulletin_refresh_failures = 0; bulletin_refresh_circuit_open = false; next_bulletin_refresh_allowed_at = now; @@ -1935,6 +1952,10 @@ async fn run_cortex_loop( } maintenance_consecutive_failures = 0; maintenance_disabled_at = None; + // Merges change memory content — bump dirty flag. + if report.merged > 0 { + cortex.deps.runtime_config.bump_knowledge_synthesis_version(); + } logger.log( "maintenance_completed", "Memory maintenance completed", @@ -2047,7 +2068,7 @@ async fn run_cortex_loop( } } - let bulletin_interval = Duration::from_secs(cortex_config.bulletin_interval_secs.max(1)); + let _bulletin_interval = Duration::from_secs(cortex_config.bulletin_interval_secs.max(1)); let now = Instant::now(); if maybe_close_bulletin_refresh_circuit( &mut bulletin_refresh_failures, @@ -2064,15 +2085,25 @@ async fn run_cortex_loop( ) { tracing::info!("cortex maintenance circuit closed; retries re-enabled"); } + // Bulletin timer-based refresh removed — knowledge synthesis + // is now change-driven via dirty flag + debounce. The bulletin + // loop was generating ~96 redundant calls/day at 15-min intervals. + // The old bulletin code is preserved for startup fallback only. + + // Knowledge synthesis: change-driven regeneration with debounce. if refresh_task.is_none() - && !bulletin_refresh_circuit_open - && last_bulletin_refresh.elapsed() >= bulletin_interval - && now >= next_bulletin_refresh_allowed_at + && should_regenerate_knowledge_synthesis(&cortex.deps) { - refresh_task = Some(spawn_bulletin_refresh_task( - cortex.deps.clone(), - logger.clone(), - )); + let deps = cortex.deps.clone(); + let synthesis_logger = logger.clone(); + refresh_task = Some(tokio::spawn(async move { + let success = generate_knowledge_synthesis(&deps, &synthesis_logger).await; + if success { + BulletinRefreshOutcome::Generated + } else { + BulletinRefreshOutcome::Failed + } + })); } if last_maintenance.elapsed() >= Duration::from_secs( @@ -2124,6 +2155,22 @@ async fn run_cortex_loop( last_maintenance = Instant::now(); } + // Working memory: intra-day synthesis (cheap SQL check, LLM only on threshold). + if let Err(error) = maybe_synthesize_intraday_batch(&cortex.deps, logger).await { + tracing::warn!(%error, "intra-day synthesis check failed"); + } + + // Working memory: daily summary for yesterday (idempotent, 1 LLM call/day max). + if let Err(error) = maybe_synthesize_daily_summary(&cortex.deps, logger).await { + tracing::warn!(%error, "daily summary check failed"); + } + + // Working memory: prune old events (cheap SQL, runs every tick but deletes nothing most of the time). + let wm_config = **cortex.deps.runtime_config.working_memory.load(); + if let Err(error) = cortex.deps.working_memory.prune_old_events(wm_config.event_retention_days).await { + tracing::warn!(%error, "working memory event pruning failed"); + } + let updated_tick_interval_secs = cortex_config.tick_interval_secs.max(1); if updated_tick_interval_secs != tick_interval_secs { tick_interval_secs = updated_tick_interval_secs; @@ -2433,6 +2480,530 @@ pub async fn generate_bulletin(deps: &AgentDeps, logger: &CortexLogger) -> bool } } +// -- Knowledge Synthesis -- + +/// Sections for knowledge synthesis — narrower than the bulletin. +/// No identity (Layer 1), no recent events (Layer 2), no per-user context (Layer 4). +const KNOWLEDGE_SYNTHESIS_SECTIONS: &[BulletinSection] = &[ + BulletinSection { + label: "Decisions", + mode: SearchMode::Typed, + memory_type: Some(MemoryType::Decision), + sort_by: SearchSort::Recent, + max_results: 10, + }, + BulletinSection { + label: "High-Importance Context", + mode: SearchMode::Important, + memory_type: None, + sort_by: SearchSort::Importance, + max_results: 10, + }, + BulletinSection { + label: "Preferences & Patterns", + mode: SearchMode::Typed, + memory_type: Some(MemoryType::Preference), + sort_by: SearchSort::Importance, + max_results: 10, + }, + BulletinSection { + label: "Active Goals", + mode: SearchMode::Typed, + memory_type: Some(MemoryType::Goal), + sort_by: SearchSort::Recent, + max_results: 10, + }, + BulletinSection { + label: "Observations", + mode: SearchMode::Typed, + memory_type: Some(MemoryType::Observation), + sort_by: SearchSort::Recent, + max_results: 5, + }, +]; + +/// Generate a change-driven knowledge synthesis (Layer 5) and store it in RuntimeConfig. +/// +/// Uses the same programmatic gather + LLM synthesis pattern as the bulletin, +/// but with narrower scope and the `cortex_knowledge_synthesis` prompt template. +/// Also keeps `memory_bulletin` in sync for backward compatibility. +#[tracing::instrument(skip(deps, logger), fields(agent_id = %deps.agent_id))] +pub async fn generate_knowledge_synthesis(deps: &AgentDeps, logger: &CortexLogger) -> bool { + tracing::info!("cortex generating knowledge synthesis"); + let started = Instant::now(); + + // Gather narrower sections (no identity, no events, no recent). + let raw_sections = gather_sections_from_list(deps, KNOWLEDGE_SYNTHESIS_SECTIONS).await; + let section_count = raw_sections.matches("### ").count(); + + if raw_sections.is_empty() { + tracing::info!("no memories found for knowledge synthesis"); + deps.runtime_config + .knowledge_synthesis + .store(Arc::new(String::new())); + // Keep bulletin in sync during transition. + deps.runtime_config + .memory_bulletin + .store(Arc::new(String::new())); + return true; + } + + // Append active tasks (same as bulletin). + let raw_sections = match gather_active_tasks(deps).await { + Ok(tasks) => format!("{raw_sections}{tasks}"), + Err(error) => { + tracing::warn!(%error, "failed to gather active tasks for knowledge synthesis"); + raw_sections + } + }; + + let cortex_config = **deps.runtime_config.cortex.load(); + let prompt_engine = deps.runtime_config.prompts.load(); + let synthesis_preamble = match prompt_engine.render_static("cortex_knowledge_synthesis") { + Ok(p) => p, + Err(error) => { + tracing::error!(%error, "failed to render cortex_knowledge_synthesis prompt"); + return false; + } + }; + + let routing = deps.runtime_config.routing.load(); + let model_name = routing.resolve(ProcessType::Cortex, None).to_string(); + let model = SpacebotModel::make(&deps.llm_manager, &model_name) + .with_context(&*deps.agent_id, "cortex") + .with_routing((**routing).clone()); + + let agent = AgentBuilder::new(model) + .preamble(&synthesis_preamble) + .hook(CortexHook::new()) + .build(); + + let max_words = cortex_config.knowledge_synthesis_max_words; + let user_prompt = match prompt_engine.render_system_cortex_synthesis(max_words, &raw_sections) { + Ok(p) => p, + Err(error) => { + tracing::error!(%error, "failed to render cortex synthesis user prompt"); + return false; + } + }; + + match agent.prompt(&user_prompt).await { + Ok(synthesis) => { + let word_count = synthesis.split_whitespace().count(); + let duration_ms = started.elapsed().as_millis() as u64; + tracing::info!( + words = word_count, + sections = section_count, + duration_ms, + "knowledge synthesis generated" + ); + deps.runtime_config + .knowledge_synthesis + .store(Arc::new(synthesis.clone())); + // Keep bulletin in sync during transition so unconverted consumers work. + deps.runtime_config + .memory_bulletin + .store(Arc::new(synthesis)); + // Mark this version as synthesized. + let current = deps + .runtime_config + .knowledge_synthesis_version + .load(std::sync::atomic::Ordering::Relaxed); + deps.runtime_config + .knowledge_synthesis_last_version + .store(current, std::sync::atomic::Ordering::Relaxed); + // Update warmup status. + let refresh_ms = chrono::Utc::now().timestamp_millis(); + update_warmup_status(deps, |status| { + status.last_refresh_unix_ms = Some(refresh_ms); + status.bulletin_age_secs = Some(0); + if status.state != crate::config::WarmupState::Warming { + status.state = crate::config::WarmupState::Warm; + status.last_error = None; + } + }); + logger.log( + "knowledge_synthesis_generated", + &format!("Knowledge synthesis: {word_count} words, {section_count} sections, {duration_ms}ms"), + Some(serde_json::json!({ + "word_count": word_count, + "sections": section_count, + "duration_ms": duration_ms, + "model": model_name, + })), + ); + true + } + Err(error) => { + let duration_ms = started.elapsed().as_millis() as u64; + tracing::error!(%error, duration_ms, "knowledge synthesis failed"); + update_warmup_status(deps, |status| { + status.last_error = Some(format!("knowledge synthesis failed: {error}")); + }); + logger.log( + "knowledge_synthesis_failed", + &format!("Knowledge synthesis failed after {duration_ms}ms: {error}"), + Some(serde_json::json!({ + "duration_ms": duration_ms, + "error": error.to_string(), + "model": model_name, + })), + ); + false + } + } +} + +/// Gather raw memory sections from a specific section list. +/// +/// Uses the same pattern as `gather_bulletin_sections` (empty-query metadata +/// search) but accepts an arbitrary section list for narrower scoping. +async fn gather_sections_from_list(deps: &AgentDeps, sections: &[BulletinSection]) -> String { + let mut output = String::new(); + + for section in sections { + let config = SearchConfig { + mode: section.mode, + memory_type: section.memory_type, + max_results: section.max_results, + sort_by: section.sort_by, + ..Default::default() + }; + + let results = match deps.memory_search.search("", &config).await { + Ok(results) => results, + Err(error) => { + tracing::warn!( + section = section.label, + %error, + "knowledge synthesis section query failed" + ); + continue; + } + }; + + if results.is_empty() { + continue; + } + + output.push_str(&format!("### {}\n\n", section.label)); + for result in &results { + output.push_str(&format!( + "- [{}] (importance: {:.1}) {}\n", + result.memory.memory_type, + result.memory.importance, + result + .memory + .content + .lines() + .next() + .unwrap_or(&result.memory.content), + )); + } + output.push('\n'); + } + + output +} + +/// Check if knowledge synthesis needs regeneration based on dirty flag and debounce. +pub fn should_regenerate_knowledge_synthesis(deps: &AgentDeps) -> bool { + let current_version = deps + .runtime_config + .knowledge_synthesis_version + .load(std::sync::atomic::Ordering::Relaxed); + let last_version = deps + .runtime_config + .knowledge_synthesis_last_version + .load(std::sync::atomic::Ordering::Relaxed); + + if current_version == last_version { + return false; + } + + // Debounce: wait for activity to settle. + let cortex_config = **deps.runtime_config.cortex.load(); + let last_change = deps + .runtime_config + .knowledge_synthesis_last_change + .load(std::sync::atomic::Ordering::Relaxed); + let now = chrono::Utc::now().timestamp(); + let elapsed = now.saturating_sub(last_change) as u64; + + elapsed >= cortex_config.knowledge_synthesis_debounce_secs +} + +// -- Intra-Day Synthesis + Daily Summaries -- + +/// Check and potentially synthesize a batch of recent working memory events. +/// +/// Called on every cortex tick. The check is one cheap SQL query. LLM synthesis +/// only happens when the event count threshold or time fallback is reached. +pub async fn maybe_synthesize_intraday_batch( + deps: &AgentDeps, + logger: &CortexLogger, +) -> anyhow::Result { + let wm = &deps.working_memory; + let wm_config = **deps.runtime_config.working_memory.load(); + let today = wm.today(); + + let last_end = wm.get_last_intraday_synthesis_end(&today).await?; + let unsynthesized = wm.get_events_after(&today, last_end).await?; + + if unsynthesized.is_empty() { + return Ok(false); + } + + // Dual trigger: count-based OR time-based fallback. + let count_trigger = unsynthesized.len() >= wm_config.intraday_batch_threshold; + let time_trigger = if let Some(last) = last_end { + let elapsed = (chrono::Utc::now() - last).num_seconds() as u64; + elapsed >= wm_config.intraday_time_fallback_secs + } else { + // No previous synthesis — use time since first event. + let first_event = &unsynthesized[0]; + let elapsed = (chrono::Utc::now() - first_event.timestamp).num_seconds() as u64; + elapsed >= wm_config.intraday_time_fallback_secs + }; + + if !count_trigger && !time_trigger { + return Ok(false); + } + + // Build the event text for the LLM. + let time_start = unsynthesized + .first() + .map(|e| e.timestamp) + .unwrap_or_else(chrono::Utc::now); + let time_end = unsynthesized + .last() + .map(|e| e.timestamp) + .unwrap_or_else(chrono::Utc::now); + let timezone = wm.timezone(); + let time_start_str = time_start + .with_timezone(&timezone) + .format("%H:%M") + .to_string(); + let time_end_str = time_end + .with_timezone(&timezone) + .format("%H:%M") + .to_string(); + + let mut events_text = String::new(); + for event in &unsynthesized { + let ts = event + .timestamp + .with_timezone(&timezone) + .format("%H:%M") + .to_string(); + let channel_label = event + .channel_id + .as_deref() + .map(|c| format!(" [{c}]")) + .unwrap_or_default(); + events_text.push_str(&format!( + "[{ts}]{channel_label} {}: {}\n", + event.event_type, event.summary + )); + } + + // Render the synthesis prompt. + let prompt_engine = deps.runtime_config.prompts.load(); + let prompt = prompt_engine.render_intraday_synthesis( + unsynthesized.len(), + &time_start_str, + &time_end_str, + &events_text, + )?; + + // Use a short one-shot LLM call — no tools, no hooks. + let routing = deps.runtime_config.routing.load(); + let model_name = routing.resolve(ProcessType::Cortex, None).to_string(); + let model = SpacebotModel::make(&deps.llm_manager, &model_name) + .with_context(&*deps.agent_id, "cortex") + .with_routing((**routing).clone()); + + let agent = AgentBuilder::new(model) + .preamble("You are a concise narrative summarizer. Output only the summary paragraph, nothing else.") + .hook(CortexHook::new()) + .build(); + + let synthesis = agent.prompt(&prompt).await?; + + // Store the synthesis. + wm.save_intraday_synthesis( + &today, + time_start, + time_end, + &synthesis, + unsynthesized.len(), + ) + .await?; + + tracing::info!( + event_count = unsynthesized.len(), + time_range = format!("{time_start_str}-{time_end_str}"), + words = synthesis.split_whitespace().count(), + trigger = if count_trigger { + "count" + } else { + "time_fallback" + }, + "intra-day synthesis completed" + ); + + logger.log( + "intraday_synthesis", + &format!( + "Synthesized {} events ({time_start_str}-{time_end_str})", + unsynthesized.len() + ), + Some(serde_json::json!({ + "event_count": unsynthesized.len(), + "trigger": if count_trigger { "count" } else { "time_fallback" }, + "words": synthesis.split_whitespace().count(), + })), + ); + + Ok(true) +} + +/// Check and potentially synthesize yesterday's daily summary. +/// +/// Called on every cortex tick. Idempotent — once a daily summary exists for +/// a given day, it is never regenerated. Uses intra-day synthesis paragraphs +/// (not raw events) as input, so the LLM call is small and cheap. +pub async fn maybe_synthesize_daily_summary( + deps: &AgentDeps, + logger: &CortexLogger, +) -> anyhow::Result { + let wm = &deps.working_memory; + let yesterday = wm.yesterday(); + + // Idempotent check. + if wm.has_daily_summary(&yesterday).await? { + return Ok(false); + } + + let intraday = wm.get_intraday_syntheses(&yesterday).await?; + let raw_events = wm.get_events_for_day(&yesterday).await?; + + // No activity at all — save a minimal summary. + if intraday.is_empty() && raw_events.is_empty() { + wm.save_daily_summary(&yesterday, "No activity.", 0).await?; + return Ok(true); + } + + // Build input from intra-day synthesis paragraphs + any unsynthesized tail. + let timezone = wm.timezone(); + let mut blocks_text = String::new(); + let mut total_events = 0i64; + + // Last timestamp covered by intra-day syntheses (if any). + let mut last_synthesis_end = None; + + for synthesis in &intraday { + let time_label = synthesis + .time_range_start + .with_timezone(&timezone) + .format("%H:%M") + .to_string(); + blocks_text.push_str(&format!("[{time_label}] {}\n\n", synthesis.summary)); + total_events += synthesis.event_count; + let end = synthesis.time_range_end; + last_synthesis_end = Some( + last_synthesis_end.map_or(end, |prev: chrono::DateTime| prev.max(end)), + ); + } + + // Collect raw events not covered by any intra-day synthesis (the "tail"). + // This happens when events didn't hit the count/time trigger before midnight. + let tail_events: Vec<_> = raw_events + .iter() + .filter(|event| match last_synthesis_end { + Some(end) => event.timestamp > end, + None => true, // No syntheses at all — all events are unsynthesized. + }) + .collect(); + + if !tail_events.is_empty() { + if !blocks_text.is_empty() { + blocks_text.push_str("Unsynthesized events from the rest of the day:\n"); + } + for event in &tail_events { + let ts = event + .timestamp + .with_timezone(&timezone) + .format("%H:%M") + .to_string(); + let channel_label = event + .channel_id + .as_deref() + .map(|c| format!(" [{c}]")) + .unwrap_or_default(); + blocks_text.push_str(&format!( + "[{ts}]{channel_label} {}: {}\n", + event.event_type, event.summary + )); + } + blocks_text.push('\n'); + total_events += tail_events.len() as i64; + } + + let wm_config = **deps.runtime_config.working_memory.load(); + let prompt_engine = deps.runtime_config.prompts.load(); + let prompt = prompt_engine.render_daily_summary( + &yesterday, + wm_config.daily_summary_max_words, + &blocks_text, + )?; + + // One-shot LLM call. + let routing = deps.runtime_config.routing.load(); + let model_name = routing.resolve(ProcessType::Cortex, None).to_string(); + let model = SpacebotModel::make(&deps.llm_manager, &model_name) + .with_context(&*deps.agent_id, "cortex") + .with_routing((**routing).clone()); + + let agent = AgentBuilder::new(model) + .preamble("You are a daily activity summarizer. Output only the summary, nothing else.") + .hook(CortexHook::new()) + .build(); + + let summary = agent.prompt(&prompt).await?; + + wm.save_daily_summary(&yesterday, &summary, total_events) + .await?; + + let tail_count = tail_events.len(); + + tracing::info!( + day = yesterday, + intraday_blocks = intraday.len(), + tail_events = tail_count, + total_events, + words = summary.split_whitespace().count(), + "daily summary generated" + ); + + logger.log( + "daily_summary", + &format!( + "Daily summary for {yesterday}: {total_events} events, {} blocks, {tail_count} tail", + intraday.len() + ), + Some(serde_json::json!({ + "day": yesterday, + "intraday_blocks": intraday.len(), + "tail_events": tail_count, + "total_events": total_events, + "words": summary.split_whitespace().count(), + })), + ); + + Ok(true) +} + // -- Agent Profile -- /// Persisted agent profile generated by the cortex. diff --git a/src/agent/ingestion.rs b/src/agent/ingestion.rs index 24d845ca5..7ed5f0c9e 100644 --- a/src/agent/ingestion.rs +++ b/src/agent/ingestion.rs @@ -499,6 +499,8 @@ async fn process_chunk( crate::conversation::ProcessRunLogger::new(deps.sqlite_pool.clone()), crate::tools::BranchToolProfile::MemoryPersistence { contract_state: contract_state.clone(), + working_memory: Some(deps.working_memory.clone()), + channel_id: None, }, ); diff --git a/src/api/agents.rs b/src/api/agents.rs index ce75842c1..86315b79b 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -417,6 +417,15 @@ pub(super) async fn trigger_warmup( let (event_tx, memory_event_tx) = crate::create_process_event_buses(); let project_store = std::sync::Arc::new(crate::projects::ProjectStore::new(sqlite_pool.clone())); + let working_memory_tz = runtime_config + .user_timezone + .load() + .as_deref() + .or(runtime_config.cron_timezone.load().as_deref()) + .and_then(|tz| tz.parse::().ok()) + .unwrap_or(chrono_tz::Tz::UTC); + let working_memory = + crate::memory::WorkingMemoryStore::new(sqlite_pool.clone(), working_memory_tz); let deps = crate::AgentDeps { agent_id: Arc::from(agent_id.as_str()), memory_search, @@ -439,6 +448,7 @@ pub(super) async fn trigger_warmup( crate::agent::process_control::ProcessControlRegistry::new(), ), injection_tx, + working_memory, }; let logger = CortexLogger::new(sqlite_pool); crate::agent::cortex::run_warmup_once(&deps, &logger, "api_trigger", force).await; @@ -812,6 +822,15 @@ pub async fn create_agent_internal( humans: Arc::new(arc_swap::ArcSwap::from_pointee( (**state.agent_humans.load()).clone(), )), + working_memory: { + let tz = agent_config + .user_timezone + .as_deref() + .or(agent_config.cron_timezone.as_deref()) + .and_then(|tz| tz.parse::().ok()) + .unwrap_or(chrono_tz::Tz::UTC); + crate::memory::WorkingMemoryStore::new(db.sqlite.clone(), tz) + }, }; let event_rx = event_tx.subscribe(); diff --git a/src/api/channels.rs b/src/api/channels.rs index e18937b75..0469a546c 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -464,11 +464,192 @@ pub(super) async fn inspect_prompt( let sandbox_enabled = channel_state.deps.sandbox.containment_active(); + // ── Render working memory layers (Layers 2 + 3) ── + let wm_config = **rc.working_memory.load(); + let wm_timezone = channel_state.deps.working_memory.timezone(); + let working_memory = crate::memory::working::render_working_memory( + &channel_state.deps.working_memory, + &query.channel_id, + &wm_config, + wm_timezone, + ) + .await + .unwrap_or_default(); + + let channel_activity_map = crate::memory::working::render_channel_activity_map( + &channel_state.deps.sqlite_pool, + &channel_state.deps.working_memory, + &query.channel_id, + &wm_config, + wm_timezone, + ) + .await + .unwrap_or_default(); + + // ── Available channels ── + let available_channels = { + let channels = channel_state + .channel_store + .list_active() + .await + .unwrap_or_default(); + let entries: Vec = channels + .into_iter() + .filter(|channel| { + channel.id.as_str() != query.channel_id.as_str() + && channel.platform != "cron" + && channel.platform != "webhook" + }) + .map(|channel| crate::prompts::engine::ChannelEntry { + name: channel.display_name.unwrap_or_else(|| channel.id.clone()), + platform: channel.platform, + id: channel.id, + }) + .collect(); + if entries.is_empty() { + None + } else { + prompt_engine.render_available_channels(entries).ok() + } + }; + + // ── Org context ── + let org_context = { + let agent_id = channel_state.deps.agent_id.as_ref(); + let all_links = channel_state.deps.links.load(); + let links = crate::links::links_for_agent(&all_links, agent_id); + if links.is_empty() { + None + } else { + let all_humans = channel_state.deps.humans.load(); + let humans_by_id: std::collections::HashMap<&str, &crate::config::HumanDef> = + all_humans.iter().map(|h| (h.id.as_str(), h)).collect(); + + let mut superiors = Vec::new(); + let mut subordinates = Vec::new(); + let mut peers = Vec::new(); + + for link in &links { + let is_from = link.from_agent_id == agent_id; + let other_id = if is_from { + &link.to_agent_id + } else { + &link.from_agent_id + }; + let is_human = humans_by_id.contains_key(other_id.as_str()); + let (name, role, description) = + if let Some(human) = humans_by_id.get(other_id.as_str()) { + let name = human + .display_name + .clone() + .unwrap_or_else(|| other_id.clone()); + (name, human.role.clone(), human.description.clone()) + } else { + let name = channel_state + .deps + .agent_names + .get(other_id.as_str()) + .cloned() + .unwrap_or_else(|| other_id.clone()); + (name, None, None) + }; + let info = crate::prompts::engine::LinkedAgent { + name, + id: other_id.clone(), + is_human, + role, + description, + }; + match link.kind { + crate::links::LinkKind::Hierarchical => { + if is_from { + subordinates.push(info); + } else { + superiors.push(info); + } + } + crate::links::LinkKind::Peer => peers.push(info), + } + } + + if superiors.is_empty() && subordinates.is_empty() && peers.is_empty() { + None + } else { + prompt_engine + .render_org_context(crate::prompts::engine::OrgContext { + superiors, + subordinates, + peers, + }) + .ok() + } + } + }; + + // ── Adapter prompt ── + let adapter = query.channel_id.split(':').next().filter(|a| !a.is_empty()); + let adapter_prompt = + adapter.and_then(|adapter| prompt_engine.render_channel_adapter_prompt(adapter)); + + // ── Project context ── + let project_context = { + use crate::prompts::engine::{ProjectContext, ProjectRepoContext, ProjectWorktreeContext}; + let store = &channel_state.deps.project_store; + let projects = store + .list_projects( + &channel_state.deps.agent_id, + Some(crate::projects::ProjectStatus::Active), + ) + .await + .unwrap_or_default(); + if projects.is_empty() { + None + } else { + let mut contexts = Vec::with_capacity(projects.len()); + for project in &projects { + let repos = store.list_repos(&project.id).await.unwrap_or_default(); + let worktrees = store + .list_worktrees_with_repos(&project.id) + .await + .unwrap_or_default(); + contexts.push(ProjectContext { + name: project.name.clone(), + root_path: project.root_path.clone(), + description: if project.description.is_empty() { + None + } else { + Some(project.description.clone()) + }, + tags: project.tags.clone(), + repos: repos + .into_iter() + .map(|repo| ProjectRepoContext { + name: repo.name.clone(), + path: repo.path.clone(), + default_branch: repo.default_branch.clone(), + remote_url: if repo.remote_url.is_empty() { + None + } else { + Some(repo.remote_url.clone()) + }, + }) + .collect(), + worktrees: worktrees + .into_iter() + .map(|worktree_with_repo| ProjectWorktreeContext { + name: worktree_with_repo.worktree.name.clone(), + path: worktree_with_repo.worktree.path.clone(), + branch: worktree_with_repo.worktree.branch.clone(), + repo_name: worktree_with_repo.repo_name.clone(), + }) + .collect(), + }); + } + prompt_engine.render_projects_context(contexts).ok() + } + }; + // ── Render the full system prompt ── - // This is a best-effort reconstruction from the API layer. It lacks - // available_channels, org_context, adapter_prompt, and project_context - // (those require Channel methods not available from ChannelState). - // Captured snapshots store the exact prompt the model received. let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) }; let system_prompt = prompt_engine .render_channel_prompt_with_links( @@ -478,13 +659,15 @@ pub(super) async fn inspect_prompt( worker_capabilities, conversation_context, empty_to_none(status_text), - None, // coalesce_hint - None, // available_channels — not available from API layer + None, // coalesce_hint — only set during batched message handling + available_channels, sandbox_enabled, - None, // org_context — not available from API layer - None, // adapter_prompt — not available from API layer - None, // project_context — not available from API layer - None, // backfill_transcript — not available from API layer + org_context, + adapter_prompt, + project_context, + None, // backfill_transcript — only set during channel initialization + empty_to_none(working_memory), + empty_to_none(channel_activity_map), ) .unwrap_or_default(); diff --git a/src/config.rs b/src/config.rs index 60b9eb74b..3386b6015 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1212,7 +1212,8 @@ maintenance_merge_similarity_threshold = 1.1 } #[test] - fn test_work_readiness_rejects_stale_bulletin() { + fn test_work_readiness_allows_old_synthesis() { + // Knowledge synthesis is change-driven — staleness no longer blocks readiness. let readiness = evaluate_work_readiness( WarmupConfig { refresh_secs: 60, @@ -1228,10 +1229,9 @@ maintenance_merge_similarity_threshold = 1.1 122_000, ); - assert_eq!(readiness.stale_after_secs, 120); assert_eq!(readiness.bulletin_age_secs, Some(121)); - assert!(!readiness.ready); - assert_eq!(readiness.reason, Some(WorkReadinessReason::BulletinStale)); + assert!(readiness.ready, "old synthesis should not block readiness"); + assert_eq!(readiness.reason, None); } #[test] diff --git a/src/config/load.rs b/src/config/load.rs index a4b44882c..a4b76355a 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -206,6 +206,12 @@ impl CortexConfig { association_max_per_pass: overrides .association_max_per_pass .unwrap_or(defaults.association_max_per_pass), + knowledge_synthesis_max_words: overrides + .knowledge_synthesis_max_words + .unwrap_or(defaults.knowledge_synthesis_max_words), + knowledge_synthesis_debounce_secs: overrides + .knowledge_synthesis_debounce_secs + .unwrap_or(defaults.knowledge_synthesis_debounce_secs), }; config.validate_maintenance_bounds()?; Ok(config) diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 58c36ce47..6d3a892ca 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -51,6 +51,17 @@ pub struct RuntimeConfig { /// Cached memory bulletin generated by the cortex. Injected into every /// channel's system prompt. Empty string until the first cortex run. pub memory_bulletin: ArcSwap, + /// Change-driven knowledge synthesis (Layer 5). Replaces the bulletin's + /// role as the knowledge injection mechanism. Narrower scope than the + /// bulletin — no identity, events, or user profiles. + pub knowledge_synthesis: ArcSwap, + /// Monotonically increasing counter, bumped on memory content changes + /// (create, update, delete, merge). NOT bumped on importance-only changes. + pub knowledge_synthesis_version: Arc, + /// The version at which knowledge synthesis was last regenerated. + pub knowledge_synthesis_last_version: Arc, + /// Timestamp of the last knowledge_synthesis_version bump, for debouncing. + pub knowledge_synthesis_last_change: Arc, pub prompts: ArcSwap, pub identity: ArcSwap, pub skills: ArcSwap, @@ -77,6 +88,8 @@ pub struct RuntimeConfig { pub sandbox: Arc>, /// Projects workspace management configuration. pub projects: ArcSwap, + /// Working memory configuration for temporal context injection. + pub working_memory: ArcSwap, /// Shared browser state for persistent sessions. /// /// When `browser.persist_session = true`, all workers share this handle so @@ -128,6 +141,10 @@ impl RuntimeConfig { warmup_status: ArcSwap::from_pointee(WarmupStatus::default()), warmup_lock: Arc::new(tokio::sync::Mutex::new(())), memory_bulletin: ArcSwap::from_pointee(String::new()), + knowledge_synthesis: ArcSwap::from_pointee(String::new()), + knowledge_synthesis_version: Arc::new(std::sync::atomic::AtomicU64::new(0)), + knowledge_synthesis_last_version: Arc::new(std::sync::atomic::AtomicU64::new(0)), + knowledge_synthesis_last_change: Arc::new(std::sync::atomic::AtomicI64::new(0)), prompts: ArcSwap::from_pointee(prompts), identity: ArcSwap::from_pointee(identity), skills: ArcSwap::from_pointee(skills), @@ -141,6 +158,9 @@ impl RuntimeConfig { secrets: ArcSwap::from_pointee(None), sandbox: Arc::new(ArcSwap::from_pointee(agent_config.sandbox.clone())), projects: ArcSwap::from_pointee(agent_config.projects.clone()), + working_memory: ArcSwap::from_pointee( + crate::config::types::WorkingMemoryConfig::default(), + ), shared_browser: if agent_config.browser.persist_session { Some(crate::tools::browser::new_shared_browser_handle()) } else { @@ -190,6 +210,18 @@ impl RuntimeConfig { self.secrets.store(Arc::new(Some(secrets))); } + /// Signal that memory content changed (create, update, delete, merge). + /// This bumps the dirty counter so the cortex regenerates knowledge synthesis. + /// Do NOT call for importance-only changes (decay, access count). + pub fn bump_knowledge_synthesis_version(&self) { + self.knowledge_synthesis_version + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.knowledge_synthesis_last_change.store( + chrono::Utc::now().timestamp(), + std::sync::atomic::Ordering::Relaxed, + ); + } + /// Compute the current dispatch-readiness signal. pub fn work_readiness(&self) -> WorkReadiness { let warmup_config = **self.warmup.load(); diff --git a/src/config/toml_schema.rs b/src/config/toml_schema.rs index a3a833484..5ad8cf8f2 100644 --- a/src/config/toml_schema.rs +++ b/src/config/toml_schema.rs @@ -364,6 +364,8 @@ pub(super) struct TomlCortexConfig { pub(super) association_similarity_threshold: Option, pub(super) association_updates_threshold: Option, pub(super) association_max_per_pass: Option, + pub(super) knowledge_synthesis_max_words: Option, + pub(super) knowledge_synthesis_debounce_secs: Option, } #[derive(Deserialize)] diff --git a/src/config/types.rs b/src/config/types.rs index fe9d31463..4de3f5128 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -660,6 +660,63 @@ impl Default for MemoryPersistenceConfig { } } +/// Working memory system configuration. +/// +/// Controls the temporal event log, intra-day synthesis, channel activity map, +/// and persistence trigger thresholds. +#[derive(Debug, Clone, Copy)] +pub struct WorkingMemoryConfig { + /// Whether working memory context injection is enabled. + pub enabled: bool, + /// Events before an intra-day synthesis batch is triggered. + pub intraday_batch_threshold: usize, + /// Seconds before time-based fallback triggers intra-day synthesis. + pub intraday_time_fallback_secs: u64, + /// Maximum unsynthesized recent events to show in the raw tail. + pub today_max_unsynthesized_events: usize, + /// Token budget for the entire working memory section. + pub context_token_budget: usize, + /// Token budget for the channel activity map. + pub channel_map_token_budget: usize, + /// Maximum channels to show in the activity map. + pub channel_map_max_channels: usize, + /// Hide inactive channels after this many hours. + pub channel_map_inactive_hours: u64, + /// Minimum importance for events to be included under token pressure. + pub min_importance_under_pressure: f32, + /// Days to retain raw events before pruning. + pub event_retention_days: i64, + /// Daily summary max words. + pub daily_summary_max_words: usize, + /// Persistence branch trigger: message count threshold. + pub persistence_message_threshold: usize, + /// Persistence branch trigger: time threshold in seconds. + pub persistence_time_threshold_secs: u64, + /// Persistence branch trigger: event density threshold. + pub persistence_event_density_threshold: usize, +} + +impl Default for WorkingMemoryConfig { + fn default() -> Self { + Self { + enabled: true, + intraday_batch_threshold: 15, + intraday_time_fallback_secs: 14400, + today_max_unsynthesized_events: 10, + context_token_budget: 1500, + channel_map_token_budget: 300, + channel_map_max_channels: 10, + channel_map_inactive_hours: 24, + min_importance_under_pressure: 0.5, + event_retention_days: 30, + daily_summary_max_words: 300, + persistence_message_threshold: 20, + persistence_time_threshold_secs: 900, + persistence_event_density_threshold: 5, + } + } +} + impl Default for CompactionConfig { fn default() -> Self { Self { @@ -870,6 +927,10 @@ pub struct CortexConfig { pub association_updates_threshold: f32, /// Max associations to create per pass (rate limit). pub association_max_per_pass: usize, + /// Knowledge synthesis max words (replaces bulletin_max_words for Layer 5). + pub knowledge_synthesis_max_words: usize, + /// Debounce seconds after last memory change before regenerating knowledge synthesis. + pub knowledge_synthesis_debounce_secs: u64, } impl Default for CortexConfig { @@ -893,6 +954,8 @@ impl Default for CortexConfig { association_similarity_threshold: 0.85, association_updates_threshold: 0.95, association_max_per_pass: 100, + knowledge_synthesis_max_words: 500, + knowledge_synthesis_debounce_secs: 60, } } } @@ -1075,14 +1138,14 @@ pub(super) fn evaluate_work_readiness( }) .or(status.bulletin_age_secs); + // Knowledge synthesis is change-driven, not timer-driven. Staleness + // is no longer a readiness concern — only "never generated" matters. let reason = if status.state != WarmupState::Warm { Some(WorkReadinessReason::StateNotWarm) } else if warmup_config.eager_embedding_load && !status.embedding_ready { Some(WorkReadinessReason::EmbeddingNotReady) } else if bulletin_age_secs.is_none() { Some(WorkReadinessReason::BulletinMissing) - } else if bulletin_age_secs.is_some_and(|age| age > stale_after_secs) { - Some(WorkReadinessReason::BulletinStale) } else { None }; diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index adf59101b..1c4f8b4df 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -349,6 +349,16 @@ impl Scheduler { ]) .inc(); + exec_context + .deps + .working_memory + .emit( + crate::memory::WorkingMemoryEventType::CronExecuted, + format!("Cron completed: {exec_job_id}"), + ) + .importance(0.4) + .record(); + let mut j = exec_jobs.write().await; if let Some(j) = j.get_mut(&exec_job_id) { j.consecutive_failures = 0; @@ -365,6 +375,16 @@ impl Scheduler { ]) .inc(); + exec_context + .deps + .working_memory + .emit( + crate::memory::WorkingMemoryEventType::Error, + format!("Cron failed: {exec_job_id}: {error}"), + ) + .importance(0.8) + .record(); + tracing::error!( cron_id = %exec_job_id, %error, diff --git a/src/lib.rs b/src/lib.rs index 4ed49856d..a5f0550e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -409,6 +409,8 @@ pub struct AgentDeps { /// Sender for injecting messages into channels from outside the normal /// inbound message flow (e.g. cross-agent task completion notifications). pub injection_tx: tokio::sync::mpsc::Sender, + /// Working memory event log for temporal situational awareness. + pub working_memory: Arc, } impl AgentDeps { diff --git a/src/main.rs b/src/main.rs index 0679c27f3..ded7412b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2545,6 +2545,18 @@ async fn initialize_agents( embedding_model.clone(), )); + // Working memory event log (temporal situational awareness). + let working_memory_timezone = { + let user_tz = agent_config.user_timezone.as_deref(); + let cron_tz = agent_config.cron_timezone.as_deref(); + user_tz + .or(cron_tz) + .and_then(|tz_name| tz_name.parse::().ok()) + .unwrap_or(chrono_tz::Tz::UTC) + }; + let working_memory = + spacebot::memory::WorkingMemoryStore::new(db.sqlite.clone(), working_memory_timezone); + // Per-agent control and memory event buses (broadcast fan-out). let (event_tx, memory_event_tx) = spacebot::create_process_event_buses(); @@ -2650,6 +2662,7 @@ async fn initialize_agents( spacebot::agent::process_control::ProcessControlRegistry::new(), ), injection_tx: injection_tx.clone(), + working_memory, }; let agent = spacebot::Agent { @@ -2699,6 +2712,19 @@ async fn initialize_agents( tracing::info!(agent_count = agents.len(), "all agents initialized"); + // Record startup in each agent's working memory. + for agent in agents.values() { + agent + .deps + .working_memory + .emit( + spacebot::memory::WorkingMemoryEventType::System, + format!("Agent started ({})", agent.config.id), + ) + .importance(0.3) + .record(); + } + // Wire agent event streams, DB pools, and config summaries into the API server { let mut agent_pools = std::collections::HashMap::new(); diff --git a/src/memory.rs b/src/memory.rs index 5609fbc12..fb18163aa 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -6,9 +6,11 @@ pub mod maintenance; pub mod search; pub mod store; pub mod types; +pub mod working; pub use embedding::EmbeddingModel; pub use lance::EmbeddingTable; pub use search::{MemorySearch, SearchConfig, SearchMode, SearchSort, curate_results}; pub use store::MemoryStore; pub use types::{Association, Memory, MemoryType, RelationType}; +pub use working::{WorkingMemoryEventType, WorkingMemoryStore}; diff --git a/src/memory/working.rs b/src/memory/working.rs new file mode 100644 index 000000000..cad9a586f --- /dev/null +++ b/src/memory/working.rs @@ -0,0 +1,1513 @@ +//! Working memory event log and temporal context assembly. +//! +//! An append-only, structured event log scoped by day. Every significant thing +//! that happens across the agent is recorded as a timestamped event. Channels +//! get a progressively compressed view: today in detail, yesterday as a summary, +//! the week as a paragraph. +//! +//! User messages and agent responses are NOT stored here — they already live in +//! `conversation_messages`. Working memory events capture what happens *around* +//! conversations: worker lifecycle, branch conclusions, cron executions, +//! decisions, errors. + +use crate::error::Result; + +use chrono::{DateTime, Utc}; +use chrono_tz::Tz; +use sqlx::{Row, SqlitePool}; +use uuid::Uuid; + +use std::fmt; +use std::sync::Arc; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Typed event categories for working memory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkingMemoryEventType { + /// A branch completed with a conclusion. + BranchCompleted, + /// A worker was spawned. + WorkerSpawned, + /// A worker completed (success or failure). + WorkerCompleted, + /// A cron job executed. + CronExecuted, + /// A memory was saved (by any path). + MemorySaved, + /// A decision was made (extracted from conversation). + Decision, + /// An error or failure occurred. + Error, + /// A task was created or updated. + TaskUpdate, + /// Cross-agent communication. + AgentMessage, + /// System event (startup, config change, maintenance). + System, + /// Reserved for tiered memory integration — graph memory promoted to working tier. + MemoryPromoted, + /// Reserved for tiered memory integration — working tier memory demoted to graph. + MemoryDemoted, +} + +impl WorkingMemoryEventType { + pub fn as_str(&self) -> &'static str { + match self { + Self::BranchCompleted => "branch_completed", + Self::WorkerSpawned => "worker_spawned", + Self::WorkerCompleted => "worker_completed", + Self::CronExecuted => "cron_executed", + Self::MemorySaved => "memory_saved", + Self::Decision => "decision", + Self::Error => "error", + Self::TaskUpdate => "task_update", + Self::AgentMessage => "agent_message", + Self::System => "system", + Self::MemoryPromoted => "memory_promoted", + Self::MemoryDemoted => "memory_demoted", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "branch_completed" => Some(Self::BranchCompleted), + "worker_spawned" => Some(Self::WorkerSpawned), + "worker_completed" => Some(Self::WorkerCompleted), + "cron_executed" => Some(Self::CronExecuted), + "memory_saved" => Some(Self::MemorySaved), + "decision" => Some(Self::Decision), + "error" => Some(Self::Error), + "task_update" => Some(Self::TaskUpdate), + "agent_message" => Some(Self::AgentMessage), + "system" => Some(Self::System), + "memory_promoted" => Some(Self::MemoryPromoted), + "memory_demoted" => Some(Self::MemoryDemoted), + _ => None, + } + } +} + +impl fmt::Display for WorkingMemoryEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A single working memory event. +#[derive(Debug, Clone)] +pub struct WorkingMemoryEvent { + pub id: String, + pub event_type: WorkingMemoryEventType, + pub timestamp: DateTime, + pub channel_id: Option, + pub user_id: Option, + pub summary: String, + pub detail: Option, + pub importance: f32, + /// Denormalized date string (YYYY-MM-DD) in the agent's configured timezone. + pub day: String, +} + +/// A single intra-day synthesis batch (50-100 word paragraph covering a time range). +#[derive(Debug, Clone)] +pub struct IntradaySynthesis { + pub id: String, + pub day: String, + pub time_range_start: DateTime, + pub time_range_end: DateTime, + pub summary: String, + pub event_count: i64, + pub created_at: DateTime, +} + +/// A cortex-synthesized daily narrative. +#[derive(Debug, Clone)] +pub struct DailySummary { + pub day: String, + pub summary: String, + pub event_count: i64, + pub created_at: DateTime, +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +/// The append-only working memory event log. +pub struct WorkingMemoryStore { + pool: SqlitePool, + timezone: Tz, +} + +impl fmt::Debug for WorkingMemoryStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WorkingMemoryStore") + .field("timezone", &self.timezone) + .finish() + } +} + +impl WorkingMemoryStore { + /// Create a new working memory store. + /// + /// The `timezone` is used to compute the `day` column on every event — + /// a 23:30 UTC event for a UTC+2 agent must be recorded as the next + /// calendar day. + pub fn new(pool: SqlitePool, timezone: Tz) -> Arc { + Arc::new(Self { pool, timezone }) + } + + /// Compute today's date string in the agent's timezone. + pub fn today(&self) -> String { + let now_local = Utc::now().with_timezone(&self.timezone); + now_local.format("%Y-%m-%d").to_string() + } + + /// Compute a date string for a UTC timestamp in the agent's timezone. + fn day_for_timestamp(&self, timestamp: DateTime) -> String { + let local = timestamp.with_timezone(&self.timezone); + local.format("%Y-%m-%d").to_string() + } + + /// Compute yesterday's date string in the agent's timezone. + pub fn yesterday(&self) -> String { + let now_local = Utc::now().with_timezone(&self.timezone); + let yesterday = now_local.date_naive() - chrono::Duration::days(1); + yesterday.format("%Y-%m-%d").to_string() + } + + /// Get the configured timezone. + pub fn timezone(&self) -> Tz { + self.timezone + } + + // ----------------------------------------------------------------------- + // Fire-and-forget recording + // ----------------------------------------------------------------------- + + /// Fire-and-forget event recording. Spawns a task, never blocks the caller. + pub fn record(&self, event: WorkingMemoryEvent) { + let pool = self.pool.clone(); + tokio::spawn(async move { + if let Err(error) = insert_event(&pool, &event).await { + tracing::warn!(%error, event_type = %event.event_type, "failed to record working memory event"); + } + }); + } + + // ----------------------------------------------------------------------- + // Event queries + // ----------------------------------------------------------------------- + + /// Get events for a specific day, ordered by timestamp. + pub async fn get_events_for_day(&self, day: &str) -> Result> { + let rows = sqlx::query( + "SELECT id, event_type, timestamp, channel_id, user_id, summary, detail, importance, day \ + FROM working_memory_events WHERE day = ? ORDER BY timestamp ASC", + ) + .bind(day) + .fetch_all(&self.pool) + .await?; + + Ok(rows.iter().map(row_to_event).collect()) + } + + /// Get recent events for a channel, used for context injection. + pub async fn get_events_for_channel( + &self, + channel_id: &str, + limit: usize, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, event_type, timestamp, channel_id, user_id, summary, detail, importance, day \ + FROM working_memory_events WHERE channel_id = ? ORDER BY timestamp DESC LIMIT ?", + ) + .bind(channel_id) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?; + + // Reverse so oldest-first for rendering. + let mut events: Vec = rows.iter().map(row_to_event).collect(); + events.reverse(); + Ok(events) + } + + /// Get recent events across all channels, with importance filter. + pub async fn get_recent_events( + &self, + limit: usize, + min_importance: f32, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, event_type, timestamp, channel_id, user_id, summary, detail, importance, day \ + FROM working_memory_events WHERE importance >= ? ORDER BY timestamp DESC LIMIT ?", + ) + .bind(min_importance) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?; + + let mut events: Vec = rows.iter().map(row_to_event).collect(); + events.reverse(); + Ok(events) + } + + /// Get recent events for a specific user (for participant context). + pub async fn get_user_recent_events( + &self, + user_id: &str, + limit: usize, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, event_type, timestamp, channel_id, user_id, summary, detail, importance, day \ + FROM working_memory_events WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?", + ) + .bind(user_id) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?; + + let mut events: Vec = rows.iter().map(row_to_event).collect(); + events.reverse(); + Ok(events) + } + + /// Get events after a timestamp for a given day (unsynthesized events). + pub async fn get_events_after( + &self, + day: &str, + after: Option>, + ) -> Result> { + let rows = match after { + Some(after_ts) => { + sqlx::query( + "SELECT id, event_type, timestamp, channel_id, user_id, summary, detail, importance, day \ + FROM working_memory_events WHERE day = ? AND timestamp > ? ORDER BY timestamp ASC", + ) + .bind(day) + .bind(after_ts) + .fetch_all(&self.pool) + .await? + } + None => { + sqlx::query( + "SELECT id, event_type, timestamp, channel_id, user_id, summary, detail, importance, day \ + FROM working_memory_events WHERE day = ? ORDER BY timestamp ASC", + ) + .bind(day) + .fetch_all(&self.pool) + .await? + } + }; + + Ok(rows.iter().map(row_to_event).collect()) + } + + /// Count events for a channel since a timestamp (for density trigger). + pub async fn count_events_since(&self, channel_id: &str, since: DateTime) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM working_memory_events WHERE channel_id = ? AND timestamp > ?", + ) + .bind(channel_id) + .bind(since) + .fetch_one(&self.pool) + .await?; + + Ok(row.get::("count")) + } + + // ----------------------------------------------------------------------- + // Intra-day synthesis + // ----------------------------------------------------------------------- + + /// Get the end timestamp of the last intra-day synthesis for a day. + pub async fn get_last_intraday_synthesis_end( + &self, + day: &str, + ) -> Result>> { + let row = sqlx::query( + "SELECT time_range_end FROM working_memory_intraday_syntheses \ + WHERE day = ? ORDER BY time_range_start DESC LIMIT 1", + ) + .bind(day) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|r| r.get::, _>("time_range_end"))) + } + + /// Save an intra-day synthesis batch. + pub async fn save_intraday_synthesis( + &self, + day: &str, + time_start: DateTime, + time_end: DateTime, + summary: &str, + event_count: usize, + ) -> Result<()> { + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO working_memory_intraday_syntheses \ + (id, day, time_range_start, time_range_end, summary, event_count) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(day) + .bind(time_start) + .bind(time_end) + .bind(summary) + .bind(event_count as i64) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Get all intra-day syntheses for a day (for context rendering and daily rollup). + pub async fn get_intraday_syntheses(&self, day: &str) -> Result> { + let rows = sqlx::query( + "SELECT id, day, time_range_start, time_range_end, summary, event_count, created_at \ + FROM working_memory_intraday_syntheses WHERE day = ? ORDER BY time_range_start ASC", + ) + .bind(day) + .fetch_all(&self.pool) + .await?; + + Ok(rows.iter().map(row_to_intraday_synthesis).collect()) + } + + // ----------------------------------------------------------------------- + // Daily summaries + // ----------------------------------------------------------------------- + + /// Check if a daily summary exists. + pub async fn has_daily_summary(&self, day: &str) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM working_memory_daily_summaries WHERE day = ?", + ) + .bind(day) + .fetch_one(&self.pool) + .await?; + + Ok(row.get::("count") > 0) + } + + /// Save a daily summary. + pub async fn save_daily_summary( + &self, + day: &str, + summary: &str, + event_count: i64, + ) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO working_memory_daily_summaries (day, summary, event_count) \ + VALUES (?, ?, ?)", + ) + .bind(day) + .bind(summary) + .bind(event_count) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Get daily summary for a specific day. + pub async fn get_daily_summary(&self, day: &str) -> Result> { + let row = sqlx::query( + "SELECT day, summary, event_count, created_at \ + FROM working_memory_daily_summaries WHERE day = ?", + ) + .bind(day) + .fetch_optional(&self.pool) + .await?; + + Ok(row.as_ref().map(row_to_daily_summary)) + } + + /// Get daily summaries for a date range (for week rendering). + pub async fn get_daily_summaries_range( + &self, + from_day: &str, + to_day: &str, + ) -> Result> { + let rows = sqlx::query( + "SELECT day, summary, event_count, created_at \ + FROM working_memory_daily_summaries \ + WHERE day >= ? AND day <= ? ORDER BY day ASC", + ) + .bind(from_day) + .bind(to_day) + .fetch_all(&self.pool) + .await?; + + Ok(rows.iter().map(row_to_daily_summary).collect()) + } + + // ----------------------------------------------------------------------- + // Pruning + // ----------------------------------------------------------------------- + + /// Prune raw events and intra-day syntheses older than N days. + /// Daily summaries are never pruned (they are small and serve as permanent history). + pub async fn prune_old_events(&self, retention_days: i64) -> Result { + let cutoff = Utc::now() - chrono::Duration::days(retention_days); + let cutoff_day = self.day_for_timestamp(cutoff); + + let events_result = sqlx::query("DELETE FROM working_memory_events WHERE day < ?") + .bind(&cutoff_day) + .execute(&self.pool) + .await?; + + let syntheses_result = + sqlx::query("DELETE FROM working_memory_intraday_syntheses WHERE day < ?") + .bind(&cutoff_day) + .execute(&self.pool) + .await?; + + let total = events_result.rows_affected() + syntheses_result.rows_affected(); + if total > 0 { + tracing::info!( + events_pruned = events_result.rows_affected(), + syntheses_pruned = syntheses_result.rows_affected(), + cutoff_day = %cutoff_day, + "pruned old working memory data" + ); + } + Ok(total) + } + + // ----------------------------------------------------------------------- + // Builder API + // ----------------------------------------------------------------------- + + /// Convenience builder for common event emission. + pub fn emit( + self: &Arc, + event_type: WorkingMemoryEventType, + summary: impl Into, + ) -> WorkingMemoryEventBuilder { + WorkingMemoryEventBuilder::new(Arc::clone(self), event_type, summary.into()) + } +} + +// --------------------------------------------------------------------------- +// Rendering (Layers 2 + 3) +// --------------------------------------------------------------------------- + +/// Rough token estimate: ~0.75 tokens per character (conservative for English). +fn estimate_tokens(text: &str) -> usize { + text.len() * 3 / 4 +} + +/// Render Layer 2: Working Memory section for the channel system prompt. +/// +/// Produces a markdown block with today's intra-day synthesis paragraphs, +/// an unsynthesized event tail, yesterday's summary, and this week's summaries. +/// All rendering is programmatic — no LLM calls on this path. +pub async fn render_working_memory( + store: &WorkingMemoryStore, + channel_id: &str, + config: &crate::config::WorkingMemoryConfig, + timezone: chrono_tz::Tz, +) -> Result { + use std::fmt::Write; + + if !config.enabled { + return Ok(String::new()); + } + + let today = store.today(); + let yesterday = store.yesterday(); + let budget = config.context_token_budget; + + let mut output = String::with_capacity(2048); + let mut tokens_used: usize = 0; + let today_budget = budget * 60 / 100; // 60% for today + let yesterday_budget = budget * 20 / 100; // next 20% for yesterday + let week_budget = budget * 20 / 100; // remaining 20% for this week + + // --- Today header --- + let now_local = Utc::now().with_timezone(&timezone); + let day_name = now_local.format("%A, %B %-d").to_string(); + writeln!(output, "## Working Memory\n").ok(); + writeln!(output, "### Today ({day_name})").ok(); + + // 1. Intra-day synthesis paragraphs for today. + let syntheses = store.get_intraday_syntheses(&today).await?; + for synthesis in &syntheses { + let time_label = synthesis + .time_range_start + .with_timezone(&timezone) + .format("%H:%M") + .to_string(); + let block = format!("[{time_label}] {}\n", synthesis.summary); + let block_tokens = estimate_tokens(&block); + if tokens_used + block_tokens > today_budget { + break; + } + write!(output, "{block}").ok(); + tokens_used += block_tokens; + } + + // 2. Unsynthesized event tail (raw events since last synthesis). + let last_synthesis_end = store.get_last_intraday_synthesis_end(&today).await?; + let unsynthesized = store.get_events_after(&today, last_synthesis_end).await?; + let max_tail = config.today_max_unsynthesized_events; + + if !unsynthesized.is_empty() { + // Only show the header if there are also synthesis blocks above. + if !syntheses.is_empty() { + let since_label = last_synthesis_end + .map(|t| t.with_timezone(&timezone).format("%H:%M").to_string()) + .unwrap_or_else(|| "start".to_string()); + writeln!(output, "\n**Since {since_label}:**").ok(); + } + + let tail_events: Vec<&WorkingMemoryEvent> = if unsynthesized.len() > max_tail { + // Under token pressure, filter by importance. + let mut sorted: Vec<&WorkingMemoryEvent> = unsynthesized.iter().collect(); + sorted.sort_by(|a, b| { + b.importance + .partial_cmp(&a.importance) + .unwrap_or(std::cmp::Ordering::Equal) + }); + sorted.truncate(max_tail); + // Re-sort by timestamp for chronological display. + sorted.sort_by_key(|e| e.timestamp); + sorted + } else { + unsynthesized.iter().collect() + }; + + // Boost events from the current channel: always include them. + for event in &tail_events { + let line = format_event_line(event, channel_id); + let line_tokens = estimate_tokens(&line); + if tokens_used + line_tokens > today_budget { + break; + } + writeln!(output, "- {line}").ok(); + tokens_used += line_tokens; + } + } + + // If today is completely empty, note it. + if syntheses.is_empty() && unsynthesized.is_empty() { + writeln!(output, "*No activity yet today.*").ok(); + } + + // --- Yesterday --- + let yesterday_summary = store.get_daily_summary(&yesterday).await?; + if let Some(summary) = yesterday_summary { + let summary_tokens = estimate_tokens(&summary.summary); + if summary_tokens <= yesterday_budget { + let yesterday_local = (Utc::now() - chrono::Duration::days(1)).with_timezone(&timezone); + let yesterday_name = yesterday_local.format("%A, %B %-d").to_string(); + writeln!(output, "\n### Yesterday ({yesterday_name})").ok(); + writeln!(output, "{}", summary.summary).ok(); + #[allow(unused_assignments)] + { + tokens_used += summary_tokens; + } + } + } + + // --- This week (past 5 days, excluding today and yesterday) --- + let week_start_local = now_local.date_naive() - chrono::Duration::days(6); + let yesterday_date = now_local.date_naive() - chrono::Duration::days(1); + // Only fetch days before yesterday. + let week_end = (yesterday_date - chrono::Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + let week_start = week_start_local.format("%Y-%m-%d").to_string(); + + if week_start <= week_end { + let week_summaries = store + .get_daily_summaries_range(&week_start, &week_end) + .await?; + if !week_summaries.is_empty() { + let mut week_text = String::new(); + for summary in week_summaries.iter().rev() { + // Most recent first within the week section. + let candidate = format!("**{}:** {}\n", summary.day, summary.summary); + let candidate_tokens = estimate_tokens(&candidate); + if estimate_tokens(&week_text) + candidate_tokens > week_budget { + break; + } + week_text.push_str(&candidate); + } + if !week_text.is_empty() { + writeln!(output, "\n### Earlier This Week").ok(); + write!(output, "{week_text}").ok(); + } + } + } + + Ok(output) +} + +/// Render Layer 3: Channel Activity Map for the system prompt. +/// +/// Shows what's happening in other channels. Uses a single SQL query with a +/// correlated subquery to get the last message per channel, plus a batch +/// query for topic hints from working memory events. +pub async fn render_channel_activity_map( + pool: &sqlx::SqlitePool, + _working_memory: &WorkingMemoryStore, + exclude_channel_id: &str, + config: &crate::config::WorkingMemoryConfig, + _timezone: chrono_tz::Tz, +) -> Result { + use std::fmt::Write; + + let inactive_threshold = format!("-{} hours", config.channel_map_inactive_hours); + let max_channels = config.channel_map_max_channels as i64; + + // Single query: last message per channel, excluding current channel. + let rows = sqlx::query( + "SELECT \ + c.id, \ + c.display_name, \ + c.platform, \ + m.sender_name AS last_sender_name, \ + m.created_at AS last_message_at \ + FROM channels c \ + LEFT JOIN conversation_messages m ON m.id = ( \ + SELECT id FROM conversation_messages \ + WHERE channel_id = c.id \ + ORDER BY created_at DESC \ + LIMIT 1 \ + ) \ + WHERE c.id != ? \ + AND c.is_active = 1 \ + AND (m.created_at IS NULL OR m.created_at > datetime('now', ?)) \ + ORDER BY m.created_at DESC NULLS LAST \ + LIMIT ?", + ) + .bind(exclude_channel_id) + .bind(&inactive_threshold) + .bind(max_channels) + .fetch_all(pool) + .await?; + + if rows.is_empty() { + return Ok(String::new()); + } + + // Collect channel IDs for batch topic hint query. + let channel_ids: Vec = rows + .iter() + .filter_map(|r| r.get::, _>("id")) + .collect(); + + // Batch query: most recent BranchCompleted event per channel for topic hints. + let topic_hints = get_topic_hints(pool, &channel_ids).await?; + + let now = Utc::now(); + let mut output = String::with_capacity(512); + writeln!(output, "## Other Channels\n").ok(); + + for row in &rows { + let channel_id: String = row.get("id"); + let display_name: Option = row.get("display_name"); + let _platform: String = row.get("platform"); + let last_sender: Option = row.get("last_sender_name"); + let last_message_at: Option> = row.get("last_message_at"); + + let name = display_name.as_deref().unwrap_or(&channel_id); + + let time_ago = match last_message_at { + Some(at) => format_time_ago(now, at), + None => "no messages".to_string(), + }; + + let sender = last_sender.as_deref().unwrap_or("unknown"); + let topic = topic_hints.get(&channel_id); + + let mut line = format!("{name} -- {time_ago}, {sender}"); + if let Some(topic_summary) = topic { + // Truncate topic to keep the map compact. + let truncated = if topic_summary.len() > 80 { + let boundary = topic_summary.floor_char_boundary(80); + format!("{}...", &topic_summary[..boundary]) + } else { + topic_summary.clone() + }; + write!(line, ": {truncated}").ok(); + } + writeln!(output, "{line}").ok(); + } + + Ok(output) +} + +/// Format a single event as a one-line summary for the raw tail. +fn format_event_line(event: &WorkingMemoryEvent, current_channel_id: &str) -> String { + let type_label = match event.event_type { + WorkingMemoryEventType::BranchCompleted => "Branch completed", + WorkingMemoryEventType::WorkerSpawned => "Worker spawned", + WorkingMemoryEventType::WorkerCompleted => "Worker completed", + WorkingMemoryEventType::CronExecuted => "Cron executed", + WorkingMemoryEventType::MemorySaved => "Memory saved", + WorkingMemoryEventType::Decision => "Decision", + WorkingMemoryEventType::Error => "Error", + WorkingMemoryEventType::TaskUpdate => "Task update", + WorkingMemoryEventType::AgentMessage => "Agent message", + WorkingMemoryEventType::System => "System", + WorkingMemoryEventType::MemoryPromoted => "Memory promoted", + WorkingMemoryEventType::MemoryDemoted => "Memory demoted", + }; + + // Prefix with channel name if the event is from a different channel. + let channel_prefix = match &event.channel_id { + Some(cid) if cid != current_channel_id => format!("[{cid}] "), + _ => String::new(), + }; + + format!("{channel_prefix}{type_label}: {}", event.summary) +} + +/// Fetch the most recent BranchCompleted topic hint per channel. +async fn get_topic_hints( + pool: &sqlx::SqlitePool, + channel_ids: &[String], +) -> Result> { + use std::collections::HashMap; + + if channel_ids.is_empty() { + return Ok(HashMap::new()); + } + + // Build a parameterized IN clause. sqlx doesn't support binding Vec + // to IN directly, so we construct the query dynamically. + let placeholders: Vec<&str> = (0..channel_ids.len()).map(|_| "?").collect(); + let in_clause = placeholders.join(", "); + + // We need the most recent BranchCompleted per channel. Use a window + // function approach that works in a single pass. + let query = format!( + "SELECT channel_id, summary FROM ( \ + SELECT channel_id, summary, \ + ROW_NUMBER() OVER (PARTITION BY channel_id ORDER BY timestamp DESC) AS rn \ + FROM working_memory_events \ + WHERE event_type = 'branch_completed' \ + AND channel_id IN ({in_clause}) \ + ) WHERE rn = 1" + ); + + let mut query_builder = sqlx::query(&query); + for id in channel_ids { + query_builder = query_builder.bind(id); + } + + let rows = query_builder.fetch_all(pool).await?; + + let mut hints = HashMap::new(); + for row in &rows { + let channel_id: String = row.get("channel_id"); + let summary: String = row.get("summary"); + hints.insert(channel_id, summary); + } + + Ok(hints) +} + +/// Format a duration as a human-readable "time ago" string. +fn format_time_ago(now: DateTime, then: DateTime) -> String { + let delta = now - then; + let minutes = delta.num_minutes(); + if minutes < 1 { + "just now".to_string() + } else if minutes < 60 { + format!("{minutes}m ago") + } else if minutes < 1440 { + let hours = minutes / 60; + format!("{hours}h ago") + } else { + let days = minutes / 1440; + format!("{days}d ago") + } +} + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +/// Ergonomic builder for constructing and recording working memory events. +pub struct WorkingMemoryEventBuilder { + store: Arc, + event_type: WorkingMemoryEventType, + summary: String, + channel_id: Option, + user_id: Option, + detail: Option, + importance: f32, +} + +impl WorkingMemoryEventBuilder { + fn new( + store: Arc, + event_type: WorkingMemoryEventType, + summary: String, + ) -> Self { + Self { + store, + event_type, + summary, + channel_id: None, + user_id: None, + detail: None, + importance: 0.5, + } + } + + pub fn channel(mut self, channel_id: impl Into) -> Self { + self.channel_id = Some(channel_id.into()); + self + } + + pub fn user(mut self, user_id: impl Into) -> Self { + self.user_id = Some(user_id.into()); + self + } + + pub fn detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + pub fn importance(mut self, importance: f32) -> Self { + self.importance = importance; + self + } + + /// Fire-and-forget: record the event without blocking. + pub fn record(self) { + let now = Utc::now(); + let day = self.store.day_for_timestamp(now); + + let event = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: self.event_type, + timestamp: now, + channel_id: self.channel_id, + user_id: self.user_id, + summary: self.summary, + detail: self.detail, + importance: self.importance, + day, + }; + + self.store.record(event); + } +} + +// --------------------------------------------------------------------------- +// Row mapping helpers +// --------------------------------------------------------------------------- + +fn row_to_event(row: &sqlx::sqlite::SqliteRow) -> WorkingMemoryEvent { + let event_type_str: String = row.get("event_type"); + WorkingMemoryEvent { + id: row.get("id"), + event_type: WorkingMemoryEventType::parse(&event_type_str) + .unwrap_or(WorkingMemoryEventType::System), + timestamp: row.get("timestamp"), + channel_id: row.get("channel_id"), + user_id: row.get("user_id"), + summary: row.get("summary"), + detail: row.get("detail"), + importance: row.get("importance"), + day: row.get("day"), + } +} + +fn row_to_intraday_synthesis(row: &sqlx::sqlite::SqliteRow) -> IntradaySynthesis { + IntradaySynthesis { + id: row.get("id"), + day: row.get("day"), + time_range_start: row.get("time_range_start"), + time_range_end: row.get("time_range_end"), + summary: row.get("summary"), + event_count: row.get("event_count"), + created_at: row.get("created_at"), + } +} + +fn row_to_daily_summary(row: &sqlx::sqlite::SqliteRow) -> DailySummary { + DailySummary { + day: row.get("day"), + summary: row.get("summary"), + event_count: row.get("event_count"), + created_at: row.get("created_at"), + } +} + +async fn insert_event(pool: &SqlitePool, event: &WorkingMemoryEvent) -> Result<()> { + sqlx::query( + "INSERT INTO working_memory_events \ + (id, event_type, timestamp, channel_id, user_id, summary, detail, importance, day) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&event.id) + .bind(event.event_type.as_str()) + .bind(event.timestamp) + .bind(&event.channel_id) + .bind(&event.user_id) + .bind(&event.summary) + .bind(&event.detail) + .bind(event.importance) + .bind(&event.day) + .execute(pool) + .await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_test_store() -> Arc { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + WorkingMemoryStore::new(pool, Tz::UTC) + } + + #[tokio::test] + async fn test_record_and_query_events() { + let store = setup_test_store().await; + + let today = store.today(); + + // Record a few events directly (bypassing fire-and-forget for test determinism). + let event1 = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: WorkingMemoryEventType::WorkerSpawned, + timestamp: Utc::now(), + channel_id: Some("chan-1".to_string()), + user_id: None, + summary: "Worker spawned: compile project".to_string(), + detail: None, + importance: 0.6, + day: today.clone(), + }; + insert_event(&store.pool, &event1).await.unwrap(); + + let event2 = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: WorkingMemoryEventType::BranchCompleted, + timestamp: Utc::now(), + channel_id: Some("chan-1".to_string()), + user_id: Some("user-1".to_string()), + summary: "Branch concluded: auth module needs refactor".to_string(), + detail: Some("Full analysis of the auth module...".to_string()), + importance: 0.8, + day: today.clone(), + }; + insert_event(&store.pool, &event2).await.unwrap(); + + // Query by day. + let events = store.get_events_for_day(&today).await.unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].event_type, WorkingMemoryEventType::WorkerSpawned); + assert_eq!( + events[1].event_type, + WorkingMemoryEventType::BranchCompleted + ); + + // Query by channel. + let channel_events = store.get_events_for_channel("chan-1", 10).await.unwrap(); + assert_eq!(channel_events.len(), 2); + + // Query by user. + let user_events = store.get_user_recent_events("user-1", 10).await.unwrap(); + assert_eq!(user_events.len(), 1); + assert_eq!( + user_events[0].event_type, + WorkingMemoryEventType::BranchCompleted + ); + + // Count since. + let count = store + .count_events_since("chan-1", Utc::now() - chrono::Duration::hours(1)) + .await + .unwrap(); + assert_eq!(count, 2); + } + + #[tokio::test] + async fn test_event_type_roundtrip() { + let store = setup_test_store().await; + let today = store.today(); + + for event_type in [ + WorkingMemoryEventType::BranchCompleted, + WorkingMemoryEventType::WorkerSpawned, + WorkingMemoryEventType::WorkerCompleted, + WorkingMemoryEventType::CronExecuted, + WorkingMemoryEventType::MemorySaved, + WorkingMemoryEventType::Decision, + WorkingMemoryEventType::Error, + WorkingMemoryEventType::TaskUpdate, + WorkingMemoryEventType::AgentMessage, + WorkingMemoryEventType::System, + WorkingMemoryEventType::MemoryPromoted, + WorkingMemoryEventType::MemoryDemoted, + ] { + let event = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type, + timestamp: Utc::now(), + channel_id: None, + user_id: None, + summary: format!("test {}", event_type.as_str()), + detail: None, + importance: 0.5, + day: today.clone(), + }; + insert_event(&store.pool, &event).await.unwrap(); + } + + let events = store.get_events_for_day(&today).await.unwrap(); + assert_eq!(events.len(), 12); + + // Verify all types survived the roundtrip. + let types: Vec = events.iter().map(|e| e.event_type).collect(); + assert!(types.contains(&WorkingMemoryEventType::BranchCompleted)); + assert!(types.contains(&WorkingMemoryEventType::MemoryDemoted)); + } + + #[tokio::test] + async fn test_daily_summary_crud() { + let store = setup_test_store().await; + + assert!(!store.has_daily_summary("2026-03-18").await.unwrap()); + + store + .save_daily_summary("2026-03-18", "Busy day. Shipped 3 features.", 42) + .await + .unwrap(); + + assert!(store.has_daily_summary("2026-03-18").await.unwrap()); + + let summary = store + .get_daily_summary("2026-03-18") + .await + .unwrap() + .unwrap(); + assert_eq!(summary.summary, "Busy day. Shipped 3 features."); + assert_eq!(summary.event_count, 42); + + // Idempotent — save again should replace. + store + .save_daily_summary("2026-03-18", "Updated summary.", 43) + .await + .unwrap(); + let updated = store + .get_daily_summary("2026-03-18") + .await + .unwrap() + .unwrap(); + assert_eq!(updated.summary, "Updated summary."); + } + + #[tokio::test] + async fn test_intraday_synthesis() { + let store = setup_test_store().await; + let today = store.today(); + + let start = Utc::now() - chrono::Duration::hours(2); + let end = Utc::now() - chrono::Duration::hours(1); + + store + .save_intraday_synthesis(&today, start, end, "Morning batch: compiled, tested.", 8) + .await + .unwrap(); + + let syntheses = store.get_intraday_syntheses(&today).await.unwrap(); + assert_eq!(syntheses.len(), 1); + assert_eq!(syntheses[0].event_count, 8); + + let last_end = store.get_last_intraday_synthesis_end(&today).await.unwrap(); + assert!(last_end.is_some()); + } + + #[tokio::test] + async fn test_builder_api() { + let store = setup_test_store().await; + + // Use the builder to emit an event. Since `record()` is fire-and-forget + // via tokio::spawn, we need a small sleep for the write to complete. + store + .emit(WorkingMemoryEventType::System, "Agent started") + .importance(0.3) + .record(); + + store + .emit( + WorkingMemoryEventType::WorkerSpawned, + "Worker: compile check", + ) + .channel("test-channel") + .importance(0.6) + .record(); + + // Allow the spawned tasks to complete. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let today = store.today(); + let events = store.get_events_for_day(&today).await.unwrap(); + assert_eq!(events.len(), 2); + } + + #[tokio::test] + async fn test_pruning() { + let store = setup_test_store().await; + + // Insert an event with an old day. + let old_event = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: WorkingMemoryEventType::System, + timestamp: Utc::now() - chrono::Duration::days(60), + channel_id: None, + user_id: None, + summary: "Old event".to_string(), + detail: None, + importance: 0.5, + day: "2026-01-01".to_string(), + }; + insert_event(&store.pool, &old_event).await.unwrap(); + + // Insert a recent event. + let recent_event = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: WorkingMemoryEventType::System, + timestamp: Utc::now(), + channel_id: None, + user_id: None, + summary: "Recent event".to_string(), + detail: None, + importance: 0.5, + day: store.today(), + }; + insert_event(&store.pool, &recent_event).await.unwrap(); + + let pruned = store.prune_old_events(30).await.unwrap(); + assert!(pruned >= 1); + + // Recent event should survive. + let today_events = store.get_events_for_day(&store.today()).await.unwrap(); + assert_eq!(today_events.len(), 1); + } + + #[tokio::test] + async fn test_get_events_after() { + let store = setup_test_store().await; + let today = store.today(); + + let t1 = Utc::now() - chrono::Duration::minutes(30); + let t2 = Utc::now() - chrono::Duration::minutes(15); + let t3 = Utc::now(); + + for (i, ts) in [t1, t2, t3].iter().enumerate() { + let event = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: WorkingMemoryEventType::System, + timestamp: *ts, + channel_id: None, + user_id: None, + summary: format!("event {i}"), + detail: None, + importance: 0.5, + day: today.clone(), + }; + insert_event(&store.pool, &event).await.unwrap(); + } + + // All events for today (no after filter). + let all = store.get_events_after(&today, None).await.unwrap(); + assert_eq!(all.len(), 3); + + // Events after t1 should be t2 and t3. + let after_t1 = store.get_events_after(&today, Some(t1)).await.unwrap(); + assert_eq!(after_t1.len(), 2); + } + + #[tokio::test] + async fn test_daily_summaries_range() { + let store = setup_test_store().await; + + store + .save_daily_summary("2026-03-15", "Day 1", 10) + .await + .unwrap(); + store + .save_daily_summary("2026-03-16", "Day 2", 20) + .await + .unwrap(); + store + .save_daily_summary("2026-03-17", "Day 3", 30) + .await + .unwrap(); + store + .save_daily_summary("2026-03-18", "Day 4", 40) + .await + .unwrap(); + + let range = store + .get_daily_summaries_range("2026-03-16", "2026-03-18") + .await + .unwrap(); + assert_eq!(range.len(), 3); + assert_eq!(range[0].day, "2026-03-16"); + assert_eq!(range[2].day, "2026-03-18"); + } + + fn test_config() -> crate::config::WorkingMemoryConfig { + crate::config::WorkingMemoryConfig::default() + } + + #[tokio::test] + async fn test_render_working_memory_empty() { + let store = setup_test_store().await; + let config = test_config(); + + let rendered = render_working_memory(&store, "chan-1", &config, Tz::UTC) + .await + .unwrap(); + + assert!(rendered.contains("## Working Memory"), "should have header"); + assert!( + rendered.contains("No activity yet today"), + "should note empty day" + ); + } + + #[tokio::test] + async fn test_render_working_memory_with_events() { + let store = setup_test_store().await; + let config = test_config(); + let today = store.today(); + + // Insert some events. + for i in 0..3 { + let event = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: WorkingMemoryEventType::WorkerCompleted, + timestamp: Utc::now(), + channel_id: Some("chan-1".to_string()), + user_id: None, + summary: format!("Worker {i} completed successfully"), + detail: None, + importance: 0.6, + day: today.clone(), + }; + insert_event(&store.pool, &event).await.unwrap(); + } + + let rendered = render_working_memory(&store, "chan-1", &config, Tz::UTC) + .await + .unwrap(); + + assert!(rendered.contains("## Working Memory"), "should have header"); + assert!( + rendered.contains("Worker 0 completed"), + "should contain events" + ); + assert!( + rendered.contains("Worker 2 completed"), + "should contain all events" + ); + // Should NOT say "No activity" since we have events. + assert!(!rendered.contains("No activity yet today")); + } + + #[tokio::test] + async fn test_render_working_memory_with_synthesis_and_tail() { + let store = setup_test_store().await; + let config = test_config(); + let today = store.today(); + + // Add an intra-day synthesis. + let start = Utc::now() - chrono::Duration::hours(3); + let end = Utc::now() - chrono::Duration::hours(2); + store + .save_intraday_synthesis( + &today, + start, + end, + "Morning: deployed v1.2.0, fixed 3 bugs, ran full test suite.", + 8, + ) + .await + .unwrap(); + + // Add an unsynthesized event after the synthesis. + let event = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: WorkingMemoryEventType::BranchCompleted, + timestamp: Utc::now(), + channel_id: Some("chan-1".to_string()), + user_id: None, + summary: "Analyzed auth module for refactoring".to_string(), + detail: None, + importance: 0.7, + day: today.clone(), + }; + insert_event(&store.pool, &event).await.unwrap(); + + let rendered = render_working_memory(&store, "chan-1", &config, Tz::UTC) + .await + .unwrap(); + + assert!( + rendered.contains("Morning: deployed v1.2.0"), + "should contain synthesis" + ); + assert!( + rendered.contains("auth module"), + "should contain tail event" + ); + assert!( + rendered.contains("Since"), + "should have 'Since' label for tail" + ); + } + + #[tokio::test] + async fn test_render_working_memory_with_yesterday() { + let store = setup_test_store().await; + let config = test_config(); + + let yesterday = store.yesterday(); + store + .save_daily_summary(&yesterday, "Quiet day. Fixed auth bug. Reviewed 3 PRs.", 12) + .await + .unwrap(); + + let rendered = render_working_memory(&store, "chan-1", &config, Tz::UTC) + .await + .unwrap(); + + assert!( + rendered.contains("### Yesterday"), + "should have yesterday section" + ); + assert!( + rendered.contains("auth bug"), + "should contain yesterday content" + ); + } + + #[tokio::test] + async fn test_render_working_memory_respects_token_budget() { + let store = setup_test_store().await; + let today = store.today(); + let mut config = test_config(); + config.context_token_budget = 200; // Very tight budget. + + // Add many events to exceed the budget. + for i in 0..50 { + let event = WorkingMemoryEvent { + id: Uuid::new_v4().to_string(), + event_type: WorkingMemoryEventType::WorkerCompleted, + timestamp: Utc::now(), + channel_id: Some("chan-1".to_string()), + user_id: None, + summary: format!("Worker {i} completed with a long description of what was done"), + detail: None, + importance: 0.6, + day: today.clone(), + }; + insert_event(&store.pool, &event).await.unwrap(); + } + + let rendered = render_working_memory(&store, "chan-1", &config, Tz::UTC) + .await + .unwrap(); + + // Should not contain all 50 events — budget should cap it. + let event_lines = rendered.lines().filter(|l| l.starts_with("- ")).count(); + assert!( + event_lines < 50, + "should be capped by token budget, got {event_lines}" + ); + } + + #[tokio::test] + async fn test_render_working_memory_disabled() { + let store = setup_test_store().await; + let mut config = test_config(); + config.enabled = false; + + let rendered = render_working_memory(&store, "chan-1", &config, Tz::UTC) + .await + .unwrap(); + + assert!(rendered.is_empty(), "should be empty when disabled"); + } + + #[tokio::test] + async fn test_render_channel_activity_map_empty() { + let store = setup_test_store().await; + let config = test_config(); + + let rendered = render_channel_activity_map(&store.pool, &store, "chan-1", &config, Tz::UTC) + .await + .unwrap(); + + // No channels in DB, so should be empty. + assert!(rendered.is_empty(), "should be empty with no channels"); + } + + #[test] + fn test_format_time_ago() { + let now = Utc::now(); + assert_eq!(format_time_ago(now, now), "just now"); + assert_eq!( + format_time_ago(now, now - chrono::Duration::minutes(5)), + "5m ago" + ); + assert_eq!( + format_time_ago(now, now - chrono::Duration::hours(2)), + "2h ago" + ); + assert_eq!( + format_time_ago(now, now - chrono::Duration::days(3)), + "3d ago" + ); + } + + #[test] + fn test_format_event_line() { + let event = WorkingMemoryEvent { + id: "test".to_string(), + event_type: WorkingMemoryEventType::WorkerCompleted, + timestamp: Utc::now(), + channel_id: Some("chan-1".to_string()), + user_id: None, + summary: "Built the project".to_string(), + detail: None, + importance: 0.6, + day: "2026-03-18".to_string(), + }; + + // Same channel — no prefix. + let line = format_event_line(&event, "chan-1"); + assert_eq!(line, "Worker completed: Built the project"); + + // Different channel — prefix with channel ID. + let line = format_event_line(&event, "chan-2"); + assert_eq!(line, "[chan-1] Worker completed: Built the project"); + } +} diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index e706b59d1..5cac008c5 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -57,6 +57,18 @@ impl PromptEngine { "cortex_bulletin", crate::prompts::text::get("cortex_bulletin"), )?; + env.add_template( + "cortex_knowledge_synthesis", + crate::prompts::text::get("cortex_knowledge_synthesis"), + )?; + env.add_template( + "cortex_intraday_synthesis", + crate::prompts::text::get("cortex_intraday_synthesis"), + )?; + env.add_template( + "cortex_daily_summary", + crate::prompts::text::get("cortex_daily_summary"), + )?; env.add_template("compactor", crate::prompts::text::get("compactor"))?; env.add_template( "memory_persistence", @@ -398,6 +410,42 @@ impl PromptEngine { ) } + /// Render the intra-day synthesis prompt. + pub fn render_intraday_synthesis( + &self, + event_count: usize, + time_start: &str, + time_end: &str, + events: &str, + ) -> Result { + self.render( + "cortex_intraday_synthesis", + context! { + event_count => event_count, + time_start => time_start, + time_end => time_end, + events => events, + }, + ) + } + + /// Render the daily summary prompt. + pub fn render_daily_summary( + &self, + date: &str, + max_words: usize, + intraday_blocks: &str, + ) -> Result { + self.render( + "cortex_daily_summary", + context! { + date => date, + max_words => max_words, + intraday_blocks => intraday_blocks, + }, + ) + } + /// Convenience method for rendering ingestion chunk prompt. pub fn render_system_ingestion_chunk( &self, @@ -472,6 +520,8 @@ impl PromptEngine { None, None, None, + None, + None, ) } @@ -579,7 +629,13 @@ impl PromptEngine { adapter_prompt: Option, project_context: Option, backfill_transcript: Option, + working_memory: Option, + channel_activity_map: Option, ) -> Result { + // During the transition, the bulletin is also exposed as knowledge_synthesis + // so the template can render it under the new heading. + let knowledge_synthesis = memory_bulletin.clone(); + self.render( "channel", context! { @@ -596,6 +652,9 @@ impl PromptEngine { adapter_prompt => adapter_prompt, project_context => project_context, backfill_transcript => backfill_transcript, + working_memory => working_memory, + channel_activity_map => channel_activity_map, + knowledge_synthesis => knowledge_synthesis, }, ) } diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 8ec497891..53122f42d 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -59,6 +59,15 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "worker") => include_str!("../../prompts/en/worker.md.j2"), ("en", "cortex") => include_str!("../../prompts/en/cortex.md.j2"), ("en", "cortex_bulletin") => include_str!("../../prompts/en/cortex_bulletin.md.j2"), + ("en", "cortex_knowledge_synthesis") => { + include_str!("../../prompts/en/cortex_knowledge_synthesis.md.j2") + } + ("en", "cortex_intraday_synthesis") => { + include_str!("../../prompts/en/cortex_intraday_synthesis.md.j2") + } + ("en", "cortex_daily_summary") => { + include_str!("../../prompts/en/cortex_daily_summary.md.j2") + } ("en", "cortex_profile") => include_str!("../../prompts/en/cortex_profile.md.j2"), ("en", "compactor") => include_str!("../../prompts/en/compactor.md.j2"), ("en", "memory_persistence") => include_str!("../../prompts/en/memory_persistence.md.j2"), diff --git a/src/tools.rs b/src/tools.rs index 677bd3e85..59e10e0b6 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -187,6 +187,8 @@ pub enum BranchToolProfile { Default, MemoryPersistence { contract_state: Arc, + working_memory: Option>, + channel_id: Option, }, } @@ -501,8 +503,13 @@ fn memory_save_with_events( memory_search: Arc, agent_id: AgentId, memory_event_tx: broadcast::Sender, + working_memory: Option>, ) -> MemorySaveTool { - MemorySaveTool::new(memory_search).with_event_bus(agent_id, memory_event_tx) + let tool = MemorySaveTool::new(memory_search).with_event_bus(agent_id, memory_event_tx); + match working_memory { + Some(store) => tool.with_working_memory(store), + None => tool, + } } /// Create a per-branch ToolServer with memory tools. @@ -527,8 +534,9 @@ pub fn create_branch_tool_server( memory_search.clone(), agent_id.clone(), memory_event_tx.clone(), + None, ); - if let BranchToolProfile::MemoryPersistence { contract_state } = &profile { + if let BranchToolProfile::MemoryPersistence { contract_state, .. } = &profile { memory_save = memory_save.with_contract_state(contract_state.clone()); } @@ -548,8 +556,17 @@ pub fn create_branch_tool_server( .tool(TaskListTool::new(task_store.clone(), agent_id.to_string())) .tool(TaskUpdateTool::for_branch(task_store, agent_id.clone())); - if let BranchToolProfile::MemoryPersistence { contract_state } = profile { - server = server.tool(MemoryPersistenceCompleteTool::new(contract_state)); + if let BranchToolProfile::MemoryPersistence { + contract_state, + working_memory, + channel_id, + } = profile + { + let mut tool = MemoryPersistenceCompleteTool::new(contract_state); + if let Some(store) = working_memory { + tool = tool.with_working_memory(store, channel_id); + } + server = server.tool(tool); } if let Some(state) = state { @@ -621,8 +638,9 @@ pub fn create_worker_tool_server( /// Create a ToolServer for the cortex process. /// -/// The cortex only needs memory_save for consolidation. Additional tools can be -/// added later as cortex capabilities expand. +/// Retained for potential future use. The compactor no longer uses this +/// (Phase 5b removed compactor memory_save). +#[allow(dead_code)] pub fn create_cortex_tool_server( agent_id: AgentId, memory_event_tx: broadcast::Sender, @@ -633,6 +651,7 @@ pub fn create_cortex_tool_server( memory_search, agent_id, memory_event_tx, + None, )) .run() } @@ -678,6 +697,7 @@ pub fn create_cortex_chat_tool_server( memory_search.clone(), agent_id.clone(), memory_event_tx, + None, )) .tool(MemoryRecallTool::new(memory_search.clone())) .tool(MemoryDeleteTool::new(memory_search)) diff --git a/src/tools/memory_delete.rs b/src/tools/memory_delete.rs index af0383f9b..de27f7c92 100644 --- a/src/tools/memory_delete.rs +++ b/src/tools/memory_delete.rs @@ -14,12 +14,22 @@ use std::sync::Arc; #[derive(Debug, Clone)] pub struct MemoryDeleteTool { memory_search: Arc, + runtime_config: Option>, } impl MemoryDeleteTool { /// Create a new memory delete tool. pub fn new(memory_search: Arc) -> Self { - Self { memory_search } + Self { + memory_search, + runtime_config: None, + } + } + + /// Enable knowledge synthesis dirty-flag bumping on delete. + pub fn with_runtime_config(mut self, config: Arc) -> Self { + self.runtime_config = Some(config); + self } } @@ -115,6 +125,10 @@ impl Tool for MemoryDeleteTool { .with_label_values(&["unknown", "forget"]) .inc(); + if let Some(rc) = &self.runtime_config { + rc.bump_knowledge_synthesis_version(); + } + tracing::info!( memory_id = %args.memory_id, memory_type = %memory.memory_type, diff --git a/src/tools/memory_persistence_complete.rs b/src/tools/memory_persistence_complete.rs index df9e2dd69..f2a17ca5c 100644 --- a/src/tools/memory_persistence_complete.rs +++ b/src/tools/memory_persistence_complete.rs @@ -58,11 +58,28 @@ impl MemoryPersistenceContractState { #[derive(Debug, Clone)] pub struct MemoryPersistenceCompleteTool { state: Arc, + working_memory: Option>, + channel_id: Option, } impl MemoryPersistenceCompleteTool { pub fn new(state: Arc) -> Self { - Self { state } + Self { + state, + working_memory: None, + channel_id: None, + } + } + + /// Enable working memory event writing for extracted events. + pub fn with_working_memory( + mut self, + store: Arc, + channel_id: Option, + ) -> Self { + self.working_memory = Some(store); + self.channel_id = channel_id; + self } } @@ -83,6 +100,26 @@ pub struct MemoryPersistenceCompleteArgs { /// was worth saving. #[serde(default)] pub reason: Option, + /// Optional events extracted from the conversation. Each event becomes a + /// working memory entry for temporal context. + #[serde(default)] + pub events: Vec, +} + +/// A single event extracted by the persistence branch for the working memory log. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct WorkingMemoryEventInput { + /// Event type: "decision", "error", or "system". + pub event_type: String, + /// One-line summary of the event. + pub summary: String, + /// Importance score (0.0-1.0). Defaults to 0.5. + #[serde(default = "default_importance")] + pub importance: f32, +} + +fn default_importance() -> f32 { + 0.5 } #[derive(Debug, Serialize)] @@ -120,6 +157,29 @@ impl Tool for MemoryPersistenceCompleteTool { "reason": { "type": "string", "description": "Required for outcome=no_memories. Brief reason why no memories were saved" + }, + "events": { + "type": "array", + "description": "Optional events extracted from the conversation for working memory", + "items": { + "type": "object", + "properties": { + "event_type": { + "type": "string", + "enum": ["decision", "error", "system"], + "description": "Type of event" + }, + "summary": { + "type": "string", + "description": "One-line summary of the event" + }, + "importance": { + "type": "number", + "description": "Importance score 0.0-1.0, defaults to 0.5" + } + }, + "required": ["event_type", "summary"] + } } }, "required": ["outcome"] @@ -131,6 +191,31 @@ impl Tool for MemoryPersistenceCompleteTool { let outcome = args.outcome.trim(); let recorded_ids = self.state.saved_memory_ids(); + // Write any extracted events to working memory (fire-and-forget). + if let Some(working_memory) = &self.working_memory { + for event_input in &args.events { + let event_type = match event_input.event_type.as_str() { + "decision" => crate::memory::WorkingMemoryEventType::Decision, + "error" => crate::memory::WorkingMemoryEventType::Error, + _ => crate::memory::WorkingMemoryEventType::System, + }; + let importance = event_input.importance.clamp(0.0, 1.0); + let mut builder = working_memory + .emit(event_type, &event_input.summary) + .importance(importance); + if let Some(channel_id) = &self.channel_id { + builder = builder.channel(channel_id.clone()); + } + builder.record(); + } + if !args.events.is_empty() { + tracing::info!( + event_count = args.events.len(), + "persistence branch extracted events into working memory" + ); + } + } + match outcome { "saved" => { if args.saved_memory_ids.is_empty() { @@ -217,6 +302,7 @@ mod tests { outcome: "saved".to_string(), saved_memory_ids: vec!["mem_fake".to_string()], reason: None, + events: vec![], }) .await .expect_err("fabricated ids should fail"); @@ -236,6 +322,7 @@ mod tests { outcome: "saved".to_string(), saved_memory_ids: vec!["mem_2".to_string(), "mem_1".to_string()], reason: None, + events: vec![], }) .await .expect("exact ids should pass"); @@ -254,6 +341,7 @@ mod tests { outcome: "no_memories".to_string(), saved_memory_ids: Vec::new(), reason: Some("No durable facts in recent turns".to_string()), + events: vec![], }) .await .expect("no_memories should pass with reason"); diff --git a/src/tools/memory_save.rs b/src/tools/memory_save.rs index fbefbeaf6..c4a2e09b4 100644 --- a/src/tools/memory_save.rs +++ b/src/tools/memory_save.rs @@ -20,6 +20,7 @@ pub struct MemorySaveTool { memory_search: Arc, event_context: Option, contract_state: Option>, + working_memory: Option>, } #[derive(Debug, Clone)] @@ -35,6 +36,7 @@ impl MemorySaveTool { memory_search, event_context: None, contract_state: None, + working_memory: None, } } @@ -58,6 +60,12 @@ impl MemorySaveTool { self.contract_state = Some(contract_state); self } + + /// Enable working memory event emission for successful memory saves. + pub fn with_working_memory(mut self, store: Arc) -> Self { + self.working_memory = Some(store); + self + } } /// Error type for memory save tool. @@ -402,6 +410,20 @@ impl Tool for MemorySaveTool { } } + if let Some(working_memory) = &self.working_memory { + let content_preview = summarize_memory_content(&memory.content); + let mut builder = working_memory + .emit( + crate::memory::WorkingMemoryEventType::MemorySaved, + format!("Memory saved ({}): {content_preview}", memory.memory_type), + ) + .importance(0.5); + if let Some(channel_id) = &memory.channel_id { + builder = builder.channel(channel_id.to_string()); + } + builder.record(); + } + #[cfg(feature = "metrics")] { let agent_id = self.memory_search.store().agent_id(); diff --git a/src/tools/send_agent_message.rs b/src/tools/send_agent_message.rs index 213f1da31..3795c9da0 100644 --- a/src/tools/send_agent_message.rs +++ b/src/tools/send_agent_message.rs @@ -40,6 +40,7 @@ pub struct SendAgentMessageTool { /// The originating channel (conversation_id) where the user request came from. /// Set per-turn so task completion notifications route back to the right place. originating_channel: Option, + working_memory: Option>, } impl std::fmt::Debug for SendAgentMessageTool { @@ -66,6 +67,7 @@ impl SendAgentMessageTool { conversation_logger, skip_flag: None, originating_channel: None, + working_memory: None, } } @@ -82,6 +84,11 @@ impl SendAgentMessageTool { self } + pub fn with_working_memory(mut self, store: Arc) -> Self { + self.working_memory = Some(store); + self + } + /// Resolve an agent target string to an agent ID. /// Checks both IDs and display names (case-insensitive). fn resolve_agent_id(&self, target: &str) -> Option { @@ -282,6 +289,16 @@ impl Tool for SendAgentMessageTool { "task delegated to target agent" ); + if let Some(working_memory) = &self.working_memory { + working_memory + .emit( + crate::memory::WorkingMemoryEventType::AgentMessage, + format!("Delegated task #{task_number} to {target_display}"), + ) + .importance(0.7) + .record(); + } + Ok(SendAgentMessageOutput { success: true, target_agent: target_display, diff --git a/src/tools/spawn_worker.rs b/src/tools/spawn_worker.rs index 8d7692b23..fc54147a5 100644 --- a/src/tools/spawn_worker.rs +++ b/src/tools/spawn_worker.rs @@ -456,6 +456,15 @@ impl Tool for DetachedSpawnWorkerTool { directory: None, }); + self.deps + .working_memory + .emit( + crate::memory::WorkingMemoryEventType::WorkerSpawned, + format!("Worker spawned (cortex): {}", &args.task), + ) + .importance(0.5) + .record(); + // Log to worker_runs directly since there's no parent channel to do it. let run_logger = crate::conversation::history::ProcessRunLogger::new(self.deps.sqlite_pool.clone()); diff --git a/src/tools/task_create.rs b/src/tools/task_create.rs index 95a797abf..b8abe6ab9 100644 --- a/src/tools/task_create.rs +++ b/src/tools/task_create.rs @@ -12,6 +12,7 @@ pub struct TaskCreateTool { task_store: Arc, agent_id: String, created_by: String, + working_memory: Option>, } impl TaskCreateTool { @@ -24,8 +25,14 @@ impl TaskCreateTool { task_store, agent_id: agent_id.into(), created_by: created_by.into(), + working_memory: None, } } + + pub fn with_working_memory(mut self, store: Arc) -> Self { + self.working_memory = Some(store); + self + } } #[derive(Debug, thiserror::Error)] @@ -133,6 +140,16 @@ impl Tool for TaskCreateTool { .await .map_err(|error| TaskCreateError(format!("{error}")))?; + if let Some(working_memory) = &self.working_memory { + working_memory + .emit( + crate::memory::WorkingMemoryEventType::TaskUpdate, + format!("Task created #{}: {}", task.task_number, task.title), + ) + .importance(0.5) + .record(); + } + Ok(TaskCreateOutput { success: true, task_number: task.task_number, diff --git a/src/tools/task_update.rs b/src/tools/task_update.rs index d26cf356e..a27023168 100644 --- a/src/tools/task_update.rs +++ b/src/tools/task_update.rs @@ -19,6 +19,7 @@ pub struct TaskUpdateTool { task_store: Arc, agent_id: AgentId, scope: TaskUpdateScope, + working_memory: Option>, } impl TaskUpdateTool { @@ -27,6 +28,7 @@ impl TaskUpdateTool { task_store, agent_id, scope: TaskUpdateScope::Branch, + working_memory: None, } } @@ -35,8 +37,14 @@ impl TaskUpdateTool { task_store, agent_id, scope: TaskUpdateScope::Worker(worker_id), + working_memory: None, } } + + pub fn with_working_memory(mut self, store: Arc) -> Self { + self.working_memory = Some(store); + self + } } #[derive(Debug, thiserror::Error)] @@ -225,6 +233,19 @@ impl Tool for TaskUpdateTool { .map_err(|error| TaskUpdateError(format!("{error}")))? .ok_or_else(|| TaskUpdateError(format!("task #{} not found", task_number)))?; + if let Some(working_memory) = &self.working_memory { + working_memory + .emit( + crate::memory::WorkingMemoryEventType::TaskUpdate, + format!( + "Task #{} updated to {}", + updated.task_number, updated.status + ), + ) + .importance(0.4) + .record(); + } + Ok(TaskUpdateOutput { success: true, task_number: updated.task_number, diff --git a/tests/bulletin.rs b/tests/bulletin.rs index f7e6942ab..1b689eabb 100644 --- a/tests/bulletin.rs +++ b/tests/bulletin.rs @@ -130,6 +130,10 @@ async fn bootstrap_deps() -> anyhow::Result { spacebot::agent::process_control::ProcessControlRegistry::new(), ), injection_tx: tokio::sync::mpsc::channel(1).0, + working_memory: spacebot::memory::WorkingMemoryStore::new( + db.sqlite.clone(), + chrono_tz::Tz::UTC, + ), }) } diff --git a/tests/context_dump.rs b/tests/context_dump.rs index 4fad60d8f..e84c2f41d 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -129,6 +129,10 @@ async fn bootstrap_deps() -> anyhow::Result<(spacebot::AgentDeps, spacebot::conf spacebot::agent::process_control::ProcessControlRegistry::new(), ), injection_tx: tokio::sync::mpsc::channel(1).0, + working_memory: spacebot::memory::WorkingMemoryStore::new( + db.sqlite.clone(), + chrono_tz::Tz::UTC, + ), }; Ok((deps, config))