# eco-memory: triggers + confidence lever

Living doc · updated 2026-07-28 · amir

Root: the ecoagent-memory stack that lives at `/srv/ecoagent/memory/` and surfaces at [memory.agnt.eco](https://memory.agnt.eco/).

---

## Status

| Phase | State | Date |
| --- | --- | --- |
| 1 — separate memory from fact | **shipped** | 2026-07-28 |
| 2 — wire the middle hop (Episode → Fact) | **shipped** | 2026-07-28 |
| 3 — trigger + confidence lever | **shipped** | 2026-07-28 |
| 3.5 — Hermes chat integration | **shipped** | 2026-07-28 |
| 4 — ecology-scoped manual promotion | planned | – |

Live surface: [memory.agnt.eco](https://memory.agnt.eco/) · [/roadmap](https://memory.agnt.eco/roadmap) · [/api/lever](https://memory.agnt.eco/api/lever) · [/api/pipeline](https://memory.agnt.eco/api/pipeline).

---

## The problem this stack answers

Facts (verification.agnt.eco) and memory are **not the same thing**. Facts are hash-sealed, multi-pass verified, immutable citations. Memory is mutable, drifting, personal, forgettable. Treating every memory as a fact-candidate silently bypassed the real verification pipeline.

Phase 1 severed the automatic bridge. Memory lives entirely inside `:Mem_*` now. Only ripe, ecology-scoped memories may later be **manually** proposed to the real verification pipeline (Phase 4).

---

## Phase 1 — bridge retired (shipped 2026-07-28)

Retired the automatic `:Mem_Candidate → :Fact` writer. Flag `:Mem_Config { BRIDGE_ENABLED = false }` gates both `nominate()` and `approve_candidate()` — they raise `BridgeRetired` before any write.

Files:
- Migration: `/srv/ecoagent/memory/migrations/005_bridge_retired.cypher`
- Gate: `/srv/ecoagent/memory/ecoagent_memory/_bridge_gate.py`
- Contract addendum: Rule 7 in `/srv/ecoagent/memory/docs/CONTRACT.md`

Preserved as historical data: 1004 :Mem_Candidate, 5858 :Mem_AuditLog rows.

Rollback: flip `BRIDGE_ENABLED` to `true` via the dashboard. One click, no code change.

---

## Phase 2 — wire the middle hop (shipped 2026-07-28)

The extractor (`triple_extractor.py`) reads `:Mem_Episode` text and writes `:Mem_Fact` triples via qwen2.5:14b on local Ollama. Enforce mode on. Harvester timer runs hourly. `recall()` — previously returning `[]` for every agent — now returns real memory.

Configuration:
- `/etc/ecoagent-memory/harvester.env` — model + endpoint + fact-writer mode
- `ecoagent-memory-harvester.timer` — hourly (was 10 min)
- `TimeoutSec=1800` — CPU inference on this host is slow

All 10 agents have `ECOAGENT_MEMORY_PROVIDER=neo4j` in their `/srv/agents/<agent>/.env`. Provider factory routes to `Neo4jMemoryProvider`; every narrator run injects a memory block into its user message.

Current volumes: 102 :Mem_Episode, 2112 :Mem_Fact.

---

## Phase 3 — confidence lever (shipped 2026-07-28)

Memory is now a **living scalar** (Prism-style).

### Bi-temporal columns on every `:Mem_Fact`

- `valid_from` (backfilled from `created_at`)
- `invalidated_at` — `null` = live; live queries filter on `IS NULL`; contradictions supersede, they don't overwrite
- `last_reinforced_at` (rebased to `now()` for pre-existing rows so nothing rots retroactively)
- `reinforce_count`

Migration: `/srv/ecoagent/memory/migrations/006_bitemporal.cypher`.

### Reinforce path

Every successful `recall()` hit fires an asymptotic impulse:
```
Δ = α × (ceiling − current)
```
with defaults `α = 0.15`, `ceiling = 1.0` (Prism-inspired). Daily-idempotent per `(mem_fact_id, source, day_bucket)` via `:Mem_ReinforceEvent` MERGE. A chatty agent can't pump a memory — one reinforce per source per day, no more.

Source labels look like `recall:agent=maas`, so different agents each get their own daily quota per fact. Real corroboration across agents strengthens; internal loops don't.

Module: `/srv/ecoagent/memory/ecoagent_memory/reinforce.py`. Wired into `Neo4jMemoryProvider.recall()` on a fresh session, non-fatal on error.

### Decay sweep

`ecoagent-memory-decay.timer` fires daily at 03:00 UTC. Formula:
```
idle_days = (now − last_reinforced_at) − grace_days
new_conf  = floor + (current − floor) × exp(−ln2 × idle_days / half_life_days)
```
Defaults: `grace_days = 3`, `half_life_weeks = 4`, `floor = 0.1`, `archive_threshold = 0.2`. Below archive → `invalidated_at = now` (soft archive; nothing deleted).

**Consensus interlock**: facts with `consensus_score ≥ 0.75` skip decay entirely — external corroboration overrides internal-use signal.

**Gate**: `DECAY_ENABLED = false` by default. Timer runs but the script short-circuits. Flip via dashboard when ready.

Script: `/srv/ecoagent/memory/scripts/decay_sweep.py`.

### Config knobs (all in `:Mem_Config`)

| Key | Default | Meaning |
| --- | --- | --- |
| `REINFORCE_ALPHA` | 0.15 | Impulse coefficient |
| `REINFORCE_CEILING` | 1.0 | Impulse upper bound |
| `REINFORCE_ENABLED` | true | Master reinforce switch |
| `DECAY_ENABLED` | **false** | Master decay switch (safe default) |
| `DECAY_GRACE_DAYS` | 3 | Days of grace before decay begins |
| `DECAY_HALF_LIFE_WEEKS` | 4 | Half-life for exponential decay |
| `DECAY_FLOOR` | 0.1 | Confidence floor (asymptote) |
| `ARCHIVE_THRESHOLD` | 0.2 | Below this → soft-archived |
| `CONSENSUS_SKIP_ABOVE` | 0.75 | Skip decay if consensus_score above this |

All flippable at runtime via the dashboard toggles.

---

## Phase 3.5 — Hermes chat integration (shipped 2026-07-28)

The `agnt-os-backend` service (the LLM chat backend behind [hermes.agnt.eco](https://hermes.agnt.eco/)) now pulls `:Mem_Fact` into every chat's system prompt.

Two prompt-build sites in `/srv/projects/agnt-os-backend/app.py` — `_prepare()` at line 502 and the tool-loop path at line 1041 — both call a new `_kg_recall(agent_id, message)` helper that:

1. Loads `narrator_memory.get_memory_provider()`
2. If enabled, calls `provider.recall(agent_handle=agent_id, context=message[:500], scope=BOTH, max_items=5)`
3. Returns the recalled `.text` strings (already-formatted triples)
4. Passes them to `prompts.build_system_prompt(..., recalled=recalled)`

The prompt now has a new section (`prompts.py`):
```
── SHARED MEMORY (recalled — prior context, do not treat as new evidence) ──
- Atlantic salmon swims through Maas
- beavers spread into the Netherlands mainly along the River Maas
- Eel is carried by Maas
- ...
```

This is *additive* to Hermes' existing per-user "WHAT YOU REMEMBER ABOUT THIS PERSON" SQLite memory. The two coexist:
- **Personal memory** (SQLite in `hermetic.db`): things the user told this agent, private to (session × agent).
- **Shared memory** (`:Mem_Fact`): what the KG has observed, cross-session, cross-agent.

Every hermes chat turn triggers a `recall(agent=X, context=message)` — and every hit fires a reinforce. So Hermes conversations are now a **primary driver** of what stays alive in memory: the more people talk to an agent about a topic, the more the underlying claims get reinforced.

Env: systemd drop-in at `/etc/systemd/system/agnt-os-backend.service.d/memory.conf` sets `ECOAGENT_MEMORY_PROVIDER=neo4j` and loads `/srv/agents/aegir/.env` for the Neo4j password.

Rollback: `systemctl edit --full agnt-os-backend.service` and remove the drop-in; or remove the `_kg_recall` call sites in `app.py` (byte-identical to pre-2026-07-28).

---

## Phase 4 — ecology-scoped manual promotion (next)

Some ripe memories deserve to leave the memory sandbox and enter the real verification pipeline. The point of Phase 4 is to build that narrow, deliberate, human-driven bridge — **not** to reopen the automatic one.

### Design

**Ripeness gate.** A memory is a promotion candidate only if all of:
- `reinforce_count ≥ K` (proposed: 5)
- `now − created_at ≥ N days` (proposed: 30)
- `invalidated_at IS NULL`
- `confidence ≥ 0.85` (already-live recall gate; ripeness inherits it)

The exact K and N numbers should be calibrated against real reinforce data after ~2 weeks. Too permissive → noise; too strict → nothing ever gets promoted. Watch the `/api/lever` feed.

**Ecology filter.** A memory is eligible for promotion only if one of:
- Subject or object resolves to a KG node with a label in `{:Species, :Watercourse, :Biome, :Ecosystem}`
- Predicate matches a hand-curated ecological verb list (`spread_into`, `swims_through`, `is_ecologically_present_in`, etc.)

Non-ecology memories stay in memory forever. That's fine.

**Promotion action.** A session-level manual step. UI: dashboard button on a ripe :Mem_Fact row → confirms → posts to `verification.agnt.eco`'s `create_candidate_fact` MCP endpoint. Never automated. Never LLM-driven.

Once the candidate exists on the verification side, it goes through the real multi-pass pipeline — nothing about the memory shortcut sneaks back in.

### Non-goals for Phase 4

- No automatic promotion.
- No LLM-driven ecology classification (we'll use the KG label check; if that misses, the memory just stays in memory).
- No re-write of the Phase 1 bridge. The `_bridge_gate.py` refusal stays. Phase 4 uses a **new** path: `promote_to_verification.py`, which mints candidates against `create_candidate_fact`, not `approve_candidate()`.

---

## Sources borrowed from

- [ProsusAI/prism](https://github.com/ProsusAI/prism) — reinforce/decay math, push vs pull channel split, config-first design.
- [getzep/graphiti](https://github.com/getzep/graphiti) — bi-temporal validity (`valid_from` / `invalidated_at`), supersede-not-overwrite for contradictions.
- [mem0ai/mem0](https://github.com/mem0ai/mem0) — the single-pass ADD-only extraction pivot.
- [letta-ai/letta](https://github.com/letta-ai/letta) — the three-scope split (episodic / semantic / procedural). Our current model is episodic + semantic; procedural is a possible future extension.

---

## Operator notes

- Config toggles all live in `:Mem_Config` and are flippable from the dashboard.
- Audit trail: every reinforce, every decay, every config flip writes a `:Mem_AuditLog` row. Query the log with `MATCH (a:Mem_AuditLog) RETURN a ORDER BY a.when DESC LIMIT 50`.
- The Hermes chat integration means the confidence lever now moves whenever anyone chats with an agent — you'll see `reinforce_count` climb in real time on the dashboard.
- The `text/plain` MIME on `/roadmap.md` is intentional so browsers render it as source.
