Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +116 -0
- .gitignore +1 -0
- REFACTORING_TODO.md +8 -5
- RELEASE_CHECKLIST.md +43 -65
- config.yaml +1 -1
- data/mnemocore_hnsw.faiss +0 -0
- data/mnemocore_hnsw_idmap.json +1 -0
- data/mnemocore_hnsw_vectors.npy +3 -0
- data/subconscious_evolution.json +2 -2
- docs/AGI_MEMORY_BLUEPRINT.md +713 -0
- integrations/README.md +233 -0
- integrations/aider/aider_wrap.sh +44 -0
- integrations/claude_code/CLAUDE_memory_snippet.md +38 -0
- integrations/claude_code/hooks/post_tool_store.py +96 -0
- integrations/claude_code/hooks/pre_session_inject.py +93 -0
- integrations/claude_code/hooks_config_fragment.json +28 -0
- integrations/claude_code/mcp_config.json +13 -0
- integrations/gemini_cli/GEMINI_memory_snippet.md +35 -0
- integrations/gemini_cli/gemini_wrap.sh +47 -0
- integrations/mnemo_bridge.py +177 -0
- integrations/setup.ps1 +158 -0
- integrations/setup.sh +299 -0
- integrations/universal/context_inject.sh +29 -0
- integrations/universal/store_session.sh +40 -0
- mnemocore_verify.py +136 -0
- src/mnemocore/agent_interface.py +145 -0
- src/mnemocore/api/main.py +365 -1
- src/mnemocore/core/agent_profile.py +65 -0
- src/mnemocore/core/anticipatory.py +51 -0
- src/mnemocore/core/binary_hdv.py +41 -31
- src/mnemocore/core/confidence.py +196 -0
- src/mnemocore/core/config.py +91 -11
- src/mnemocore/core/container.py +24 -0
- src/mnemocore/core/contradiction.py +336 -0
- src/mnemocore/core/cross_domain.py +211 -0
- src/mnemocore/core/emotional_tag.py +124 -0
- src/mnemocore/core/engine.py +230 -55
- src/mnemocore/core/episodic_store.py +144 -0
- src/mnemocore/core/forgetting_curve.py +233 -0
- src/mnemocore/core/hnsw_index.py +170 -140
- src/mnemocore/core/memory_model.py +132 -0
- src/mnemocore/core/meta_memory.py +70 -0
- src/mnemocore/core/node.py +20 -1
- src/mnemocore/core/prediction_store.py +294 -0
- src/mnemocore/core/preference_store.py +53 -0
- src/mnemocore/core/procedural_store.py +77 -0
- src/mnemocore/core/provenance.py +297 -0
- src/mnemocore/core/pulse.py +110 -0
- src/mnemocore/core/qdrant_store.py +38 -16
- src/mnemocore/core/semantic_consolidation.py +13 -0
.env.example
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore Environment Configuration
|
| 2 |
+
# ====================================
|
| 3 |
+
# Copy this file to .env and fill in the values.
|
| 4 |
+
# All variables can be overridden at runtime.
|
| 5 |
+
|
| 6 |
+
# ===========================================
|
| 7 |
+
# REQUIRED: API Security
|
| 8 |
+
# ===========================================
|
| 9 |
+
# API key for authentication (REQUIRED - must be set)
|
| 10 |
+
# Generate a secure key: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 11 |
+
HAIM_API_KEY=your-secure-api-key-here
|
| 12 |
+
|
| 13 |
+
# ===========================================
|
| 14 |
+
# Redis Configuration
|
| 15 |
+
# ===========================================
|
| 16 |
+
# Redis connection URL
|
| 17 |
+
# Format: redis://[username:password@]host:port/db
|
| 18 |
+
REDIS_URL=redis://redis:6379/0
|
| 19 |
+
|
| 20 |
+
# Redis stream key for pub/sub events
|
| 21 |
+
REDIS_STREAM_KEY=haim:subconscious
|
| 22 |
+
|
| 23 |
+
# Maximum Redis connections
|
| 24 |
+
REDIS_MAX_CONNECTIONS=10
|
| 25 |
+
|
| 26 |
+
# Redis socket timeout (seconds)
|
| 27 |
+
REDIS_SOCKET_TIMEOUT=5
|
| 28 |
+
|
| 29 |
+
# ===========================================
|
| 30 |
+
# Qdrant Configuration
|
| 31 |
+
# ===========================================
|
| 32 |
+
# Qdrant connection URL
|
| 33 |
+
QDRANT_URL=http://qdrant:6333
|
| 34 |
+
|
| 35 |
+
# Collection names
|
| 36 |
+
QDRANT_COLLECTION_HOT=haim_hot
|
| 37 |
+
QDRANT_COLLECTION_WARM=haim_warm
|
| 38 |
+
|
| 39 |
+
# ===========================================
|
| 40 |
+
# Server Configuration
|
| 41 |
+
# ===========================================
|
| 42 |
+
# Host to bind the server
|
| 43 |
+
HOST=0.0.0.0
|
| 44 |
+
|
| 45 |
+
# Port to listen on
|
| 46 |
+
PORT=8100
|
| 47 |
+
|
| 48 |
+
# Number of uvicorn workers (1 recommended for stateful apps)
|
| 49 |
+
WORKERS=1
|
| 50 |
+
|
| 51 |
+
# ===========================================
|
| 52 |
+
# Logging Configuration
|
| 53 |
+
# ===========================================
|
| 54 |
+
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
| 55 |
+
LOG_LEVEL=INFO
|
| 56 |
+
|
| 57 |
+
# Enable structured JSON logging
|
| 58 |
+
STRUCTURED_LOGGING=true
|
| 59 |
+
|
| 60 |
+
# ===========================================
|
| 61 |
+
# Observability (Prometheus)
|
| 62 |
+
# ===========================================
|
| 63 |
+
# Port for Prometheus metrics
|
| 64 |
+
METRICS_PORT=9090
|
| 65 |
+
|
| 66 |
+
# ===========================================
|
| 67 |
+
# Memory Tier Configuration
|
| 68 |
+
# ===========================================
|
| 69 |
+
# Hot tier max memories
|
| 70 |
+
HOT_MAX_MEMORIES=2000
|
| 71 |
+
|
| 72 |
+
# Warm tier max memories
|
| 73 |
+
WARM_MAX_MEMORIES=100000
|
| 74 |
+
|
| 75 |
+
# LTP decay rate
|
| 76 |
+
LTP_DECAY_LAMBDA=0.01
|
| 77 |
+
|
| 78 |
+
# ===========================================
|
| 79 |
+
# GPU Configuration (Optional)
|
| 80 |
+
# ===========================================
|
| 81 |
+
# Enable GPU acceleration
|
| 82 |
+
GPU_ENABLED=false
|
| 83 |
+
|
| 84 |
+
# CUDA device (e.g., cuda:0)
|
| 85 |
+
GPU_DEVICE=cuda:0
|
| 86 |
+
|
| 87 |
+
# ===========================================
|
| 88 |
+
# MCP Bridge Configuration (Optional)
|
| 89 |
+
# ===========================================
|
| 90 |
+
# Enable MCP bridge
|
| 91 |
+
MCP_ENABLED=false
|
| 92 |
+
|
| 93 |
+
# MCP transport: stdio, tcp
|
| 94 |
+
MCP_TRANSPORT=stdio
|
| 95 |
+
|
| 96 |
+
# MCP host and port (for TCP transport)
|
| 97 |
+
MCP_HOST=127.0.0.1
|
| 98 |
+
MCP_PORT=8110
|
| 99 |
+
|
| 100 |
+
# ===========================================
|
| 101 |
+
# CORS Configuration (Optional)
|
| 102 |
+
# ===========================================
|
| 103 |
+
# Allowed CORS origins (comma-separated)
|
| 104 |
+
# CORS_ORIGINS=http://localhost:3000,https://example.com
|
| 105 |
+
|
| 106 |
+
# ===========================================
|
| 107 |
+
# Rate Limiting (Optional)
|
| 108 |
+
# ===========================================
|
| 109 |
+
# Enable rate limiting
|
| 110 |
+
RATE_LIMIT_ENABLED=true
|
| 111 |
+
|
| 112 |
+
# Requests per window
|
| 113 |
+
RATE_LIMIT_REQUESTS=100
|
| 114 |
+
|
| 115 |
+
# Window size in seconds
|
| 116 |
+
RATE_LIMIT_WINDOW=60
|
.gitignore
CHANGED
|
@@ -38,6 +38,7 @@ ENV/
|
|
| 38 |
htmlcov/
|
| 39 |
.tox/
|
| 40 |
.nox/
|
|
|
|
| 41 |
|
| 42 |
# Data (runtime generated)
|
| 43 |
data/memory.jsonl
|
|
|
|
| 38 |
htmlcov/
|
| 39 |
.tox/
|
| 40 |
.nox/
|
| 41 |
+
.hypothesis/
|
| 42 |
|
| 43 |
# Data (runtime generated)
|
| 44 |
data/memory.jsonl
|
REFACTORING_TODO.md
CHANGED
|
@@ -26,7 +26,7 @@ Status för kodoptimering inför kommande funktionalitet.
|
|
| 26 |
---
|
| 27 |
|
| 28 |
### 2. Ofullständiga features
|
| 29 |
-
**Status:**
|
| 30 |
|
| 31 |
**Problem:**
|
| 32 |
- Flera TODOs i produktionskod som lämnats oimplementerade
|
|
@@ -44,8 +44,10 @@ Line 320: # TODO: orchestrate_orch_or() not implemented
|
|
| 44 |
```
|
| 45 |
|
| 46 |
**Åtgärd:**
|
| 47 |
-
-
|
| 48 |
-
-
|
|
|
|
|
|
|
| 49 |
|
| 50 |
---
|
| 51 |
|
|
@@ -165,7 +167,7 @@ Import-stilen följer redan rekommenderad Python-praxis. Ingen åtgärd behövs.
|
|
| 165 |
## Förbättra testtäckning
|
| 166 |
|
| 167 |
```bash
|
| 168 |
-
pytest --cov=
|
| 169 |
```
|
| 170 |
|
| 171 |
Kör för att identifiera luckor i testtäckningen.
|
|
@@ -187,13 +189,14 @@ Kör för att identifiera luckor i testtäckningen.
|
|
| 187 |
## Framsteg
|
| 188 |
|
| 189 |
- [x] Punkt 1: HDV-konsolidering ✅
|
| 190 |
-
- [
|
| 191 |
- [ ] Punkt 3: Felhantering
|
| 192 |
- [ ] Punkt 4: Singleton-reduktion 📋 Roadmap
|
| 193 |
- [ ] Punkt 5: Stora funktioner 📋 Roadmap
|
| 194 |
- [x] Punkt 6: Circuit breakers ✅
|
| 195 |
- [x] Punkt 7: Hårkodade sökvägar ✅
|
| 196 |
- [x] Punkt 8: Import-stil ✅ (redan konsekvent)
|
|
|
|
| 197 |
|
| 198 |
---
|
| 199 |
|
|
|
|
| 26 |
---
|
| 27 |
|
| 28 |
### 2. Ofullständiga features
|
| 29 |
+
**Status:** ✅ Verified / Resolved
|
| 30 |
|
| 31 |
**Problem:**
|
| 32 |
- Flera TODOs i produktionskod som lämnats oimplementerade
|
|
|
|
| 44 |
```
|
| 45 |
|
| 46 |
**Åtgärd:**
|
| 47 |
+
- `superposition_query`: Implemented as `_superposition_query` in `HAIMLLMIntegrator`.
|
| 48 |
+
- `orchestrate_orch_or`: Implemented in `HAIMEngine`.
|
| 49 |
+
- LLM Calls: Code now supports generic providers (OpenAI, Gemini via `google.generativeai`, etc) with safe fallbacks (`_mock_llm_response`) if not configured.
|
| 50 |
+
- `_concept_to_memory_id`: Implemented in `MultiAgentHAIM`.
|
| 51 |
|
| 52 |
---
|
| 53 |
|
|
|
|
| 167 |
## Förbättra testtäckning
|
| 168 |
|
| 169 |
```bash
|
| 170 |
+
pytest --cov=mnemocore --cov-report=html
|
| 171 |
```
|
| 172 |
|
| 173 |
Kör för att identifiera luckor i testtäckningen.
|
|
|
|
| 189 |
## Framsteg
|
| 190 |
|
| 191 |
- [x] Punkt 1: HDV-konsolidering ✅
|
| 192 |
+
- [x] Punkt 2: Ofullständiga features ✅
|
| 193 |
- [ ] Punkt 3: Felhantering
|
| 194 |
- [ ] Punkt 4: Singleton-reduktion 📋 Roadmap
|
| 195 |
- [ ] Punkt 5: Stora funktioner 📋 Roadmap
|
| 196 |
- [x] Punkt 6: Circuit breakers ✅
|
| 197 |
- [x] Punkt 7: Hårkodade sökvägar ✅
|
| 198 |
- [x] Punkt 8: Import-stil ✅ (redan konsekvent)
|
| 199 |
+
- [x] Test-suite import fixad (src. -> mnemocore.) ✅
|
| 200 |
|
| 201 |
---
|
| 202 |
|
RELEASE_CHECKLIST.md
CHANGED
|
@@ -1,59 +1,44 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
## Status:
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
-
##
|
| 8 |
|
| 9 |
- [x] LICENSE file (MIT)
|
| 10 |
- [x] .gitignore created
|
| 11 |
- [x] data/memory.jsonl removed (no stored memories)
|
| 12 |
- [x] No leaked API keys or credentials
|
| 13 |
-
- [x]
|
|
|
|
|
|
|
| 14 |
|
| 15 |
---
|
| 16 |
|
| 17 |
-
##
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
-
```
|
| 23 |
-
|
| 24 |
-
```
|
| 25 |
-
**Impact:** Warm→Cold tier consolidation limited
|
| 26 |
-
**Workaround:** Hot→Warm works, Cold is filesystem-based
|
| 27 |
-
**Fix:** Implement Qdrant batch scroll API for full archival
|
| 28 |
|
| 29 |
-
|
| 30 |
-
```python
|
| 31 |
-
# TODO: Phase 3.5 Qdrant search for WARM/COLD
|
| 32 |
-
```
|
| 33 |
-
**Impact:** Query only searches HOT tier currently
|
| 34 |
-
**Workaround:** Promote memories before querying
|
| 35 |
-
**Fix:** Add async Qdrant similarity search in query()
|
| 36 |
-
|
| 37 |
-
### 3. `src/llm_integration.py:55-57, 128-129`
|
| 38 |
-
```python
|
| 39 |
-
# TODO: Call Gemini 3 Pro via OpenClaw API
|
| 40 |
-
reconstruction = "TODO: Call Gemini 3 Pro"
|
| 41 |
-
```
|
| 42 |
-
**Impact:** LLM reconstruction not functional
|
| 43 |
-
**Workaround:** Raw vector similarity works
|
| 44 |
-
**Fix:** Implement LLM client or make it pluggable
|
| 45 |
|
| 46 |
-
##
|
| 47 |
-
|
| 48 |
-
#
|
| 49 |
-
|
| 50 |
-
**
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
|
| 54 |
---
|
| 55 |
|
| 56 |
-
##
|
| 57 |
|
| 58 |
### Before git push:
|
| 59 |
|
|
@@ -62,7 +47,8 @@ reconstruction = "TODO: Call Gemini 3 Pro"
|
|
| 62 |
rm -rf .pytest_cache __pycache__ */__pycache__ *.pyc
|
| 63 |
|
| 64 |
# 2. Verify tests pass
|
| 65 |
-
|
|
|
|
| 66 |
|
| 67 |
# 3. Verify import works
|
| 68 |
python -c "from mnemocore.core.engine import HAIMEngine; print('OK')"
|
|
@@ -72,54 +58,46 @@ grep -r "sk-" src/ --include="*.py"
|
|
| 72 |
grep -r "api_key.*=" src/ --include="*.py" | grep -v "api_key=\"\""
|
| 73 |
|
| 74 |
# 5. Initialize fresh data files
|
|
|
|
|
|
|
| 75 |
touch data/memory.jsonl data/codebook.json data/concepts.json data/synapses.json
|
| 76 |
```
|
| 77 |
|
| 78 |
### Update README.md:
|
| 79 |
|
| 80 |
-
- [
|
| 81 |
-
- [
|
| 82 |
-
- [
|
| 83 |
-
- [
|
| 84 |
|
| 85 |
---
|
| 86 |
|
| 87 |
-
##
|
| 88 |
|
| 89 |
```bash
|
| 90 |
-
cd /home/dev-robin/Desktop/mnemocore
|
| 91 |
-
|
| 92 |
# Verify clean state
|
| 93 |
git status
|
| 94 |
|
| 95 |
-
# Stage public files
|
| 96 |
-
git add LICENSE .gitignore RELEASE_CHECKLIST.md
|
| 97 |
-
git add src/ tests/ config.yaml requirements.txt pytest.ini
|
| 98 |
-
git add README.md
|
| 99 |
-
git add data/.gitkeep # If exists
|
| 100 |
|
| 101 |
# Commit
|
| 102 |
-
git commit -m "
|
| 103 |
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
|
| 106 |
# Tag
|
| 107 |
-
git tag -a v0.
|
| 108 |
|
| 109 |
-
# Push
|
| 110 |
git push origin main --tags
|
| 111 |
```
|
| 112 |
|
| 113 |
---
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
- [ ] Create GitHub repository
|
| 118 |
-
- [ ] Add repository topics: `vsa`, `holographic-memory`, `active-inference`, `vector-symbolic-architecture`
|
| 119 |
-
- [ ] Enable GitHub Issues for community feedback
|
| 120 |
-
- [ ] Publish whitepaper/blog post
|
| 121 |
-
|
| 122 |
-
---
|
| 123 |
-
|
| 124 |
-
*Generated: 2026-02-15*
|
| 125 |
-
|
|
|
|
| 1 |
+
# MnemoCore Public Beta Release Checklist
|
| 2 |
|
| 3 |
+
## Status: 🟢 GREEN
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## ✅ Completed
|
| 8 |
|
| 9 |
- [x] LICENSE file (MIT)
|
| 10 |
- [x] .gitignore created
|
| 11 |
- [x] data/memory.jsonl removed (no stored memories)
|
| 12 |
- [x] No leaked API keys or credentials
|
| 13 |
+
- [x] 377 unit tests passing (Coverage increased from 82)
|
| 14 |
+
- [x] Test suite import paths fixed (`src.` -> `mnemocore.`)
|
| 15 |
+
- [x] Critical TODOs addressed or verified as safe
|
| 16 |
|
| 17 |
---
|
| 18 |
|
| 19 |
+
## 🔧 Resolved/Verified Items
|
| 20 |
|
| 21 |
+
The following items were previously listed as known limitations but have been verified as resolved or robustly handled:
|
| 22 |
|
| 23 |
+
1. **Qdrant Consolidation:** `src/core/tier_manager.py` implements `consolidate_warm_to_cold` with full Qdrant batch scrolling.
|
| 24 |
+
2. **Qdrant Search:** `src/core/engine.py` query pipeline correctly delegates to `TierManager.search` which queries Qdrant for WARM tier results.
|
| 25 |
+
3. **LLM Integration:** `src/llm_integration.py` includes `_mock_llm_response` fallbacks when no provider is configured, ensuring stability even without API keys.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
## 📝 Remaining Roadmap Items (Non-Blocking)
|
| 30 |
+
|
| 31 |
+
### 1. `src/llm_integration.py` - Advanced LLM Features
|
| 32 |
+
- **Status:** Functional with generic providers.
|
| 33 |
+
- **Task:** Implement specific "OpenClaw" or "Gemini 3 Pro" adapters if required in future. Current implementation supports generic OpenAI/Anthropic/Gemini/Ollama clients.
|
| 34 |
+
|
| 35 |
+
### 2. Full Notion Integration
|
| 36 |
+
- **Status:** Not currently present in `src/mnemocore`.
|
| 37 |
+
- **Task:** Re-introduce `nightlab` or similar module if Notion support is needed in Phase 5.
|
| 38 |
|
| 39 |
---
|
| 40 |
|
| 41 |
+
## 📋 Pre-Release Actions
|
| 42 |
|
| 43 |
### Before git push:
|
| 44 |
|
|
|
|
| 47 |
rm -rf .pytest_cache __pycache__ */__pycache__ *.pyc
|
| 48 |
|
| 49 |
# 2. Verify tests pass
|
| 50 |
+
# Note: Ensure you are in the environment where mnemocore is installed
|
| 51 |
+
python -m pytest
|
| 52 |
|
| 53 |
# 3. Verify import works
|
| 54 |
python -c "from mnemocore.core.engine import HAIMEngine; print('OK')"
|
|
|
|
| 58 |
grep -r "api_key.*=" src/ --include="*.py" | grep -v "api_key=\"\""
|
| 59 |
|
| 60 |
# 5. Initialize fresh data files
|
| 61 |
+
# Ensure data directory exists
|
| 62 |
+
mkdir -p data
|
| 63 |
touch data/memory.jsonl data/codebook.json data/concepts.json data/synapses.json
|
| 64 |
```
|
| 65 |
|
| 66 |
### Update README.md:
|
| 67 |
|
| 68 |
+
- [x] Add: "Beta Release - See RELEASE_CHECKLIST.md for known limitations"
|
| 69 |
+
- [x] Add: "Installation" section with `pip install -r requirements.txt`
|
| 70 |
+
- [x] Add: "Quick Start" example
|
| 71 |
+
- [x] Add: "Roadmap" section linking TODOs above
|
| 72 |
|
| 73 |
---
|
| 74 |
|
| 75 |
+
## 🚀 Release Command Sequence
|
| 76 |
|
| 77 |
```bash
|
|
|
|
|
|
|
| 78 |
# Verify clean state
|
| 79 |
git status
|
| 80 |
|
| 81 |
+
# Stage public files
|
| 82 |
+
git add LICENSE .gitignore RELEASE_CHECKLIST.md REFACTORING_TODO.md
|
| 83 |
+
git add src/ tests/ config.yaml requirements.txt pytest.ini pyproject.toml
|
| 84 |
+
git add README.md docker-compose.yml
|
| 85 |
+
git add data/.gitkeep # If exists
|
| 86 |
|
| 87 |
# Commit
|
| 88 |
+
git commit -m "Release Candidate: All tests passing, critical TODOs resolved.
|
| 89 |
|
| 90 |
+
- Fixed test suite import paths (src -> mnemocore)
|
| 91 |
+
- Verified Qdrant consolidation and search implementation
|
| 92 |
+
- Confirmed LLM integration fallbacks"
|
| 93 |
|
| 94 |
# Tag
|
| 95 |
+
git tag -a v0.5.0-beta -m "Public Beta Release"
|
| 96 |
|
| 97 |
+
# Push
|
| 98 |
git push origin main --tags
|
| 99 |
```
|
| 100 |
|
| 101 |
---
|
| 102 |
|
| 103 |
+
*Updated: 2026-02-18*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
config.yaml
CHANGED
|
@@ -86,7 +86,7 @@ haim:
|
|
| 86 |
|
| 87 |
# MCP (Model Context Protocol) bridge
|
| 88 |
mcp:
|
| 89 |
-
enabled:
|
| 90 |
transport: "stdio" # "stdio" recommended for local MCP clients
|
| 91 |
host: "127.0.0.1"
|
| 92 |
port: 8110
|
|
|
|
| 86 |
|
| 87 |
# MCP (Model Context Protocol) bridge
|
| 88 |
mcp:
|
| 89 |
+
enabled: true
|
| 90 |
transport: "stdio" # "stdio" recommended for local MCP clients
|
| 91 |
host: "127.0.0.1"
|
| 92 |
port: 8110
|
data/mnemocore_hnsw.faiss
ADDED
|
Binary file (6.95 kB). View file
|
|
|
data/mnemocore_hnsw_idmap.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"id_map": ["test_node_1", "test_node_2", "test_node_0", "test_node_1", "test_node_2", "test_node_3", "test_node_4", "test_node_5", "test_node_6", "test_node_7", "test_node_8", "test_node_9", "f130a982-5936-4943-ac23-e1f4ad997d25", "19c168e1-c1c0-4074-bdc6-fa6c37bf5009", "b7ad4001-e20c-4f6d-8db7-eca98fbde4ea", "f22c991c-6336-4b11-867a-d141724253ff", "db802f41-0c60-4e62-8595-4864058924b2", "835b80bc-c2b1-4ecf-b0af-ec0442ec87bf", "d594c442-d4b5-4246-9c62-0fb605f235ca", "a467b3f5-d899-4b95-9e62-f7012171ec8d", "fdb78500-49fc-4cf3-a269-b023ba8fa11c", "21d4f6d9-72a6-4dd8-b83e-0d09ef57c680", "19f52711-1ae5-4c29-a0be-2618b162dace", "8442e4e6-cf0f-4f54-b35a-0ce6169fd2f3", "70010aa2-0d82-42bc-96f7-9b99374e24cd", "3cdb7cc9-44ce-4328-9d9a-44dc98d088eb", "1f12c17a-b4ef-4f21-b7f3-3df51196e7f6", "ac29dda1-fd96-4880-9141-01bc46ef93b1", "e0644808-6d24-470d-a667-867fe74d78e2", null, "836f116d-f47f-493b-9ac7-47db84e4a4f9", "fc37dc28-9e3a-484f-a65d-8bf46ee95dbb", "1d3fa01a-0710-453e-8738-4c5be348cd1b", "2776ca2a-429b-491d-9b8a-7061c9b14166", "a9cf8418-d188-41a8-b20d-f2431f790414", "eaf66116-a5ab-436a-a6c3-031d303cb6bd", "474ec49c-1f57-42ac-94e7-3d07d233780f", "a1a19beb-a5f8-4b31-9fe7-0db5b00415c6", "bad8ef94-7313-4bbf-a807-739ff8dcb5c2", "2f4bd760-18b2-4dc1-9dcb-3a6adb77ef23", "0c017bbc-e3ca-4ca0-be83-70b76c0b1be1", "3823dfcd-798d-4e59-a09e-113cc4ec6384", "2682da73-2827-4d68-83b5-1d88f92712ae", "280f0ca4-0930-4452-af29-392d2f99ba7b", "521f09c3-ed6c-4d0f-b04d-97362cb4a94d", "f338c239-dcbd-45ab-ab2b-4192084f5492", "2f011bbd-2a40-4666-a566-4117e8c5ed8e", "dae2e9c6-0c36-4536-b2ff-b59f5443c6fe", "d6028558-d803-454f-8a6f-f82463c44f18", "4c64fe2a-4194-494e-81a2-122911f6cd79", null, "n1", "n2", "n1"], "use_hnsw": false, "stale_count": 2}
|
data/mnemocore_hnsw_vectors.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:00400554e796c7717c4def78feb30b5ba5360efed6a37bd35f71738695ac6522
|
| 3 |
+
size 7040
|
data/subconscious_evolution.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
-
"updated_at": "2026-02-
|
| 3 |
-
"cycle_count":
|
| 4 |
"insights_generated": 0,
|
| 5 |
"current_cycle_interval": 1,
|
| 6 |
"schedule": {
|
|
|
|
| 1 |
{
|
| 2 |
+
"updated_at": "2026-02-21T21:14:22.859465+00:00",
|
| 3 |
+
"cycle_count": 168,
|
| 4 |
"insights_generated": 0,
|
| 5 |
"current_cycle_interval": 1,
|
| 6 |
"schedule": {
|
docs/AGI_MEMORY_BLUEPRINT.md
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore AGI Memory Blueprint
|
| 2 |
+
### Toward a True Cognitive Memory Substrate for Agentic Systems
|
| 3 |
+
|
| 4 |
+
> This document defines the **Phase 5 “AGI Memory” architecture** for MnemoCore – transforming it from a high‑end hyperdimensional memory engine into a **general cognitive substrate** for autonomous AI agents.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 0. Goals & Non‑Goals
|
| 9 |
+
|
| 10 |
+
### 0.1 Core Goals
|
| 11 |
+
|
| 12 |
+
- Provide a **plug‑and‑play cognitive memory system** that any agent framework can mount as its “mind”:
|
| 13 |
+
- Solves **context window** limits by offloading long‑term structure and recall.
|
| 14 |
+
- Solves **memory management** by autonomous consolidation, forgetting, and self‑repair.
|
| 15 |
+
- Provides **new thoughts, associations and suggestions** rather than only retrieval.
|
| 16 |
+
- Implement an explicit, formal model of:
|
| 17 |
+
- **Working / Short‑Term Memory (WM/STM)**
|
| 18 |
+
- **Episodic Memory**
|
| 19 |
+
- **Semantic Memory**
|
| 20 |
+
- **Procedural / Skill Memory**
|
| 21 |
+
- **Meta‑Memory & Self‑Model**
|
| 22 |
+
- Maintain:
|
| 23 |
+
- `pip install mnemocore` **zero‑infra dev mode** (SQLite / in‑process vector store).
|
| 24 |
+
- Full infra path (Redis, Qdrant, k8s, MCP, OpenClaw live memory integration).[cite:436][cite:437]
|
| 25 |
+
- Provide **clean public APIs** (Python + HTTP + MCP) that:
|
| 26 |
+
- Give agents a minimal but powerful surface: `observe / recall / reflect / propose_change`.
|
| 27 |
+
- Are stable enough to build higher‑level frameworks on (LangGraph, AutoGen, OpenAI Agents, OpenClaw, custom stacks).
|
| 28 |
+
|
| 29 |
+
### 0.2 Non‑Goals
|
| 30 |
+
|
| 31 |
+
- MnemoCore is **not**:
|
| 32 |
+
- En LLM eller policy‑generator.
|
| 33 |
+
- En komplett agentram – det är **minnet + kognitiva processer**.
|
| 34 |
+
- MnemoCore ska **inte** hårdkoda specifika LLM‑providers.
|
| 35 |
+
- LLM används via abstraherad integration (`SubconsciousAI`, `LLMIntegration`) så att byte av motor är trivialt.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## 1. Cognitive Architecture Overview
|
| 40 |
+
|
| 41 |
+
### 1.1 High‑Level Mental Model
|
| 42 |
+
|
| 43 |
+
Systemet ska exponera en internt konsekvent kognitiv modell:
|
| 44 |
+
|
| 45 |
+
- **Working Memory (WM)**
|
| 46 |
+
- Korttidsbuffert per agent / samtal / uppgift.
|
| 47 |
+
- Håller aktuella mål, senaste steg, delresultat.
|
| 48 |
+
- Living in RAM, med explicit API.
|
| 49 |
+
|
| 50 |
+
- **Episodic Memory (EM)**
|
| 51 |
+
- Sekvens av *episodes*: “agent X gjorde Y i kontext Z och fick utfallet U”.
|
| 52 |
+
- Tidsstämplad, med länkar mellan episoder (kedjor).
|
| 53 |
+
- Riktad mot “vad hände när, i vilken ordning”.
|
| 54 |
+
|
| 55 |
+
- **Semantic Memory (SM)**
|
| 56 |
+
- Abstraherade, konsoliderade representationer (concepts, prototypes).
|
| 57 |
+
- Sammanfattningar av hundratals episoder → en “semantic anchor”.
|
| 58 |
+
- Bra för svar på “vad vet jag generellt om X?”.
|
| 59 |
+
|
| 60 |
+
- **Procedural Memory (PM)**
|
| 61 |
+
- Skills, planer, recept: “för att lösa typ‑X problem, gör följande steg …”.
|
| 62 |
+
- Kan hålla både mänsklig läsbar text och exekverbar kod (snippets, tools).
|
| 63 |
+
|
| 64 |
+
- **Meta‑Memory (MM)**
|
| 65 |
+
- Självmodell för MnemoCore själv: prestanda, reliability, konfiguration, kända svagheter.
|
| 66 |
+
- Driver **självförbättringsloopen**.
|
| 67 |
+
|
| 68 |
+
Alla dessa lever ovanpå din befintliga HDV/VSA‑kärna, tier manager, synapse index, subconscious loop osv.[cite:436][cite:437]
|
| 69 |
+
|
| 70 |
+
### 1.2 New Core Services
|
| 71 |
+
|
| 72 |
+
Föreslagna nya Python‑moduler (under `src/mnemocore/core`):
|
| 73 |
+
|
| 74 |
+
- `memory_model.py`
|
| 75 |
+
- Typed dataklasser för WM/EM/SM/PM/MM entities.
|
| 76 |
+
- `working_memory.py`
|
| 77 |
+
- WM implementation per agent/task med snabb caching.
|
| 78 |
+
- `episodic_store.py`
|
| 79 |
+
- Episodisk tidsserie, sekvens‑API.
|
| 80 |
+
- `semantic_store.py`
|
| 81 |
+
- Wrapper ovanpå befintlig vektorstore (Qdrant/HDV/HNSW) + consolidation hooks.
|
| 82 |
+
- `procedural_store.py`
|
| 83 |
+
- Lagret för skills, scripts, tool definitions.
|
| 84 |
+
- `meta_memory.py`
|
| 85 |
+
- Självmodell, logik för self‑improvement proposals.
|
| 86 |
+
- `pulse.py`
|
| 87 |
+
- “Heartbeat”‑loop: driver subtle thoughts, consolidation ticks, gap detection, self‑reflection.
|
| 88 |
+
- `agent_profile.py`
|
| 89 |
+
- Persistent profil per agent: preferenser, styrkor/svagheter, quirks.
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## 2. Data Model
|
| 94 |
+
|
| 95 |
+
### 2.1 Working Memory (WM)
|
| 96 |
+
|
| 97 |
+
```python
|
| 98 |
+
# src/mnemocore/core/memory_model.py
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class WorkingMemoryItem:
|
| 102 |
+
id: str
|
| 103 |
+
agent_id: str
|
| 104 |
+
created_at: datetime
|
| 105 |
+
ttl_seconds: int
|
| 106 |
+
content: str
|
| 107 |
+
kind: Literal["thought", "observation", "goal", "plan_step", "meta"]
|
| 108 |
+
importance: float #
|
| 109 |
+
tags: list[str]
|
| 110 |
+
hdv: BinaryHDV | None
|
| 111 |
+
|
| 112 |
+
@dataclass
|
| 113 |
+
class WorkingMemoryState:
|
| 114 |
+
agent_id: str
|
| 115 |
+
items: list[WorkingMemoryItem]
|
| 116 |
+
max_items: int
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
**Invariantes:**
|
| 120 |
+
|
| 121 |
+
- WM är *liten* (t.ex. 32–128 items per agent).
|
| 122 |
+
- WM ligger primärt i RAM; kan serialiseras till Redis/SQLite för persistens.
|
| 123 |
+
- Access är O(1)/O(log n); LRU + importance‑vägning vid evicering.
|
| 124 |
+
|
| 125 |
+
### 2.2 Episodic Memory (EM)
|
| 126 |
+
|
| 127 |
+
```python
|
| 128 |
+
@dataclass
|
| 129 |
+
class EpisodeEvent:
|
| 130 |
+
timestamp: datetime
|
| 131 |
+
kind: Literal["observation", "action", "thought", "reward", "error"]
|
| 132 |
+
content: str
|
| 133 |
+
metadata: dict[str, Any]
|
| 134 |
+
hdv: BinaryHDV
|
| 135 |
+
|
| 136 |
+
@dataclass
|
| 137 |
+
class Episode:
|
| 138 |
+
id: str
|
| 139 |
+
agent_id: str
|
| 140 |
+
started_at: datetime
|
| 141 |
+
ended_at: datetime | None
|
| 142 |
+
goal: str | None
|
| 143 |
+
context: str | None # project / environment
|
| 144 |
+
events: list[EpisodeEvent]
|
| 145 |
+
outcome: Literal["success", "failure", "partial", "unknown"]
|
| 146 |
+
reward: float | None
|
| 147 |
+
links_prev: list[str] # previous episode IDs
|
| 148 |
+
links_next: list[str] # next episode IDs
|
| 149 |
+
ltp_strength: float
|
| 150 |
+
reliability: float
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
### 2.3 Semantic Memory (SM)
|
| 154 |
+
|
| 155 |
+
```python
|
| 156 |
+
@dataclass
|
| 157 |
+
class SemanticConcept:
|
| 158 |
+
id: str
|
| 159 |
+
label: str # "fastapi-request-validation"
|
| 160 |
+
description: str
|
| 161 |
+
tags: list[str]
|
| 162 |
+
prototype_hdv: BinaryHDV
|
| 163 |
+
support_episode_ids: list[str] # episodes som gav upphov
|
| 164 |
+
reliability: float
|
| 165 |
+
last_updated_at: datetime
|
| 166 |
+
metadata: dict[str, Any]
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
Kopplas direkt mot consolidation/semantic_consolidation + codebook/immunology.[cite:436][cite:437]
|
| 170 |
+
|
| 171 |
+
### 2.4 Procedural Memory (PM)
|
| 172 |
+
|
| 173 |
+
```python
|
| 174 |
+
@dataclass
|
| 175 |
+
class ProcedureStep:
|
| 176 |
+
order: int
|
| 177 |
+
instruction: str
|
| 178 |
+
code_snippet: str | None
|
| 179 |
+
tool_call: dict[str, Any] | None
|
| 180 |
+
|
| 181 |
+
@dataclass
|
| 182 |
+
class Procedure:
|
| 183 |
+
id: str
|
| 184 |
+
name: str
|
| 185 |
+
description: str
|
| 186 |
+
created_by_agent: str | None
|
| 187 |
+
created_at: datetime
|
| 188 |
+
updated_at: datetime
|
| 189 |
+
steps: list[ProcedureStep]
|
| 190 |
+
trigger_pattern: str # "if user asks about X and Y"
|
| 191 |
+
success_count: int
|
| 192 |
+
failure_count: int
|
| 193 |
+
reliability: float
|
| 194 |
+
tags: list[str]
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
Procedurer kan genereras av LLM (SubconsciousAI), testas i episodiskt minne, och sedan promotas/demotas med reliability‑loop.
|
| 198 |
+
|
| 199 |
+
### 2.5 Meta‑Memory (MM)
|
| 200 |
+
|
| 201 |
+
```python
|
| 202 |
+
@dataclass
|
| 203 |
+
class SelfMetric:
|
| 204 |
+
name: str # "hot_tier_hit_rate", "avg_query_latency_ms"
|
| 205 |
+
value: float
|
| 206 |
+
window: str # "5m", "1h", "24h"
|
| 207 |
+
updated_at: datetime
|
| 208 |
+
|
| 209 |
+
@dataclass
|
| 210 |
+
class SelfImprovementProposal:
|
| 211 |
+
id: str
|
| 212 |
+
created_at: datetime
|
| 213 |
+
author: Literal["system", "agent", "human"]
|
| 214 |
+
title: str
|
| 215 |
+
description: str
|
| 216 |
+
rationale: str
|
| 217 |
+
expected_effect: str
|
| 218 |
+
status: Literal["pending", "accepted", "rejected", "implemented"]
|
| 219 |
+
metadata: dict[str, Any]
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
MM lagras delvis i vanlig storage (SM/PM) men har egen API‑yta.
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## 3. Service Layer Design
|
| 227 |
+
|
| 228 |
+
### 3.1 Working Memory Service
|
| 229 |
+
|
| 230 |
+
**Fil:** `src/mnemocore/core/working_memory.py`
|
| 231 |
+
|
| 232 |
+
Ansvar:
|
| 233 |
+
|
| 234 |
+
- Hålla en per‑agent WM‑state.
|
| 235 |
+
- Explicita operationer:
|
| 236 |
+
- `push_item(agent_id, item: WorkingMemoryItem)`
|
| 237 |
+
- `get_state(agent_id) -> WorkingMemoryState`
|
| 238 |
+
- `clear(agent_id)`
|
| 239 |
+
- `prune(agent_id)` – enligt importance + LRU.
|
| 240 |
+
- Integrera med engine/query:
|
| 241 |
+
- Vid varje query: WM får en snapshot av top‑K resultat som “context items”.
|
| 242 |
+
- Vid svar: agent kan markera vilka items som var relevanta.
|
| 243 |
+
|
| 244 |
+
### 3.2 Episodic Store Service
|
| 245 |
+
|
| 246 |
+
**Fil:** `src/mnemocore/core/episodic_store.py`
|
| 247 |
+
|
| 248 |
+
Ansvar:
|
| 249 |
+
|
| 250 |
+
- Skapa och uppdatera Episodes:
|
| 251 |
+
- `start_episode(agent_id, goal, context) -> episode_id`
|
| 252 |
+
- `append_event(episode_id, kind, content, metadata)`
|
| 253 |
+
- `end_episode(episode_id, outcome, reward)`
|
| 254 |
+
- Query:
|
| 255 |
+
- `get_episode(id)`
|
| 256 |
+
- `get_recent(agent_id, limit, context)`
|
| 257 |
+
- `find_similar_episodes(hdv, top_k)`
|
| 258 |
+
- Koppling till befintlig HDV + tier manager:
|
| 259 |
+
- Varje Episode får en “episode_hdv” (bundle över event‑HDVs).
|
| 260 |
+
- LTP + reliabilitet följer samma formel som övrig LTP.
|
| 261 |
+
|
| 262 |
+
### 3.3 Semantic Store Service
|
| 263 |
+
|
| 264 |
+
**Fil:** `src/mnemocore/core/semantic_store.py`
|
| 265 |
+
|
| 266 |
+
Ansvar:
|
| 267 |
+
|
| 268 |
+
- Hålla SemanticConcepts + codebook.
|
| 269 |
+
- API:
|
| 270 |
+
- `upsert_concept(concept: SemanticConcept)`
|
| 271 |
+
- `find_nearby_concepts(hdv, top_k)`
|
| 272 |
+
- `get_concept(id)`
|
| 273 |
+
- Hookar mot:
|
| 274 |
+
- `semantic_consolidation.py` → abstraktioner / anchors.
|
| 275 |
+
- `immunology.py` → attractor cleanup.
|
| 276 |
+
- `recursive_synthesizer.py` → djup konceptsyntes.
|
| 277 |
+
|
| 278 |
+
### 3.4 Procedural Store Service
|
| 279 |
+
|
| 280 |
+
**Fil:** `src/mnemocore/core/procedural_store.py`
|
| 281 |
+
|
| 282 |
+
Ansvar:
|
| 283 |
+
|
| 284 |
+
- Lagra och hämta Procedures.
|
| 285 |
+
- API:
|
| 286 |
+
- `store_procedure(proc: Procedure)`
|
| 287 |
+
- `get_procedure(id)`
|
| 288 |
+
- `find_applicable_procedures(query, agent_id)`
|
| 289 |
+
- `record_procedure_outcome(id, success: bool)`
|
| 290 |
+
- Integrera med:
|
| 291 |
+
- SubconsciousAI → generera nya procedurer från pattern i EM/SM.
|
| 292 |
+
- Reliability‑loopen → promota “verified” skills.
|
| 293 |
+
|
| 294 |
+
### 3.5 Meta Memory Service
|
| 295 |
+
|
| 296 |
+
**Fil:** `src/mnemocore/core/meta_memory.py`
|
| 297 |
+
|
| 298 |
+
Ansvar:
|
| 299 |
+
|
| 300 |
+
- Hålla SelfMetrics + SelfImprovementProposals.
|
| 301 |
+
- API:
|
| 302 |
+
- `record_metric(metric: SelfMetric)`
|
| 303 |
+
- `list_metrics(filter...)`
|
| 304 |
+
- `create_proposal(...)`
|
| 305 |
+
- `update_proposal_status(id, status)`
|
| 306 |
+
- Integrera med:
|
| 307 |
+
- Pulse → skanna metrics och föreslå ändringar.
|
| 308 |
+
- LLM → generera förslagstexter (“self‑reflection reports”).
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## 4. Pulse & Subtle Thoughts
|
| 313 |
+
|
| 314 |
+
### 4.1 Pulse Definition
|
| 315 |
+
|
| 316 |
+
**Fil:** `src/mnemocore/core/pulse.py`
|
| 317 |
+
|
| 318 |
+
“Pulsen” är en central loop (async task, cron, eller k8s CronJob) som:
|
| 319 |
+
|
| 320 |
+
- Kör med konfigurerbart intervall (t.ex. var 10:e sekund–var 5:e minut).
|
| 321 |
+
- Har ett definierat set “ticks”:
|
| 322 |
+
|
| 323 |
+
```python
|
| 324 |
+
class PulseTick(Enum):
|
| 325 |
+
WM_MAINTENANCE = "wm_maintenance"
|
| 326 |
+
EPISODIC_CHAINING = "episodic_chaining"
|
| 327 |
+
SEMANTIC_REFRESH = "semantic_refresh"
|
| 328 |
+
GAP_DETECTION = "gap_detection"
|
| 329 |
+
INSIGHT_GENERATION = "insight_generation"
|
| 330 |
+
PROCEDURE_REFINEMENT = "procedure_refinement"
|
| 331 |
+
META_SELF_REFLECTION = "meta_self_reflection"
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
Pulse orchestrerar:
|
| 335 |
+
|
| 336 |
+
- **WM_MAINTENANCE**
|
| 337 |
+
- Prune WM per agent.
|
| 338 |
+
- Lyfta nyligen viktiga items (“keep in focus”).
|
| 339 |
+
|
| 340 |
+
- **EPISODIC_CHAINING**
|
| 341 |
+
- Skapa/länka episodiska sekvenser (prev/next).
|
| 342 |
+
- “Temporala narrativ”.
|
| 343 |
+
|
| 344 |
+
- **SEMANTIC_REFRESH**
|
| 345 |
+
- Uppdatera semantic concepts baserat på nya episoder.
|
| 346 |
+
- Trigga immunology cleanup för drift.
|
| 347 |
+
|
| 348 |
+
- **GAP_DETECTION**
|
| 349 |
+
- Kör `GapDetector` över EM/SM sista N minuter/timmar.
|
| 350 |
+
- Producera strukturerade knowledge gaps.
|
| 351 |
+
|
| 352 |
+
- **INSIGHT_GENERATION**
|
| 353 |
+
- Kör SubconsciousAI/LLM över utvalda kluster.
|
| 354 |
+
- Skapar nya SemanticConcepts, Procedures, eller MetaProposals.
|
| 355 |
+
|
| 356 |
+
- **PROCEDURE_REFINEMENT**
|
| 357 |
+
- Uppdatera reliability över PM.
|
| 358 |
+
- Flagga outdated/farliga procedures.
|
| 359 |
+
|
| 360 |
+
- **META_SELF_REFLECTION**
|
| 361 |
+
- Sammanfattar senaste metriker, gap, failures → SelfImprovementProposals.
|
| 362 |
+
|
| 363 |
+
### 4.2 Pulse Implementation Sketch
|
| 364 |
+
|
| 365 |
+
```python
|
| 366 |
+
# src/mnemocore/core/pulse.py
|
| 367 |
+
|
| 368 |
+
class Pulse:
|
| 369 |
+
def __init__(self, container, config):
|
| 370 |
+
self.container = container
|
| 371 |
+
self.config = config
|
| 372 |
+
self._running = False
|
| 373 |
+
|
| 374 |
+
async def start(self):
|
| 375 |
+
self._running = True
|
| 376 |
+
while self._running:
|
| 377 |
+
start = datetime.utcnow()
|
| 378 |
+
await self.tick()
|
| 379 |
+
elapsed = (datetime.utcnow() - start).total_seconds()
|
| 380 |
+
await asyncio.sleep(max(0, self.config.pulse_interval_seconds - elapsed))
|
| 381 |
+
|
| 382 |
+
async def tick(self):
|
| 383 |
+
await self._wm_maintenance()
|
| 384 |
+
await self._episodic_chaining()
|
| 385 |
+
await self._semantic_refresh()
|
| 386 |
+
await self._gap_detection()
|
| 387 |
+
await self._insight_generation()
|
| 388 |
+
await self._procedure_refinement()
|
| 389 |
+
await self._meta_self_reflection()
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
Konfiguration i `config.yaml`:
|
| 393 |
+
|
| 394 |
+
```yaml
|
| 395 |
+
haim:
|
| 396 |
+
pulse:
|
| 397 |
+
enabled: true
|
| 398 |
+
interval_seconds: 30
|
| 399 |
+
max_agents_per_tick: 50
|
| 400 |
+
max_episodes_per_tick: 200
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
---
|
| 404 |
+
|
| 405 |
+
## 5. Agent‑Facing APIs (Python & HTTP & MCP)
|
| 406 |
+
|
| 407 |
+
### 5.1 High‑Level Python API
|
| 408 |
+
|
| 409 |
+
**Fil:** `src/mnemocore/agent_interface.py`
|
| 410 |
+
|
| 411 |
+
Syfte: ge agent‑kod ett ENKELT API:
|
| 412 |
+
|
| 413 |
+
```python
|
| 414 |
+
class CognitiveMemoryClient:
|
| 415 |
+
def __init__(self, engine: HAIMEngine, wm, episodic, semantic, procedural, meta):
|
| 416 |
+
...
|
| 417 |
+
|
| 418 |
+
# --- Observation & WM ---
|
| 419 |
+
def observe(self, agent_id: str, content: str, **meta) -> str: ...
|
| 420 |
+
def get_working_context(self, agent_id: str, limit: int = 16) -> list[WorkingMemoryItem]: ...
|
| 421 |
+
|
| 422 |
+
# --- Episodic ---
|
| 423 |
+
def start_episode(self, agent_id: str, goal: str, context: str | None = None) -> str: ...
|
| 424 |
+
def append_event(self, episode_id: str, kind: str, content: str, **meta) -> None: ...
|
| 425 |
+
def end_episode(self, episode_id: str, outcome: str, reward: float | None = None) -> None: ...
|
| 426 |
+
|
| 427 |
+
# --- Semantic / Retrieval ---
|
| 428 |
+
def recall(self, agent_id: str, query: str, context: str | None = None,
|
| 429 |
+
top_k: int = 8, modes: tuple[str, ...] = ("episodic","semantic")) -> list[dict]: ...
|
| 430 |
+
|
| 431 |
+
# --- Procedural ---
|
| 432 |
+
def suggest_procedures(self, agent_id: str, query: str, top_k: int = 5) -> list[Procedure]: ...
|
| 433 |
+
def record_procedure_outcome(self, proc_id: str, success: bool) -> None: ...
|
| 434 |
+
|
| 435 |
+
# --- Meta / Self-awareness ---
|
| 436 |
+
def get_knowledge_gaps(self, agent_id: str, lookback_hours: int = 24) -> list[dict]: ...
|
| 437 |
+
def get_self_improvement_proposals(self) -> list[SelfImprovementProposal]: ...
|
| 438 |
+
```
|
| 439 |
+
|
| 440 |
+
### 5.2 HTTP Layer Additions
|
| 441 |
+
|
| 442 |
+
Utöver befintliga `/store`, `/query`, `/feedback`, osv.[cite:437]
|
| 443 |
+
|
| 444 |
+
Nya endpoints:
|
| 445 |
+
|
| 446 |
+
- `POST /wm/observe`
|
| 447 |
+
- `GET /wm/{agent_id}`
|
| 448 |
+
- `POST /episodes/start`
|
| 449 |
+
- `POST /episodes/{id}/event`
|
| 450 |
+
- `POST /episodes/{id}/end`
|
| 451 |
+
- `GET /episodes/{id}`
|
| 452 |
+
- `GET /agents/{agent_id}/episodes`
|
| 453 |
+
- `GET /agents/{agent_id}/context`
|
| 454 |
+
- `GET /agents/{agent_id}/knowledge-gaps`
|
| 455 |
+
- `GET /procedures/search`
|
| 456 |
+
- `POST /procedures/{id}/feedback`
|
| 457 |
+
- `GET /meta/proposals`
|
| 458 |
+
- `POST /meta/proposals`
|
| 459 |
+
|
| 460 |
+
### 5.3 MCP Tools
|
| 461 |
+
|
| 462 |
+
Utöka `mnemocore.mcp.server` med nya verktyg:
|
| 463 |
+
|
| 464 |
+
- `store_observation`
|
| 465 |
+
- `recall_context`
|
| 466 |
+
- `start_episode`, `end_episode`
|
| 467 |
+
- `query_memory`
|
| 468 |
+
- `get_knowledge_gaps`
|
| 469 |
+
- `get_self_improvement_proposals`
|
| 470 |
+
|
| 471 |
+
Så att Claude/GPT‑agenter kan:
|
| 472 |
+
|
| 473 |
+
- “Titta in” i agentens egen historik.
|
| 474 |
+
- Få WM + relevanta episoder + semantic concepts innan svar.
|
| 475 |
+
- Få gaps och self‑reflection prompts.
|
| 476 |
+
|
| 477 |
+
---
|
| 478 |
+
|
| 479 |
+
## 6. Self‑Improvement Loop
|
| 480 |
+
|
| 481 |
+
### 6.1 Loop Definition
|
| 482 |
+
|
| 483 |
+
Målet: MnemoCore ska **ständigt förbättra sig**:
|
| 484 |
+
|
| 485 |
+
1. Samlar **metrics** (performance + quality).
|
| 486 |
+
2. Upptäcker systematiska brister (höga felrates, gap‑clusters).
|
| 487 |
+
3. Genererar SelfImprovementProposals via LLM.
|
| 488 |
+
4. Låter människa eller meta‑agent granska & appliera.
|
| 489 |
+
|
| 490 |
+
### 6.2 Pipeline
|
| 491 |
+
|
| 492 |
+
1. **Metrics Collection**
|
| 493 |
+
- Utnyttja befintlig `metrics.py` + Prometheus.[cite:436][cite:437]
|
| 494 |
+
- Exempelmetriker:
|
| 495 |
+
- `query_hit_rate`, `retrieval_latency_ms`
|
| 496 |
+
- `feedback_success_rate`, `feedback_failure_rate`
|
| 497 |
+
- `hot_tier_size`, `tier_promotion_rate`
|
| 498 |
+
- `gap_detection_count`, `gap_fill_count`
|
| 499 |
+
|
| 500 |
+
2. **Issue Detection (Rule‑Based)**
|
| 501 |
+
- Batchjobb (Pulse) kör enkla regler:
|
| 502 |
+
- Om `feedback_failure_rate > X` för en viss tag (t.ex. “fastapi”) → skapa “knowledge area weak” flagg.
|
| 503 |
+
- Om `hot_tier_hit_rate < threshold` → dålig context‑masking eller fel tuned thresholds.
|
| 504 |
+
|
| 505 |
+
3. **Proposal Generation (LLM)**
|
| 506 |
+
- `SubconsciousAI` får inputs:
|
| 507 |
+
- Metrics, knowledge gaps, failure cases, config snapshot.
|
| 508 |
+
- Prompt genererar:
|
| 509 |
+
- `SelfImprovementProposal.title/description/rationale`.
|
| 510 |
+
|
| 511 |
+
4. **Review & Execution**
|
| 512 |
+
- API / UI för att lista proposals.
|
| 513 |
+
- Människa/agent accepterar/rejectar.
|
| 514 |
+
- Vid accept:
|
| 515 |
+
- Kan trigga config ändringar (med patch PR).
|
| 516 |
+
- Kan skapa GitHub issues/PR mallar.
|
| 517 |
+
|
| 518 |
+
### 6.3 API
|
| 519 |
+
|
| 520 |
+
- `GET /meta/proposals`
|
| 521 |
+
- `POST /meta/proposals/{id}/status`
|
| 522 |
+
|
| 523 |
+
---
|
| 524 |
+
|
| 525 |
+
## 7. Association & “Subtle Thoughts”
|
| 526 |
+
|
| 527 |
+
### 7.1 Association Engine
|
| 528 |
+
|
| 529 |
+
Målet: Systemet ska **själv föreslå**:
|
| 530 |
+
|
| 531 |
+
- Analogier (“det här liknar när vi gjorde X i annat projekt”).
|
| 532 |
+
- Relaterade koncept (“du pratar om Y, men Z har varit viktigt tidigare”).
|
| 533 |
+
- Långsiktiga teman och lärdomar.
|
| 534 |
+
|
| 535 |
+
Bygg vidare på:
|
| 536 |
+
|
| 537 |
+
- `synapse_index.py` (hebbian connections).[cite:436]
|
| 538 |
+
- `ripple_context.py` (kaskader).[cite:436]
|
| 539 |
+
- `recursive_synthesizer.py` (konceptsyntes).[cite:436]
|
| 540 |
+
|
| 541 |
+
Nya pattern:
|
| 542 |
+
|
| 543 |
+
- Vid varje Pulse:
|
| 544 |
+
- Hämta senaste N episoder.
|
| 545 |
+
- Kör k‑NN i semantic concept space.
|
| 546 |
+
- Kör ripple over synapses.
|
| 547 |
+
- Generera en uppsättning **CandidateAssociations**:
|
| 548 |
+
|
| 549 |
+
```python
|
| 550 |
+
@dataclass
|
| 551 |
+
class CandidateAssociation:
|
| 552 |
+
id: str
|
| 553 |
+
agent_id: str
|
| 554 |
+
created_at: datetime
|
| 555 |
+
source_episode_ids: list[str]
|
| 556 |
+
related_concept_ids: list[str]
|
| 557 |
+
suggestion_text: str
|
| 558 |
+
confidence: float
|
| 559 |
+
```
|
| 560 |
+
|
| 561 |
+
Lagra i SM/EM så att agent/LLM kan hämta “subtle thoughts” innan svar:
|
| 562 |
+
|
| 563 |
+
- `GET /agents/{agent_id}/subtle-thoughts`
|
| 564 |
+
|
| 565 |
+
---
|
| 566 |
+
|
| 567 |
+
## 8. Storage Backends & Profiles
|
| 568 |
+
|
| 569 |
+
### 8.1 Profiles
|
| 570 |
+
|
| 571 |
+
Behåll pip‑enkelheten via profiler:
|
| 572 |
+
|
| 573 |
+
- **Lite Profile** (default, no extra deps):
|
| 574 |
+
- WM: in‑process dict
|
| 575 |
+
- EM: SQLite
|
| 576 |
+
- SM: in‑process HDV + mmap
|
| 577 |
+
- PM/MM: SQLite/JSON
|
| 578 |
+
- **Standard Profile**:
|
| 579 |
+
- WARM: Redis
|
| 580 |
+
- COLD: filesystem
|
| 581 |
+
- **Scale Profile**:
|
| 582 |
+
- WARM: Redis
|
| 583 |
+
- COLD: Qdrant (eller annan vector DB)
|
| 584 |
+
- Optionellt: S3 archive
|
| 585 |
+
|
| 586 |
+
Konfigurationsexempel:
|
| 587 |
+
|
| 588 |
+
```yaml
|
| 589 |
+
haim:
|
| 590 |
+
profile: "lite" # "lite" | "standard" | "scale"
|
| 591 |
+
```
|
| 592 |
+
|
| 593 |
+
---
|
| 594 |
+
|
| 595 |
+
## 9. OpenClaw & External Agents
|
| 596 |
+
|
| 597 |
+
### 9.1 Designprincip för integration
|
| 598 |
+
|
| 599 |
+
För OpenClaw / liknande orchestrators:
|
| 600 |
+
|
| 601 |
+
- En agent definieras genom:
|
| 602 |
+
- `agent_id`
|
| 603 |
+
- `capabilities` (tools etc.)
|
| 604 |
+
- MnemoCore ska behandla `agent_id` som primär nyckel för:
|
| 605 |
+
- WM
|
| 606 |
+
- Episoder
|
| 607 |
+
- Preferenser
|
| 608 |
+
- Procedurer som agenten själv skapat
|
| 609 |
+
|
| 610 |
+
### 9.2 “Live Memory” Pattern
|
| 611 |
+
|
| 612 |
+
- När OpenClaw kör:
|
| 613 |
+
- Varje observation → `observe(agent_id, content, meta)`
|
| 614 |
+
- Varje tool call / action → episod event.
|
| 615 |
+
- Före varje beslut:
|
| 616 |
+
- Hämta:
|
| 617 |
+
- `WM`
|
| 618 |
+
- `recent episodes`
|
| 619 |
+
- `relevant semantic concepts`
|
| 620 |
+
- `subtle thoughts` / associations
|
| 621 |
+
- `knowledge gaps` (om agenten vill använda dessa som frågor).
|
| 622 |
+
|
| 623 |
+
---
|
| 624 |
+
|
| 625 |
+
## 10. Testing & Evaluation Plan
|
| 626 |
+
|
| 627 |
+
### 10.1 Unit & Integration Tests
|
| 628 |
+
|
| 629 |
+
Nya testfiler:
|
| 630 |
+
|
| 631 |
+
- `tests/test_working_memory.py`
|
| 632 |
+
- `tests/test_episodic_store.py`
|
| 633 |
+
- `tests/test_semantic_store.py`
|
| 634 |
+
- `tests/test_procedural_store.py`
|
| 635 |
+
- `tests/test_meta_memory.py`
|
| 636 |
+
- `tests/test_pulse.py`
|
| 637 |
+
- `tests/test_agent_interface.py`
|
| 638 |
+
|
| 639 |
+
Fokus:
|
| 640 |
+
|
| 641 |
+
- Invariantes (max WM size, LTP thresholds, reliability‑update).
|
| 642 |
+
- Episodic chaining korrekt.
|
| 643 |
+
- Semantic consolidation integration med nya SM‑API:t.
|
| 644 |
+
- Pulse tick ordering & time budget.
|
| 645 |
+
|
| 646 |
+
### 10.2 Behavioural Benchmarks
|
| 647 |
+
|
| 648 |
+
Skapa `benchmarks/AGI_MEMORY_SCENARIOS.md`:
|
| 649 |
+
|
| 650 |
+
- Multi‑session tasks där agent måste:
|
| 651 |
+
- Minnas user preferences över dagar.
|
| 652 |
+
- Lära sig av failed attempts (feedback).
|
| 653 |
+
- Använda analogier över domäner.
|
| 654 |
+
|
| 655 |
+
Mät:
|
| 656 |
+
|
| 657 |
+
- Context reuse rate.
|
| 658 |
+
- Time‑to‑solve vs “no memory” baseline.
|
| 659 |
+
- Antal genererade self‑improvement proposals som faktiskt förbättrar outcomes.
|
| 660 |
+
|
| 661 |
+
---
|
| 662 |
+
|
| 663 |
+
## 11. Implementation Roadmap
|
| 664 |
+
|
| 665 |
+
### Phase 5.0 – Core Structure
|
| 666 |
+
|
| 667 |
+
1. Introduce `memory_model.py`, `working_memory.py`, `episodic_store.py`, `semantic_store.py`, `procedural_store.py`, `meta_memory.py`, `pulse.py`.
|
| 668 |
+
2. Wire everything in `container.py` (new providers).
|
| 669 |
+
3. Add `CognitiveMemoryClient` + minimal tests.
|
| 670 |
+
|
| 671 |
+
### Phase 5.1 – WM/EM/SM in Engine
|
| 672 |
+
|
| 673 |
+
4. Integrate WM into engine query/store paths.
|
| 674 |
+
5. Integrate EM creation in API (store/query/feedback).
|
| 675 |
+
6. Adapt semantic_consolidation/immunology to new SM service.
|
| 676 |
+
|
| 677 |
+
### Phase 5.2 – Procedural & Association
|
| 678 |
+
|
| 679 |
+
7. Implement procedural store + reliability integration.
|
| 680 |
+
8. Build association engine + subtle thoughts endpoints.
|
| 681 |
+
|
| 682 |
+
### Phase 5.3 – Self‑Improvement
|
| 683 |
+
|
| 684 |
+
9. Wire metrics → meta_memory → proposals via SubconsciousAI.
|
| 685 |
+
10. Add endpoints & optional small UI for proposals.
|
| 686 |
+
|
| 687 |
+
### Phase 5.4 – Hardening & Agents
|
| 688 |
+
|
| 689 |
+
11. Harden profiles (lite/standard/scale).
|
| 690 |
+
12. Build reference integrations (OpenClaw, LangGraph, AutoGen).
|
| 691 |
+
|
| 692 |
+
---
|
| 693 |
+
|
| 694 |
+
## 12. Developer Notes
|
| 695 |
+
|
| 696 |
+
- Håll **backwards compatibility** på API där det går:
|
| 697 |
+
- Nya endpoints → prefix `v2` om nödvändigt.
|
| 698 |
+
- Python API kan vara “ny high‑level layer” ovanpå befintlig `HAIMEngine`.
|
| 699 |
+
- All ny funktionalitet **feature‑flaggas i config**:
|
| 700 |
+
- `haim.pulse.enabled`
|
| 701 |
+
- `haim.episodic.enabled`
|
| 702 |
+
- `haim.procedural.enabled`
|
| 703 |
+
- etc.
|
| 704 |
+
- Strikt logging / metrics för allt nytt:
|
| 705 |
+
- `haim_pulse_tick_duration_seconds`
|
| 706 |
+
- `haim_wm_size`
|
| 707 |
+
- `haim_episode_count`
|
| 708 |
+
- `haim_procedure_success_rate`
|
| 709 |
+
- `haim_self_proposals_pending`
|
| 710 |
+
|
| 711 |
+
---
|
| 712 |
+
|
| 713 |
+
*This blueprint is the contract between MnemoCore, its agents, and its contributors. The intention is to let autonomous AI agents, human developers, and MnemoCore itself co‑evolve toward a truly cognitive memory substrate – one that remembers, forgets, reflects, and grows.*
|
integrations/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore Integrations
|
| 2 |
+
|
| 3 |
+
Connect MnemoCore's persistent cognitive memory to your AI coding tools.
|
| 4 |
+
|
| 5 |
+
## Supported tools
|
| 6 |
+
|
| 7 |
+
| Tool | Method | Notes |
|
| 8 |
+
|------|--------|-------|
|
| 9 |
+
| **Claude Code** | MCP server + hooks + CLAUDE.md | Best integration — native tool access |
|
| 10 |
+
| **Gemini CLI** | GEMINI.md + wrapper script | Context injected at session start |
|
| 11 |
+
| **Aider** | Wrapper script (`--system-prompt`) | Context injected at session start |
|
| 12 |
+
| **Any CLI tool** | Universal shell scripts | Pipe context into any tool |
|
| 13 |
+
| **Open-source agents** | REST API (`mnemo_bridge.py`) | Minimal Python dependency |
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## Quick setup
|
| 18 |
+
|
| 19 |
+
### Prerequisites
|
| 20 |
+
|
| 21 |
+
1. MnemoCore running: `uvicorn mnemocore.api.main:app --port 8100`
|
| 22 |
+
2. `HAIM_API_KEY` environment variable set
|
| 23 |
+
3. Python 3.10+ with `requests` (`pip install requests`)
|
| 24 |
+
|
| 25 |
+
### Linux / macOS
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
cd integrations/
|
| 29 |
+
bash setup.sh --all
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### Windows (PowerShell)
|
| 33 |
+
|
| 34 |
+
```powershell
|
| 35 |
+
cd integrations\
|
| 36 |
+
.\setup.ps1 -All
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
### Manual: test the bridge first
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
export MNEMOCORE_URL=http://localhost:8100
|
| 43 |
+
export HAIM_API_KEY=your-key-here
|
| 44 |
+
|
| 45 |
+
python integrations/mnemo_bridge.py health
|
| 46 |
+
python integrations/mnemo_bridge.py context --top-k 5
|
| 47 |
+
python integrations/mnemo_bridge.py store "Fixed import error in engine.py" --tags "bugfix,python"
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Claude Code (recommended)
|
| 53 |
+
|
| 54 |
+
The setup script does three things:
|
| 55 |
+
|
| 56 |
+
### 1. MCP server — native tool access
|
| 57 |
+
|
| 58 |
+
Registers MnemoCore as an MCP server in `~/.claude/mcp.json`.
|
| 59 |
+
Claude Code gets four tools:
|
| 60 |
+
- `memory_query` — search memories
|
| 61 |
+
- `memory_store` — store a memory
|
| 62 |
+
- `memory_get` / `memory_delete` — manage individual memories
|
| 63 |
+
|
| 64 |
+
Claude will use these automatically when you instruct it to remember things,
|
| 65 |
+
or you can configure CLAUDE.md to trigger them on every session (see below).
|
| 66 |
+
|
| 67 |
+
**Verify:** Run `claude mcp list` — you should see `mnemocore` listed.
|
| 68 |
+
|
| 69 |
+
### 2. Hooks — automatic background storage
|
| 70 |
+
|
| 71 |
+
Two hooks are installed in `~/.claude/settings.json`:
|
| 72 |
+
|
| 73 |
+
- **PreToolUse** (`pre_session_inject.py`): On the first tool call of a session,
|
| 74 |
+
queries MnemoCore and injects recent context into Claude's awareness.
|
| 75 |
+
|
| 76 |
+
- **PostToolUse** (`post_tool_store.py`): After every `Edit`/`Write` call,
|
| 77 |
+
stores a lightweight memory entry in the background (non-blocking).
|
| 78 |
+
|
| 79 |
+
Hooks never block Claude Code — they degrade silently if MnemoCore is offline.
|
| 80 |
+
|
| 81 |
+
### 3. CLAUDE.md — behavioral instructions
|
| 82 |
+
|
| 83 |
+
The setup appends memory usage instructions to `CLAUDE.md`.
|
| 84 |
+
This tells Claude *when* to use memory tools proactively.
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Gemini CLI
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
# Option A: Use wrapper (injects context automatically)
|
| 92 |
+
alias gemini='bash integrations/gemini_cli/gemini_wrap.sh'
|
| 93 |
+
gemini "Fix the async bug in engine.py"
|
| 94 |
+
|
| 95 |
+
# Option B: Manual context injection
|
| 96 |
+
CONTEXT=$(integrations/universal/context_inject.sh)
|
| 97 |
+
gemini --system-prompt "$CONTEXT" "Fix the async bug in engine.py"
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
Also add instructions to your `GEMINI.md`:
|
| 101 |
+
```bash
|
| 102 |
+
cat integrations/gemini_cli/GEMINI_memory_snippet.md >> GEMINI.md
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## Aider
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
# Option A: Use wrapper
|
| 111 |
+
alias aider='bash integrations/aider/aider_wrap.sh'
|
| 112 |
+
aider --model claude-3-5-sonnet-20241022 engine.py
|
| 113 |
+
|
| 114 |
+
# Option B: Manual
|
| 115 |
+
CONTEXT=$(integrations/universal/context_inject.sh "async engine")
|
| 116 |
+
aider --system-prompt "$CONTEXT" engine.py
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## Universal / Open-source agents
|
| 122 |
+
|
| 123 |
+
Any tool that accepts a system prompt can use MnemoCore:
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
# Get context as markdown
|
| 127 |
+
integrations/universal/context_inject.sh "query text" 6
|
| 128 |
+
|
| 129 |
+
# Use in any command
|
| 130 |
+
MY_CONTEXT=$(integrations/universal/context_inject.sh)
|
| 131 |
+
some-ai-cli --system "$MY_CONTEXT" "do the task"
|
| 132 |
+
|
| 133 |
+
# Store a memory after a session
|
| 134 |
+
integrations/universal/store_session.sh \
|
| 135 |
+
"Discovered that warm tier mmap files grow unbounded without consolidation" \
|
| 136 |
+
"discovery,warm-tier,storage" \
|
| 137 |
+
"mnemocore-project"
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
### REST API (Python / any language)
|
| 141 |
+
|
| 142 |
+
```python
|
| 143 |
+
import os, requests
|
| 144 |
+
|
| 145 |
+
BASE = os.getenv("MNEMOCORE_URL", "http://localhost:8100")
|
| 146 |
+
KEY = os.getenv("HAIM_API_KEY", "")
|
| 147 |
+
HDR = {"X-API-Key": KEY}
|
| 148 |
+
|
| 149 |
+
# Query
|
| 150 |
+
r = requests.post(f"{BASE}/query", json={"query": "async bugs", "top_k": 5}, headers=HDR)
|
| 151 |
+
for m in r.json()["results"]:
|
| 152 |
+
print(m["score"], m["content"])
|
| 153 |
+
|
| 154 |
+
# Store
|
| 155 |
+
requests.post(f"{BASE}/store", json={
|
| 156 |
+
"content": "Found root cause of memory leak in consolidation worker",
|
| 157 |
+
"metadata": {"source": "my-agent", "tags": ["bugfix", "memory"]}
|
| 158 |
+
}, headers=HDR)
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## Environment variables
|
| 164 |
+
|
| 165 |
+
| Variable | Default | Description |
|
| 166 |
+
|----------|---------|-------------|
|
| 167 |
+
| `MNEMOCORE_URL` | `http://localhost:8100` | MnemoCore API base URL |
|
| 168 |
+
| `HAIM_API_KEY` | — | API key (same as MnemoCore's `HAIM_API_KEY`) |
|
| 169 |
+
| `MNEMOCORE_TIMEOUT` | `5` | Request timeout in seconds |
|
| 170 |
+
| `MNEMOCORE_CONTEXT_DIR` | `~/.claude/mnemo_context` | Where hook writes context files |
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
## Architecture
|
| 175 |
+
|
| 176 |
+
```
|
| 177 |
+
┌─────────────────────────────────────────────────────┐
|
| 178 |
+
│ AI Coding Tool │
|
| 179 |
+
│ (Claude Code / Gemini CLI / Aider / Custom) │
|
| 180 |
+
└──────────────┬──────────────────────┬───────────────┘
|
| 181 |
+
│ MCP tools │ System prompt
|
| 182 |
+
│ (Claude Code only) │ (all tools)
|
| 183 |
+
▼ ▼
|
| 184 |
+
┌──────────────────────┐ ┌───────────────────────────┐
|
| 185 |
+
│ mnemocore MCP server│ │ mnemo_bridge.py CLI │
|
| 186 |
+
│ (stdio transport) │ │ (lightweight wrapper) │
|
| 187 |
+
└──────────┬───────────┘ └─────────────┬─────────────┘
|
| 188 |
+
│ │
|
| 189 |
+
└────────────┬───────────────┘
|
| 190 |
+
│ HTTP REST
|
| 191 |
+
▼
|
| 192 |
+
┌────────────────────────┐
|
| 193 |
+
│ MnemoCore API │
|
| 194 |
+
│ localhost:8100 │
|
| 195 |
+
│ │
|
| 196 |
+
│ ┌──────────────────┐ │
|
| 197 |
+
│ │ HAIMEngine │ │
|
| 198 |
+
│ │ HOT/WARM/COLD │ │
|
| 199 |
+
│ │ HDV vectors │ │
|
| 200 |
+
│ └──────────────────┘ │
|
| 201 |
+
└────────────────────────┘
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## Troubleshooting
|
| 207 |
+
|
| 208 |
+
**MnemoCore offline:**
|
| 209 |
+
```bash
|
| 210 |
+
python integrations/mnemo_bridge.py health
|
| 211 |
+
# → MnemoCore is OFFLINE
|
| 212 |
+
# Start it: uvicorn mnemocore.api.main:app --port 8100
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
**API key error (401):**
|
| 216 |
+
```bash
|
| 217 |
+
export HAIM_API_KEY="your-key-from-.env"
|
| 218 |
+
python integrations/mnemo_bridge.py health
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
**Hook not triggering (Claude Code):**
|
| 222 |
+
```bash
|
| 223 |
+
# Check settings.json
|
| 224 |
+
cat ~/.claude/settings.json | python -m json.tool | grep -A5 hooks
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
**MCP server not found (Claude Code):**
|
| 228 |
+
```bash
|
| 229 |
+
# Verify mcp.json
|
| 230 |
+
cat ~/.claude/mcp.json
|
| 231 |
+
# Check PYTHONPATH includes src/
|
| 232 |
+
cd /path/to/mnemocore && python -m mnemocore.mcp.server --help
|
| 233 |
+
```
|
integrations/aider/aider_wrap.sh
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# aider_wrap.sh — MnemoCore context injector for Aider
|
| 3 |
+
# ======================================================
|
| 4 |
+
# Usage: ./aider_wrap.sh [any aider args...]
|
| 5 |
+
#
|
| 6 |
+
# Injects MnemoCore memory context into Aider's system prompt
|
| 7 |
+
# using the --system-prompt flag (available in Aider 0.40+).
|
| 8 |
+
#
|
| 9 |
+
# Environment variables:
|
| 10 |
+
# MNEMOCORE_URL MnemoCore REST URL (default: http://localhost:8100)
|
| 11 |
+
# HAIM_API_KEY API key for MnemoCore
|
| 12 |
+
# BRIDGE_PY Path to mnemo_bridge.py (auto-detected)
|
| 13 |
+
# AIDER_BIN Path to aider binary (default: aider)
|
| 14 |
+
|
| 15 |
+
set -euo pipefail
|
| 16 |
+
|
| 17 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 18 |
+
BRIDGE_PY="${BRIDGE_PY:-$(realpath "$SCRIPT_DIR/../mnemo_bridge.py")}"
|
| 19 |
+
AIDER_BIN="${AIDER_BIN:-aider}"
|
| 20 |
+
|
| 21 |
+
# ── Fetch context ──────────────────────────────────────────────────────────
|
| 22 |
+
CONTEXT=""
|
| 23 |
+
if python3 "$BRIDGE_PY" health &>/dev/null 2>&1; then
|
| 24 |
+
CONTEXT="$(python3 "$BRIDGE_PY" context --top-k 6 2>/dev/null || true)"
|
| 25 |
+
fi
|
| 26 |
+
|
| 27 |
+
# ── Run Aider with or without injected context ─────────────────────────────
|
| 28 |
+
if [[ -n "$CONTEXT" ]]; then
|
| 29 |
+
PROMPT_FILE="$(mktemp /tmp/mnemo_aider_XXXXXX.md)"
|
| 30 |
+
trap 'rm -f "$PROMPT_FILE"' EXIT
|
| 31 |
+
|
| 32 |
+
cat > "$PROMPT_FILE" <<'HEREDOC'
|
| 33 |
+
## MnemoCore: Memory from previous sessions
|
| 34 |
+
|
| 35 |
+
Use the following context from persistent memory to inform your work.
|
| 36 |
+
Do not repeat known decisions. Reference this to avoid re-discovering bugs.
|
| 37 |
+
|
| 38 |
+
HEREDOC
|
| 39 |
+
echo "$CONTEXT" >> "$PROMPT_FILE"
|
| 40 |
+
|
| 41 |
+
exec "$AIDER_BIN" --system-prompt "$PROMPT_FILE" "$@"
|
| 42 |
+
else
|
| 43 |
+
exec "$AIDER_BIN" "$@"
|
| 44 |
+
fi
|
integrations/claude_code/CLAUDE_memory_snippet.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore — Persistent Cognitive Memory
|
| 2 |
+
|
| 3 |
+
You have access to a persistent memory system via MCP tools:
|
| 4 |
+
- `memory_query` — search for relevant memories before starting any task
|
| 5 |
+
- `memory_store` — save important decisions, findings, and bug fixes after completing work
|
| 6 |
+
- `memory_stats` / `memory_health` — check system status
|
| 7 |
+
|
| 8 |
+
## When to use memory
|
| 9 |
+
|
| 10 |
+
**At session start:** Call `memory_query` with the user's first message to retrieve relevant past context.
|
| 11 |
+
|
| 12 |
+
**After completing a task:** Call `memory_store` to record:
|
| 13 |
+
- What was changed and why (key architectural decisions)
|
| 14 |
+
- Bug fixes and root causes
|
| 15 |
+
- Non-obvious patterns discovered in the codebase
|
| 16 |
+
- User preferences and project conventions
|
| 17 |
+
|
| 18 |
+
**When you find something unexpected:** Store it immediately with relevant tags.
|
| 19 |
+
|
| 20 |
+
## Storing memories
|
| 21 |
+
|
| 22 |
+
Include useful metadata:
|
| 23 |
+
```json
|
| 24 |
+
{
|
| 25 |
+
"content": "Fixed async race condition in tier_manager.py by adding asyncio.Lock around promotion logic",
|
| 26 |
+
"metadata": {
|
| 27 |
+
"source": "claude-code",
|
| 28 |
+
"tags": ["bugfix", "async", "tier_manager"],
|
| 29 |
+
"project": "mnemocore"
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Rules
|
| 35 |
+
- Do NOT store trivial information (e.g., "the user asked me to open a file")
|
| 36 |
+
- DO store non-obvious insights, decisions with reasoning, and recurring patterns
|
| 37 |
+
- Query memory BEFORE reading files when working on a known codebase
|
| 38 |
+
- Store memory AFTER completing non-trivial changes
|
integrations/claude_code/hooks/post_tool_store.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Claude Code PostToolUse hook — MnemoCore auto-store
|
| 4 |
+
====================================================
|
| 5 |
+
Automatically stores the result of significant file edits into MnemoCore.
|
| 6 |
+
|
| 7 |
+
Configure in ~/.claude/settings.json:
|
| 8 |
+
{
|
| 9 |
+
"hooks": {
|
| 10 |
+
"PostToolUse": [
|
| 11 |
+
{
|
| 12 |
+
"matcher": "Edit|Write",
|
| 13 |
+
"hooks": [
|
| 14 |
+
{
|
| 15 |
+
"type": "command",
|
| 16 |
+
"command": "python /path/to/post_tool_store.py"
|
| 17 |
+
}
|
| 18 |
+
]
|
| 19 |
+
}
|
| 20 |
+
]
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
The hook receives a JSON blob on stdin and exits 0 to allow the tool call.
|
| 25 |
+
It stores a lightweight memory entry in the background (non-blocking via
|
| 26 |
+
subprocess) so it never delays Claude Code's response.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
import subprocess
|
| 32 |
+
import sys
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
BRIDGE = Path(__file__).resolve().parents[2] / "mnemo_bridge.py"
|
| 36 |
+
MIN_CONTENT_LEN = 30 # Ignore trivially short edits
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> int:
|
| 40 |
+
try:
|
| 41 |
+
raw = sys.stdin.read()
|
| 42 |
+
data = json.loads(raw) if raw.strip() else {}
|
| 43 |
+
except json.JSONDecodeError:
|
| 44 |
+
return 0 # Never block Claude Code
|
| 45 |
+
|
| 46 |
+
tool_name = data.get("tool_name", "")
|
| 47 |
+
tool_input = data.get("tool_input", {})
|
| 48 |
+
session_id = data.get("session_id", "")
|
| 49 |
+
|
| 50 |
+
# Only act on file-writing tools
|
| 51 |
+
if tool_name not in {"Edit", "Write", "MultiEdit"}:
|
| 52 |
+
return 0
|
| 53 |
+
|
| 54 |
+
file_path = tool_input.get("file_path", "")
|
| 55 |
+
new_string = tool_input.get("new_string") or tool_input.get("content", "")
|
| 56 |
+
|
| 57 |
+
if not new_string or len(new_string) < MIN_CONTENT_LEN:
|
| 58 |
+
return 0
|
| 59 |
+
|
| 60 |
+
# Build a concise memory entry — just the file + a short excerpt
|
| 61 |
+
excerpt = new_string[:200].replace("\n", " ").strip()
|
| 62 |
+
memory_text = f"Modified {file_path}: {excerpt}"
|
| 63 |
+
|
| 64 |
+
tags = "claude-code,edit"
|
| 65 |
+
if file_path.endswith(".py"):
|
| 66 |
+
tags += ",python"
|
| 67 |
+
elif file_path.endswith((".ts", ".js")):
|
| 68 |
+
tags += ",javascript"
|
| 69 |
+
|
| 70 |
+
ctx = session_id[:16] if session_id else None
|
| 71 |
+
|
| 72 |
+
cmd = [
|
| 73 |
+
sys.executable, str(BRIDGE),
|
| 74 |
+
"store", memory_text,
|
| 75 |
+
"--source", "claude-code-hook",
|
| 76 |
+
"--tags", tags,
|
| 77 |
+
]
|
| 78 |
+
if ctx:
|
| 79 |
+
cmd += ["--ctx", ctx]
|
| 80 |
+
|
| 81 |
+
env = {**os.environ}
|
| 82 |
+
|
| 83 |
+
# Fire-and-forget: do not block Claude Code
|
| 84 |
+
subprocess.Popen(
|
| 85 |
+
cmd,
|
| 86 |
+
env=env,
|
| 87 |
+
stdout=subprocess.DEVNULL,
|
| 88 |
+
stderr=subprocess.DEVNULL,
|
| 89 |
+
start_new_session=True,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return 0
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
sys.exit(main())
|
integrations/claude_code/hooks/pre_session_inject.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Claude Code PreToolUse hook — MnemoCore context injection
|
| 4 |
+
==========================================================
|
| 5 |
+
On the FIRST tool call of a session, queries MnemoCore for recent context
|
| 6 |
+
and writes it to a temporary file that is referenced from CLAUDE.md.
|
| 7 |
+
|
| 8 |
+
This gives Claude Code automatic memory of previous sessions WITHOUT
|
| 9 |
+
requiring any explicit user commands.
|
| 10 |
+
|
| 11 |
+
Configure in ~/.claude/settings.json:
|
| 12 |
+
{
|
| 13 |
+
"hooks": {
|
| 14 |
+
"PreToolUse": [
|
| 15 |
+
{
|
| 16 |
+
"matcher": ".*",
|
| 17 |
+
"hooks": [
|
| 18 |
+
{
|
| 19 |
+
"type": "command",
|
| 20 |
+
"command": "python /path/to/pre_session_inject.py"
|
| 21 |
+
}
|
| 22 |
+
]
|
| 23 |
+
}
|
| 24 |
+
]
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
The hook must exit 0 (allow) or 2 (block with message).
|
| 29 |
+
It never blocks — silently degrades if MnemoCore is offline.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import subprocess
|
| 35 |
+
import sys
|
| 36 |
+
import tempfile
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
|
| 39 |
+
BRIDGE = Path(__file__).resolve().parents[2] / "mnemo_bridge.py"
|
| 40 |
+
CONTEXT_DIR = Path(os.getenv("MNEMOCORE_CONTEXT_DIR", Path.home() / ".claude" / "mnemo_context"))
|
| 41 |
+
DONE_FILE = CONTEXT_DIR / ".session_injected"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main() -> int:
|
| 45 |
+
try:
|
| 46 |
+
raw = sys.stdin.read()
|
| 47 |
+
data = json.loads(raw) if raw.strip() else {}
|
| 48 |
+
except json.JSONDecodeError:
|
| 49 |
+
return 0
|
| 50 |
+
|
| 51 |
+
session_id = data.get("session_id", "")
|
| 52 |
+
|
| 53 |
+
# Only inject once per session
|
| 54 |
+
done_marker = CONTEXT_DIR / f".injected_{session_id[:16]}"
|
| 55 |
+
if done_marker.exists():
|
| 56 |
+
return 0
|
| 57 |
+
|
| 58 |
+
CONTEXT_DIR.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
|
| 60 |
+
# Query MnemoCore for context
|
| 61 |
+
try:
|
| 62 |
+
result = subprocess.run(
|
| 63 |
+
[sys.executable, str(BRIDGE), "context", "--top-k", "8"],
|
| 64 |
+
capture_output=True,
|
| 65 |
+
text=True,
|
| 66 |
+
timeout=5,
|
| 67 |
+
env={**os.environ},
|
| 68 |
+
)
|
| 69 |
+
context_md = result.stdout.strip()
|
| 70 |
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
| 71 |
+
context_md = ""
|
| 72 |
+
|
| 73 |
+
if context_md:
|
| 74 |
+
context_file = CONTEXT_DIR / "latest_context.md"
|
| 75 |
+
context_file.write_text(context_md, encoding="utf-8")
|
| 76 |
+
|
| 77 |
+
# Mark session as injected
|
| 78 |
+
done_marker.touch()
|
| 79 |
+
|
| 80 |
+
# Output context as additional system information if available
|
| 81 |
+
if context_md:
|
| 82 |
+
# Claude Code hooks can output JSON to inject content
|
| 83 |
+
output = {
|
| 84 |
+
"type": "system_reminder",
|
| 85 |
+
"content": context_md,
|
| 86 |
+
}
|
| 87 |
+
print(json.dumps(output))
|
| 88 |
+
|
| 89 |
+
return 0
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
sys.exit(main())
|
integrations/claude_code/hooks_config_fragment.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "Merge this fragment into ~/.claude/settings.json under the 'hooks' key.",
|
| 3 |
+
"_note": "Replace MNEMOCORE_INTEGRATIONS_PATH with the absolute path to integrations/claude_code/hooks/",
|
| 4 |
+
"hooks": {
|
| 5 |
+
"PreToolUse": [
|
| 6 |
+
{
|
| 7 |
+
"matcher": ".*",
|
| 8 |
+
"hooks": [
|
| 9 |
+
{
|
| 10 |
+
"type": "command",
|
| 11 |
+
"command": "python MNEMOCORE_INTEGRATIONS_PATH/pre_session_inject.py"
|
| 12 |
+
}
|
| 13 |
+
]
|
| 14 |
+
}
|
| 15 |
+
],
|
| 16 |
+
"PostToolUse": [
|
| 17 |
+
{
|
| 18 |
+
"matcher": "Edit|Write|MultiEdit",
|
| 19 |
+
"hooks": [
|
| 20 |
+
{
|
| 21 |
+
"type": "command",
|
| 22 |
+
"command": "python MNEMOCORE_INTEGRATIONS_PATH/post_tool_store.py"
|
| 23 |
+
}
|
| 24 |
+
]
|
| 25 |
+
}
|
| 26 |
+
]
|
| 27 |
+
}
|
| 28 |
+
}
|
integrations/claude_code/mcp_config.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mcpServers": {
|
| 3 |
+
"mnemocore": {
|
| 4 |
+
"command": "python",
|
| 5 |
+
"args": ["-m", "mnemocore.mcp.server"],
|
| 6 |
+
"cwd": "${MNEMOCORE_DIR}",
|
| 7 |
+
"env": {
|
| 8 |
+
"HAIM_API_KEY": "${HAIM_API_KEY}",
|
| 9 |
+
"PYTHONPATH": "${MNEMOCORE_DIR}/src"
|
| 10 |
+
}
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
}
|
integrations/gemini_cli/GEMINI_memory_snippet.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore — Persistent Cognitive Memory
|
| 2 |
+
|
| 3 |
+
You have access to a persistent memory system via the MnemoCore REST API at `$MNEMOCORE_URL` (default: `http://localhost:8100`).
|
| 4 |
+
|
| 5 |
+
## Querying memory
|
| 6 |
+
|
| 7 |
+
To recall relevant context, call the API at the start of a task:
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
curl -s -X POST "$MNEMOCORE_URL/query" \
|
| 11 |
+
-H "X-API-Key: $HAIM_API_KEY" \
|
| 12 |
+
-H "Content-Type: application/json" \
|
| 13 |
+
-d '{"query": "DESCRIBE_TASK_HERE", "top_k": 5}'
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
## Storing memory
|
| 17 |
+
|
| 18 |
+
After completing significant work, store a memory:
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
curl -s -X POST "$MNEMOCORE_URL/store" \
|
| 22 |
+
-H "X-API-Key: $HAIM_API_KEY" \
|
| 23 |
+
-H "Content-Type: application/json" \
|
| 24 |
+
-d '{
|
| 25 |
+
"content": "WHAT_WAS_DONE_AND_WHY",
|
| 26 |
+
"metadata": {"source": "gemini-cli", "tags": ["relevant", "tags"]}
|
| 27 |
+
}'
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## Guidelines
|
| 31 |
+
|
| 32 |
+
- **Query before starting** any non-trivial task on a known codebase
|
| 33 |
+
- **Store after completing** important changes, bug fixes, or design decisions
|
| 34 |
+
- **Do NOT store** trivial or ephemeral information
|
| 35 |
+
- Include relevant tags: language, component, type (bugfix/feature/refactor)
|
integrations/gemini_cli/gemini_wrap.sh
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# gemini_wrap.sh — MnemoCore context injector for Gemini CLI
|
| 3 |
+
# =============================================================
|
| 4 |
+
# Usage: ./gemini_wrap.sh [any gemini CLI args...]
|
| 5 |
+
#
|
| 6 |
+
# Fetches recent MnemoCore context and prepends it to the system prompt
|
| 7 |
+
# via a temporary file, then delegates to the real `gemini` binary.
|
| 8 |
+
#
|
| 9 |
+
# Environment variables:
|
| 10 |
+
# MNEMOCORE_URL MnemoCore REST URL (default: http://localhost:8100)
|
| 11 |
+
# HAIM_API_KEY API key for MnemoCore
|
| 12 |
+
# BRIDGE_PY Path to mnemo_bridge.py (auto-detected)
|
| 13 |
+
# GEMINI_BIN Path to gemini binary (default: gemini)
|
| 14 |
+
|
| 15 |
+
set -euo pipefail
|
| 16 |
+
|
| 17 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 18 |
+
BRIDGE_PY="${BRIDGE_PY:-$(realpath "$SCRIPT_DIR/../mnemo_bridge.py")}"
|
| 19 |
+
GEMINI_BIN="${GEMINI_BIN:-gemini}"
|
| 20 |
+
MNEMOCORE_URL="${MNEMOCORE_URL:-http://localhost:8100}"
|
| 21 |
+
|
| 22 |
+
# ── Fetch context (silently degrade if offline) ────────────────────────────
|
| 23 |
+
CONTEXT=""
|
| 24 |
+
if python3 "$BRIDGE_PY" health &>/dev/null; then
|
| 25 |
+
CONTEXT="$(python3 "$BRIDGE_PY" context --top-k 6 2>/dev/null || true)"
|
| 26 |
+
fi
|
| 27 |
+
|
| 28 |
+
# ── Build the injected system prompt fragment ──────────────────────────────
|
| 29 |
+
if [[ -n "$CONTEXT" ]]; then
|
| 30 |
+
MEMORY_FILE="$(mktemp /tmp/mnemo_context_XXXXXX.md)"
|
| 31 |
+
trap 'rm -f "$MEMORY_FILE"' EXIT
|
| 32 |
+
|
| 33 |
+
cat > "$MEMORY_FILE" <<'HEREDOC'
|
| 34 |
+
## Persistent Memory Context (from MnemoCore)
|
| 35 |
+
|
| 36 |
+
The following is relevant context from your memory of previous sessions.
|
| 37 |
+
Use it to avoid re-discovering known patterns, bugs, and decisions.
|
| 38 |
+
|
| 39 |
+
HEREDOC
|
| 40 |
+
echo "$CONTEXT" >> "$MEMORY_FILE"
|
| 41 |
+
|
| 42 |
+
# Gemini CLI supports --system-prompt-file or similar flags.
|
| 43 |
+
# Adjust this to match the actual Gemini CLI interface.
|
| 44 |
+
exec "$GEMINI_BIN" --system-prompt-file "$MEMORY_FILE" "$@"
|
| 45 |
+
else
|
| 46 |
+
exec "$GEMINI_BIN" "$@"
|
| 47 |
+
fi
|
integrations/mnemo_bridge.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
MnemoCore Bridge — Universal CLI
|
| 4 |
+
=================================
|
| 5 |
+
Lightweight bridge between MnemoCore REST API and any AI CLI tool.
|
| 6 |
+
No heavy dependencies: only stdlib + requests.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python mnemo_bridge.py context [--query TEXT] [--top-k 5] [--ctx CTX_ID]
|
| 10 |
+
python mnemo_bridge.py store TEXT [--source SOURCE] [--tags TAG1,TAG2] [--ctx CTX_ID]
|
| 11 |
+
python mnemo_bridge.py health
|
| 12 |
+
|
| 13 |
+
Environment variables:
|
| 14 |
+
MNEMOCORE_URL Base URL of MnemoCore API (default: http://localhost:8100)
|
| 15 |
+
MNEMOCORE_API_KEY API key (same as HAIM_API_KEY)
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
from typing import Any, Dict, List, Optional
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
import requests
|
| 26 |
+
except ImportError:
|
| 27 |
+
print("ERROR: 'requests' package required. Run: pip install requests", file=sys.stderr)
|
| 28 |
+
sys.exit(1)
|
| 29 |
+
|
| 30 |
+
# ── Config ────────────────────────────────────────────────────────────────────
|
| 31 |
+
BASE_URL = os.getenv("MNEMOCORE_URL", "http://localhost:8100").rstrip("/")
|
| 32 |
+
API_KEY = os.getenv("MNEMOCORE_API_KEY") or os.getenv("HAIM_API_KEY", "")
|
| 33 |
+
TIMEOUT = int(os.getenv("MNEMOCORE_TIMEOUT", "5"))
|
| 34 |
+
|
| 35 |
+
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ── API helpers ───────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
def _get(path: str) -> Optional[Dict]:
|
| 41 |
+
try:
|
| 42 |
+
r = requests.get(f"{BASE_URL}{path}", headers=HEADERS, timeout=TIMEOUT)
|
| 43 |
+
r.raise_for_status()
|
| 44 |
+
return r.json()
|
| 45 |
+
except requests.ConnectionError:
|
| 46 |
+
return None
|
| 47 |
+
except requests.HTTPError as e:
|
| 48 |
+
print(f"HTTP error: {e}", file=sys.stderr)
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _post(path: str, payload: Dict) -> Optional[Dict]:
|
| 53 |
+
try:
|
| 54 |
+
r = requests.post(f"{BASE_URL}{path}", headers=HEADERS,
|
| 55 |
+
json=payload, timeout=TIMEOUT)
|
| 56 |
+
r.raise_for_status()
|
| 57 |
+
return r.json()
|
| 58 |
+
except requests.ConnectionError:
|
| 59 |
+
return None
|
| 60 |
+
except requests.HTTPError as e:
|
| 61 |
+
print(f"HTTP error: {e}", file=sys.stderr)
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ── Commands ──────────────────────────────────────────────────────────────────
|
| 66 |
+
|
| 67 |
+
def cmd_health() -> int:
|
| 68 |
+
data = _get("/health")
|
| 69 |
+
if data is None:
|
| 70 |
+
print("MnemoCore is OFFLINE (could not connect)", file=sys.stderr)
|
| 71 |
+
return 1
|
| 72 |
+
status = data.get("status", "unknown")
|
| 73 |
+
print(f"MnemoCore status: {status}")
|
| 74 |
+
return 0 if status == "ok" else 1
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def cmd_store(text: str, source: str, tags: List[str], ctx: Optional[str]) -> int:
|
| 78 |
+
metadata: Dict[str, Any] = {"source": source}
|
| 79 |
+
if tags:
|
| 80 |
+
metadata["tags"] = tags
|
| 81 |
+
|
| 82 |
+
payload: Dict[str, Any] = {"content": text, "metadata": metadata}
|
| 83 |
+
if ctx:
|
| 84 |
+
payload["agent_id"] = ctx
|
| 85 |
+
|
| 86 |
+
data = _post("/store", payload)
|
| 87 |
+
if data is None:
|
| 88 |
+
print("Failed to store memory (MnemoCore offline or error)", file=sys.stderr)
|
| 89 |
+
return 1
|
| 90 |
+
|
| 91 |
+
memory_id = data.get("id") or data.get("memory_id", "?")
|
| 92 |
+
print(f"Stored: {memory_id}")
|
| 93 |
+
return 0
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def cmd_context(query: Optional[str], top_k: int, ctx: Optional[str]) -> int:
|
| 97 |
+
"""
|
| 98 |
+
Fetch relevant memories and print them as a markdown block
|
| 99 |
+
suitable for injection into any AI tool's system prompt.
|
| 100 |
+
"""
|
| 101 |
+
payload: Dict[str, Any] = {
|
| 102 |
+
"query": query or "recent work context decisions bugs fixes",
|
| 103 |
+
"top_k": top_k,
|
| 104 |
+
}
|
| 105 |
+
if ctx:
|
| 106 |
+
payload["agent_id"] = ctx
|
| 107 |
+
|
| 108 |
+
data = _post("/query", payload)
|
| 109 |
+
if data is None:
|
| 110 |
+
# Silently return empty — don't break the calling tool's startup
|
| 111 |
+
return 0
|
| 112 |
+
|
| 113 |
+
results: List[Dict] = data.get("results", [])
|
| 114 |
+
if not results:
|
| 115 |
+
return 0
|
| 116 |
+
|
| 117 |
+
lines = [
|
| 118 |
+
"<!-- MnemoCore: Persistent Memory Context -->",
|
| 119 |
+
"## Relevant memory from previous sessions\n",
|
| 120 |
+
]
|
| 121 |
+
for r in results:
|
| 122 |
+
content = r.get("content", "").strip()
|
| 123 |
+
score = r.get("score", 0.0)
|
| 124 |
+
meta = r.get("metadata", {})
|
| 125 |
+
source = meta.get("source", "unknown")
|
| 126 |
+
tags = meta.get("tags", [])
|
| 127 |
+
tag_str = f" [{', '.join(tags)}]" if tags else ""
|
| 128 |
+
|
| 129 |
+
lines.append(f"- **[{source}{tag_str}]** (relevance {score:.2f}): {content}")
|
| 130 |
+
|
| 131 |
+
lines.append("\n<!-- End MnemoCore Context -->")
|
| 132 |
+
print("\n".join(lines))
|
| 133 |
+
return 0
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
def main() -> int:
|
| 139 |
+
parser = argparse.ArgumentParser(
|
| 140 |
+
description="MnemoCore universal CLI bridge",
|
| 141 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 142 |
+
)
|
| 143 |
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
| 144 |
+
|
| 145 |
+
# health
|
| 146 |
+
sub.add_parser("health", help="Check MnemoCore connectivity")
|
| 147 |
+
|
| 148 |
+
# store
|
| 149 |
+
p_store = sub.add_parser("store", help="Store a memory")
|
| 150 |
+
p_store.add_argument("text", help="Memory content")
|
| 151 |
+
p_store.add_argument("--source", default="cli", help="Source label")
|
| 152 |
+
p_store.add_argument("--tags", default="", help="Comma-separated tags")
|
| 153 |
+
p_store.add_argument("--ctx", default=None, help="Context/project ID")
|
| 154 |
+
|
| 155 |
+
# context
|
| 156 |
+
p_ctx = sub.add_parser("context", help="Fetch context as markdown")
|
| 157 |
+
p_ctx.add_argument("--query", default=None, help="Semantic query string")
|
| 158 |
+
p_ctx.add_argument("--top-k", type=int, default=5, help="Number of results")
|
| 159 |
+
p_ctx.add_argument("--ctx", default=None, help="Context/project ID")
|
| 160 |
+
|
| 161 |
+
args = parser.parse_args()
|
| 162 |
+
|
| 163 |
+
if args.cmd == "health":
|
| 164 |
+
return cmd_health()
|
| 165 |
+
|
| 166 |
+
if args.cmd == "store":
|
| 167 |
+
tags = [t.strip() for t in args.tags.split(",") if t.strip()]
|
| 168 |
+
return cmd_store(args.text, args.source, tags, args.ctx)
|
| 169 |
+
|
| 170 |
+
if args.cmd == "context":
|
| 171 |
+
return cmd_context(args.query, args.top_k, args.ctx)
|
| 172 |
+
|
| 173 |
+
return 1
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
sys.exit(main())
|
integrations/setup.ps1
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MnemoCore Integration Setup — Windows PowerShell
|
| 2 |
+
# =================================================
|
| 3 |
+
# One-command setup for Claude Code (MCP) on Windows.
|
| 4 |
+
# For full hook/wrapper support, use WSL or Git Bash.
|
| 5 |
+
#
|
| 6 |
+
# Usage:
|
| 7 |
+
# .\setup.ps1
|
| 8 |
+
# .\setup.ps1 -All
|
| 9 |
+
# .\setup.ps1 -ClaudeCode
|
| 10 |
+
|
| 11 |
+
param(
|
| 12 |
+
[switch]$All,
|
| 13 |
+
[switch]$ClaudeCode,
|
| 14 |
+
[switch]$Gemini,
|
| 15 |
+
[switch]$Aider
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
$ErrorActionPreference = "Stop"
|
| 19 |
+
|
| 20 |
+
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
| 21 |
+
$MnemoDir = Split-Path -Parent $ScriptDir
|
| 22 |
+
$BridgePy = Join-Path $ScriptDir "mnemo_bridge.py"
|
| 23 |
+
$ClaudeHome = Join-Path $env:USERPROFILE ".claude"
|
| 24 |
+
$ClaudeMcp = Join-Path $ClaudeHome "mcp.json"
|
| 25 |
+
$ClaudeSettings = Join-Path $ClaudeHome "settings.json"
|
| 26 |
+
$HooksDir = Join-Path $ScriptDir "claude_code\hooks"
|
| 27 |
+
|
| 28 |
+
function Write-Info { Write-Host "[INFO] $args" -ForegroundColor Cyan }
|
| 29 |
+
function Write-Success { Write-Host "[OK] $args" -ForegroundColor Green }
|
| 30 |
+
function Write-Warn { Write-Host "[WARN] $args" -ForegroundColor Yellow }
|
| 31 |
+
function Write-Err { Write-Host "[ERROR] $args" -ForegroundColor Red }
|
| 32 |
+
|
| 33 |
+
# ── Prerequisite checks ────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
Write-Info "Checking Python requests..."
|
| 36 |
+
$requestsCheck = python -c "import requests; print('ok')" 2>&1
|
| 37 |
+
if ($requestsCheck -ne "ok") {
|
| 38 |
+
Write-Warn "Installing requests..."
|
| 39 |
+
python -m pip install --quiet requests
|
| 40 |
+
}
|
| 41 |
+
Write-Success "Python requests available"
|
| 42 |
+
|
| 43 |
+
Write-Info "Checking MnemoCore connectivity..."
|
| 44 |
+
$healthCheck = python "$BridgePy" health 2>&1
|
| 45 |
+
if ($LASTEXITCODE -eq 0) {
|
| 46 |
+
Write-Success "MnemoCore is online"
|
| 47 |
+
} else {
|
| 48 |
+
Write-Warn "MnemoCore offline — start it first:"
|
| 49 |
+
Write-Warn " cd $MnemoDir"
|
| 50 |
+
Write-Warn " uvicorn mnemocore.api.main:app --port 8100"
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# ── Claude Code MCP Setup ─────────────────────────────────────────────────
|
| 54 |
+
|
| 55 |
+
function Setup-ClaudeCode {
|
| 56 |
+
Write-Info "Setting up Claude Code integration..."
|
| 57 |
+
|
| 58 |
+
if (-not (Test-Path $ClaudeHome)) { New-Item -ItemType Directory -Path $ClaudeHome | Out-Null }
|
| 59 |
+
New-Item -ItemType Directory -Path (Join-Path $ClaudeHome "mnemo_context") -Force | Out-Null
|
| 60 |
+
|
| 61 |
+
# MCP config
|
| 62 |
+
$McpTemplate = Get-Content (Join-Path $ScriptDir "claude_code\mcp_config.json") -Raw
|
| 63 |
+
$McpTemplate = $McpTemplate `
|
| 64 |
+
-replace '\$\{MNEMOCORE_DIR\}', $MnemoDir.Replace('\', '/') `
|
| 65 |
+
-replace '\$\{HAIM_API_KEY\}', ($env:HAIM_API_KEY ?? '')
|
| 66 |
+
|
| 67 |
+
if (-not (Test-Path $ClaudeMcp)) {
|
| 68 |
+
'{"mcpServers":{}}' | Set-Content $ClaudeMcp
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
$Existing = Get-Content $ClaudeMcp -Raw | ConvertFrom-Json
|
| 72 |
+
$New = $McpTemplate | ConvertFrom-Json
|
| 73 |
+
if (-not $Existing.mcpServers) { $Existing | Add-Member -MemberType NoteProperty -Name mcpServers -Value @{} }
|
| 74 |
+
$New.mcpServers.PSObject.Properties | ForEach-Object {
|
| 75 |
+
$Existing.mcpServers | Add-Member -MemberType NoteProperty -Name $_.Name -Value $_.Value -Force
|
| 76 |
+
}
|
| 77 |
+
$Existing | ConvertTo-Json -Depth 10 | Set-Content $ClaudeMcp
|
| 78 |
+
Write-Success "MCP server registered in $ClaudeMcp"
|
| 79 |
+
|
| 80 |
+
# Hooks
|
| 81 |
+
if (-not (Test-Path $ClaudeSettings)) { '{}' | Set-Content $ClaudeSettings }
|
| 82 |
+
$Settings = Get-Content $ClaudeSettings -Raw | ConvertFrom-Json
|
| 83 |
+
|
| 84 |
+
if (-not $Settings.hooks) {
|
| 85 |
+
$Settings | Add-Member -MemberType NoteProperty -Name hooks -Value @{}
|
| 86 |
+
}
|
| 87 |
+
$hooksObj = $Settings.hooks
|
| 88 |
+
|
| 89 |
+
$preCmd = "python `"$($HooksDir.Replace('\','/'))/pre_session_inject.py`""
|
| 90 |
+
$postCmd = "python `"$($HooksDir.Replace('\','/'))/post_tool_store.py`""
|
| 91 |
+
|
| 92 |
+
if (-not $hooksObj.PreToolUse) {
|
| 93 |
+
$hooksObj | Add-Member -MemberType NoteProperty -Name PreToolUse -Value @()
|
| 94 |
+
}
|
| 95 |
+
if (-not $hooksObj.PostToolUse) {
|
| 96 |
+
$hooksObj | Add-Member -MemberType NoteProperty -Name PostToolUse -Value @()
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
$existingPre = $hooksObj.PreToolUse | ForEach-Object { $_.hooks[0].command }
|
| 100 |
+
if ($preCmd -notin $existingPre) {
|
| 101 |
+
$hooksObj.PreToolUse += @{matcher=".*"; hooks=@(@{type="command"; command=$preCmd})}
|
| 102 |
+
}
|
| 103 |
+
$existingPost = $hooksObj.PostToolUse | ForEach-Object { $_.hooks[0].command }
|
| 104 |
+
if ($postCmd -notin $existingPost) {
|
| 105 |
+
$hooksObj.PostToolUse += @{matcher="Edit|Write|MultiEdit"; hooks=@(@{type="command"; command=$postCmd})}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
$Settings | ConvertTo-Json -Depth 10 | Set-Content $ClaudeSettings
|
| 109 |
+
Write-Success "Hooks installed in $ClaudeSettings"
|
| 110 |
+
|
| 111 |
+
# CLAUDE.md snippet
|
| 112 |
+
$ClaudeMd = Join-Path $MnemoDir "CLAUDE.md"
|
| 113 |
+
$Snippet = Get-Content (Join-Path $ScriptDir "claude_code\CLAUDE_memory_snippet.md") -Raw
|
| 114 |
+
$Marker = "# MnemoCore — Persistent Cognitive Memory"
|
| 115 |
+
if (Test-Path $ClaudeMd) {
|
| 116 |
+
$Current = Get-Content $ClaudeMd -Raw
|
| 117 |
+
if ($Current -notlike "*$Marker*") {
|
| 118 |
+
Add-Content $ClaudeMd "`n$Snippet"
|
| 119 |
+
Write-Success "Memory instructions appended to CLAUDE.md"
|
| 120 |
+
} else {
|
| 121 |
+
Write-Info "CLAUDE.md already contains MnemoCore instructions"
|
| 122 |
+
}
|
| 123 |
+
} else {
|
| 124 |
+
$Snippet | Set-Content $ClaudeMd
|
| 125 |
+
Write-Success "Created CLAUDE.md with memory instructions"
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
Write-Success "Claude Code integration complete"
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
# ── Main ───────────────────────────────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
Write-Host ""
|
| 134 |
+
Write-Host "╔══════════════════════════════════════════╗" -ForegroundColor Magenta
|
| 135 |
+
Write-Host "║ MnemoCore Integration Setup (Win) ║" -ForegroundColor Magenta
|
| 136 |
+
Write-Host "╚══════════════════════════════════════════╝" -ForegroundColor Magenta
|
| 137 |
+
Write-Host ""
|
| 138 |
+
|
| 139 |
+
if (-not ($All -or $ClaudeCode -or $Gemini -or $Aider)) {
|
| 140 |
+
Write-Host "Choose integrations:"
|
| 141 |
+
Write-Host " 1) Claude Code (MCP + hooks + CLAUDE.md) — recommended"
|
| 142 |
+
Write-Host " 4) All"
|
| 143 |
+
$choice = Read-Host "Enter choice"
|
| 144 |
+
switch ($choice) {
|
| 145 |
+
"1" { $ClaudeCode = $true }
|
| 146 |
+
"4" { $All = $true }
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if ($All -or $ClaudeCode) { Setup-ClaudeCode }
|
| 151 |
+
|
| 152 |
+
Write-Host ""
|
| 153 |
+
Write-Host "╔══════════════════════════════════════════╗" -ForegroundColor Green
|
| 154 |
+
Write-Host "║ Setup complete! ║" -ForegroundColor Green
|
| 155 |
+
Write-Host "║ ║" -ForegroundColor Green
|
| 156 |
+
Write-Host "║ Test: python integrations/mnemo_bridge.py health" -ForegroundColor Green
|
| 157 |
+
Write-Host "╚══════════════════════════════════════════╝" -ForegroundColor Green
|
| 158 |
+
Write-Host ""
|
integrations/setup.sh
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# MnemoCore Integration Setup
|
| 3 |
+
# ============================
|
| 4 |
+
# One-command setup for Claude Code, Gemini CLI, Aider, and universal tools.
|
| 5 |
+
#
|
| 6 |
+
# Usage:
|
| 7 |
+
# ./setup.sh # Interactive, choose integrations
|
| 8 |
+
# ./setup.sh --all # Enable all integrations
|
| 9 |
+
# ./setup.sh --claude-code # Claude Code only
|
| 10 |
+
# ./setup.sh --gemini # Gemini CLI only
|
| 11 |
+
# ./setup.sh --aider # Aider only
|
| 12 |
+
#
|
| 13 |
+
# Prerequisites:
|
| 14 |
+
# - Python 3.10+ with 'requests' package
|
| 15 |
+
# - MnemoCore running (uvicorn mnemocore.api.main:app --port 8100)
|
| 16 |
+
# - HAIM_API_KEY environment variable set
|
| 17 |
+
|
| 18 |
+
set -euo pipefail
|
| 19 |
+
|
| 20 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 21 |
+
MNEMOCORE_DIR="$(realpath "$SCRIPT_DIR/..")"
|
| 22 |
+
BRIDGE_PY="$SCRIPT_DIR/mnemo_bridge.py"
|
| 23 |
+
HOOKS_DIR="$SCRIPT_DIR/claude_code/hooks"
|
| 24 |
+
|
| 25 |
+
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
|
| 26 |
+
CLAUDE_MCP="$HOME/.claude/mcp.json"
|
| 27 |
+
|
| 28 |
+
RED='\033[0;31m'
|
| 29 |
+
GREEN='\033[0;32m'
|
| 30 |
+
YELLOW='\033[1;33m'
|
| 31 |
+
BLUE='\033[0;34m'
|
| 32 |
+
NC='\033[0m'
|
| 33 |
+
|
| 34 |
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
| 35 |
+
|
| 36 |
+
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
| 37 |
+
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
| 38 |
+
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
| 39 |
+
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
| 40 |
+
|
| 41 |
+
check_python() {
|
| 42 |
+
if ! python3 -c "import requests" &>/dev/null; then
|
| 43 |
+
warn "Python 'requests' not installed. Installing..."
|
| 44 |
+
python3 -m pip install --quiet requests
|
| 45 |
+
success "requests installed"
|
| 46 |
+
fi
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
check_mnemocore() {
|
| 50 |
+
info "Checking MnemoCore connectivity..."
|
| 51 |
+
if python3 "$BRIDGE_PY" health &>/dev/null; then
|
| 52 |
+
success "MnemoCore is online"
|
| 53 |
+
return 0
|
| 54 |
+
else
|
| 55 |
+
warn "MnemoCore is not running. Start it first with:"
|
| 56 |
+
warn " cd $MNEMOCORE_DIR && uvicorn mnemocore.api.main:app --port 8100"
|
| 57 |
+
return 1
|
| 58 |
+
fi
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
merge_json() {
|
| 62 |
+
# Merge JSON object $2 into file $1 (creates if not exists)
|
| 63 |
+
local target="$1"
|
| 64 |
+
local fragment="$2"
|
| 65 |
+
|
| 66 |
+
if [[ ! -f "$target" ]]; then
|
| 67 |
+
echo '{}' > "$target"
|
| 68 |
+
fi
|
| 69 |
+
|
| 70 |
+
python3 - <<PYEOF
|
| 71 |
+
import json, sys
|
| 72 |
+
with open("$target") as f:
|
| 73 |
+
existing = json.load(f)
|
| 74 |
+
with open("$fragment") as f:
|
| 75 |
+
new = json.load(f)
|
| 76 |
+
# Deep merge (one level)
|
| 77 |
+
for k, v in new.items():
|
| 78 |
+
if k.startswith("_"):
|
| 79 |
+
continue
|
| 80 |
+
if k in existing and isinstance(existing[k], dict) and isinstance(v, dict):
|
| 81 |
+
existing[k].update(v)
|
| 82 |
+
else:
|
| 83 |
+
existing[k] = v
|
| 84 |
+
with open("$target", "w") as f:
|
| 85 |
+
json.dump(existing, f, indent=2)
|
| 86 |
+
print("Merged successfully")
|
| 87 |
+
PYEOF
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# ── Integration: Claude Code ───────────────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
setup_claude_code() {
|
| 93 |
+
info "Setting up Claude Code integration..."
|
| 94 |
+
mkdir -p "$HOME/.claude/mnemo_context"
|
| 95 |
+
|
| 96 |
+
# 1. MCP Server
|
| 97 |
+
info " Configuring MCP server..."
|
| 98 |
+
local mcp_tmp
|
| 99 |
+
mcp_tmp="$(mktemp /tmp/mnemo_mcp_XXXXXX.json)"
|
| 100 |
+
sed \
|
| 101 |
+
-e "s|\${MNEMOCORE_DIR}|$MNEMOCORE_DIR|g" \
|
| 102 |
+
-e "s|\${HAIM_API_KEY}|${HAIM_API_KEY:-}|g" \
|
| 103 |
+
"$SCRIPT_DIR/claude_code/mcp_config.json" > "$mcp_tmp"
|
| 104 |
+
|
| 105 |
+
if [[ ! -f "$CLAUDE_MCP" ]]; then
|
| 106 |
+
echo '{"mcpServers": {}}' > "$CLAUDE_MCP"
|
| 107 |
+
fi
|
| 108 |
+
|
| 109 |
+
python3 - "$CLAUDE_MCP" "$mcp_tmp" <<'PYEOF'
|
| 110 |
+
import json, sys
|
| 111 |
+
with open(sys.argv[1]) as f:
|
| 112 |
+
existing = json.load(f)
|
| 113 |
+
with open(sys.argv[2]) as f:
|
| 114 |
+
new = json.load(f)
|
| 115 |
+
existing.setdefault("mcpServers", {}).update(new.get("mcpServers", {}))
|
| 116 |
+
with open(sys.argv[1], "w") as f:
|
| 117 |
+
json.dump(existing, f, indent=2)
|
| 118 |
+
PYEOF
|
| 119 |
+
rm -f "$mcp_tmp"
|
| 120 |
+
success " MCP server registered in $CLAUDE_MCP"
|
| 121 |
+
|
| 122 |
+
# 2. Hooks
|
| 123 |
+
info " Installing hooks in $CLAUDE_SETTINGS..."
|
| 124 |
+
if [[ ! -f "$CLAUDE_SETTINGS" ]]; then
|
| 125 |
+
echo '{}' > "$CLAUDE_SETTINGS"
|
| 126 |
+
fi
|
| 127 |
+
|
| 128 |
+
python3 - "$CLAUDE_SETTINGS" "$HOOKS_DIR" <<PYEOF
|
| 129 |
+
import json, sys
|
| 130 |
+
settings_path = sys.argv[1]
|
| 131 |
+
hooks_dir = sys.argv[2]
|
| 132 |
+
with open(settings_path) as f:
|
| 133 |
+
settings = json.load(f)
|
| 134 |
+
|
| 135 |
+
hooks = settings.setdefault("hooks", {})
|
| 136 |
+
pre = hooks.setdefault("PreToolUse", [])
|
| 137 |
+
post = hooks.setdefault("PostToolUse", [])
|
| 138 |
+
|
| 139 |
+
pre_hook = {
|
| 140 |
+
"matcher": ".*",
|
| 141 |
+
"hooks": [{"type": "command", "command": f"python3 {hooks_dir}/pre_session_inject.py"}]
|
| 142 |
+
}
|
| 143 |
+
post_hook = {
|
| 144 |
+
"matcher": "Edit|Write|MultiEdit",
|
| 145 |
+
"hooks": [{"type": "command", "command": f"python3 {hooks_dir}/post_tool_store.py"}]
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
# Only add if not already present
|
| 149 |
+
pre_cmds = [h["hooks"][0]["command"] for h in pre if h.get("hooks")]
|
| 150 |
+
post_cmds = [h["hooks"][0]["command"] for h in post if h.get("hooks")]
|
| 151 |
+
|
| 152 |
+
if pre_hook["hooks"][0]["command"] not in pre_cmds:
|
| 153 |
+
pre.append(pre_hook)
|
| 154 |
+
if post_hook["hooks"][0]["command"] not in post_cmds:
|
| 155 |
+
post.append(post_hook)
|
| 156 |
+
|
| 157 |
+
with open(settings_path, "w") as f:
|
| 158 |
+
json.dump(settings, f, indent=2)
|
| 159 |
+
print("Hooks installed")
|
| 160 |
+
PYEOF
|
| 161 |
+
success " Hooks installed in $CLAUDE_SETTINGS"
|
| 162 |
+
|
| 163 |
+
# 3. CLAUDE.md snippet — append if not already present
|
| 164 |
+
local clause_md="$MNEMOCORE_DIR/CLAUDE.md"
|
| 165 |
+
local snippet="$SCRIPT_DIR/claude_code/CLAUDE_memory_snippet.md"
|
| 166 |
+
local marker="# MnemoCore — Persistent Cognitive Memory"
|
| 167 |
+
if [[ -f "$clause_md" ]] && grep -qF "$marker" "$clause_md"; then
|
| 168 |
+
info " CLAUDE.md already contains MnemoCore memory instructions"
|
| 169 |
+
else
|
| 170 |
+
echo "" >> "$clause_md"
|
| 171 |
+
cat "$snippet" >> "$clause_md"
|
| 172 |
+
success " Memory instructions appended to $clause_md"
|
| 173 |
+
fi
|
| 174 |
+
|
| 175 |
+
success "Claude Code integration complete"
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
# ── Integration: Gemini CLI ────────────────────────────────────────────────
|
| 179 |
+
|
| 180 |
+
setup_gemini() {
|
| 181 |
+
info "Setting up Gemini CLI integration..."
|
| 182 |
+
|
| 183 |
+
# Make wrapper executable
|
| 184 |
+
chmod +x "$SCRIPT_DIR/gemini_cli/gemini_wrap.sh"
|
| 185 |
+
|
| 186 |
+
# Append to GEMINI.md if it exists
|
| 187 |
+
local gemini_md="$MNEMOCORE_DIR/GEMINI.md"
|
| 188 |
+
local snippet="$SCRIPT_DIR/gemini_cli/GEMINI_memory_snippet.md"
|
| 189 |
+
local marker="# MnemoCore — Persistent Cognitive Memory"
|
| 190 |
+
if [[ -f "$gemini_md" ]] && grep -qF "$marker" "$gemini_md"; then
|
| 191 |
+
info " GEMINI.md already contains MnemoCore instructions"
|
| 192 |
+
elif [[ -f "$gemini_md" ]]; then
|
| 193 |
+
echo "" >> "$gemini_md"
|
| 194 |
+
cat "$snippet" >> "$gemini_md"
|
| 195 |
+
success " Memory instructions appended to $gemini_md"
|
| 196 |
+
else
|
| 197 |
+
cp "$snippet" "$gemini_md"
|
| 198 |
+
success " Created $gemini_md with memory instructions"
|
| 199 |
+
fi
|
| 200 |
+
|
| 201 |
+
success "Gemini CLI integration complete"
|
| 202 |
+
info " Use: $SCRIPT_DIR/gemini_cli/gemini_wrap.sh [args] instead of 'gemini'"
|
| 203 |
+
info " Or alias: alias gemini='$SCRIPT_DIR/gemini_cli/gemini_wrap.sh'"
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
# ── Integration: Aider ─────────────────────────────────────────────────────
|
| 207 |
+
|
| 208 |
+
setup_aider() {
|
| 209 |
+
info "Setting up Aider integration..."
|
| 210 |
+
chmod +x "$SCRIPT_DIR/aider/aider_wrap.sh"
|
| 211 |
+
|
| 212 |
+
# Write .env fragment for aider
|
| 213 |
+
local aider_env="$MNEMOCORE_DIR/.aider.env"
|
| 214 |
+
cat > "$aider_env" <<EOF
|
| 215 |
+
# MnemoCore environment for Aider
|
| 216 |
+
export MNEMOCORE_URL="${MNEMOCORE_URL:-http://localhost:8100}"
|
| 217 |
+
export HAIM_API_KEY="${HAIM_API_KEY:-}"
|
| 218 |
+
export BRIDGE_PY="$BRIDGE_PY"
|
| 219 |
+
EOF
|
| 220 |
+
success "Aider integration complete"
|
| 221 |
+
info " Use: $SCRIPT_DIR/aider/aider_wrap.sh [args] instead of 'aider'"
|
| 222 |
+
info " Or alias: alias aider='$SCRIPT_DIR/aider/aider_wrap.sh'"
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
# ── Integration: Universal scripts ─────────────────────────────────────────
|
| 226 |
+
|
| 227 |
+
setup_universal() {
|
| 228 |
+
chmod +x "$SCRIPT_DIR/universal/context_inject.sh"
|
| 229 |
+
chmod +x "$SCRIPT_DIR/universal/store_session.sh"
|
| 230 |
+
success "Universal scripts ready"
|
| 231 |
+
info " Context: $SCRIPT_DIR/universal/context_inject.sh [query] [top-k]"
|
| 232 |
+
info " Store: $SCRIPT_DIR/universal/store_session.sh [text] [tags] [ctx]"
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
# ── Main ───────────────────────────────────────────────────────────────────
|
| 236 |
+
|
| 237 |
+
echo ""
|
| 238 |
+
echo "╔══════════════════════════════════════════════════════╗"
|
| 239 |
+
echo "║ MnemoCore Integration Setup ║"
|
| 240 |
+
echo "╚══════════════════════════════════════════════════════╝"
|
| 241 |
+
echo ""
|
| 242 |
+
|
| 243 |
+
# Check prerequisites
|
| 244 |
+
check_python
|
| 245 |
+
check_mnemocore || true # Non-fatal — offline check is a warning
|
| 246 |
+
|
| 247 |
+
DO_ALL=false
|
| 248 |
+
DO_CLAUDE=false
|
| 249 |
+
DO_GEMINI=false
|
| 250 |
+
DO_AIDER=false
|
| 251 |
+
|
| 252 |
+
for arg in "$@"; do
|
| 253 |
+
case "$arg" in
|
| 254 |
+
--all) DO_ALL=true ;;
|
| 255 |
+
--claude-code) DO_CLAUDE=true ;;
|
| 256 |
+
--gemini) DO_GEMINI=true ;;
|
| 257 |
+
--aider) DO_AIDER=true ;;
|
| 258 |
+
esac
|
| 259 |
+
done
|
| 260 |
+
|
| 261 |
+
if ! $DO_ALL && ! $DO_CLAUDE && ! $DO_GEMINI && ! $DO_AIDER; then
|
| 262 |
+
echo "Which integrations do you want to enable?"
|
| 263 |
+
echo " 1) Claude Code (MCP + hooks + CLAUDE.md)"
|
| 264 |
+
echo " 2) Gemini CLI (GEMINI.md + wrapper)"
|
| 265 |
+
echo " 3) Aider (wrapper script)"
|
| 266 |
+
echo " 4) All of the above"
|
| 267 |
+
echo ""
|
| 268 |
+
read -rp "Enter choice(s) [e.g. 1 3 or 4]: " CHOICES
|
| 269 |
+
|
| 270 |
+
for c in $CHOICES; do
|
| 271 |
+
case "$c" in
|
| 272 |
+
1) DO_CLAUDE=true ;;
|
| 273 |
+
2) DO_GEMINI=true ;;
|
| 274 |
+
3) DO_AIDER=true ;;
|
| 275 |
+
4) DO_ALL=true ;;
|
| 276 |
+
esac
|
| 277 |
+
done
|
| 278 |
+
fi
|
| 279 |
+
|
| 280 |
+
if $DO_ALL; then
|
| 281 |
+
DO_CLAUDE=true; DO_GEMINI=true; DO_AIDER=true
|
| 282 |
+
fi
|
| 283 |
+
|
| 284 |
+
echo ""
|
| 285 |
+
setup_universal
|
| 286 |
+
|
| 287 |
+
$DO_CLAUDE && setup_claude_code
|
| 288 |
+
$DO_GEMINI && setup_gemini
|
| 289 |
+
$DO_AIDER && setup_aider
|
| 290 |
+
|
| 291 |
+
echo ""
|
| 292 |
+
echo "╔══════════════════════════════════════════════════════╗"
|
| 293 |
+
echo "║ Setup complete! Quick start: ║"
|
| 294 |
+
echo "║ ║"
|
| 295 |
+
echo "║ Test bridge: python3 integrations/mnemo_bridge.py health"
|
| 296 |
+
echo "║ Get context: integrations/universal/context_inject.sh"
|
| 297 |
+
echo "║ Store memory: integrations/universal/store_session.sh"
|
| 298 |
+
echo "╚══════════════════════════════════════════════════════╝"
|
| 299 |
+
echo ""
|
integrations/universal/context_inject.sh
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# context_inject.sh — Universal MnemoCore context provider
|
| 3 |
+
# =========================================================
|
| 4 |
+
# Outputs MnemoCore memory context as plain text/markdown.
|
| 5 |
+
# Pipe or include the output into any tool that accepts system prompts.
|
| 6 |
+
#
|
| 7 |
+
# Usage:
|
| 8 |
+
# ./context_inject.sh # General context
|
| 9 |
+
# ./context_inject.sh "bug fix async" # Focused query
|
| 10 |
+
# ./context_inject.sh "" 10 # Top-10 results
|
| 11 |
+
#
|
| 12 |
+
# Examples:
|
| 13 |
+
# codex --system "$(./context_inject.sh)" ...
|
| 14 |
+
# openai-cli --system-prompt "$(./context_inject.sh 'auth')"
|
| 15 |
+
|
| 16 |
+
set -euo pipefail
|
| 17 |
+
|
| 18 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 19 |
+
BRIDGE_PY="${BRIDGE_PY:-$(realpath "$SCRIPT_DIR/../mnemo_bridge.py")}"
|
| 20 |
+
|
| 21 |
+
QUERY="${1:-}"
|
| 22 |
+
TOP_K="${2:-6}"
|
| 23 |
+
|
| 24 |
+
ARGS=(context --top-k "$TOP_K")
|
| 25 |
+
if [[ -n "$QUERY" ]]; then
|
| 26 |
+
ARGS+=(--query "$QUERY")
|
| 27 |
+
fi
|
| 28 |
+
|
| 29 |
+
python3 "$BRIDGE_PY" "${ARGS[@]}" 2>/dev/null || true
|
integrations/universal/store_session.sh
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# store_session.sh — Store session outcomes into MnemoCore
|
| 3 |
+
# =========================================================
|
| 4 |
+
# Call this at the end of an AI coding session to persist key findings.
|
| 5 |
+
#
|
| 6 |
+
# Usage (interactive):
|
| 7 |
+
# ./store_session.sh
|
| 8 |
+
#
|
| 9 |
+
# Usage (non-interactive / scripted):
|
| 10 |
+
# ./store_session.sh "Fixed race condition in tier_manager.py" "bugfix,async" "my-project"
|
| 11 |
+
|
| 12 |
+
set -euo pipefail
|
| 13 |
+
|
| 14 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 15 |
+
BRIDGE_PY="${BRIDGE_PY:-$(realpath "$SCRIPT_DIR/../mnemo_bridge.py")}"
|
| 16 |
+
|
| 17 |
+
if [[ $# -ge 1 ]]; then
|
| 18 |
+
CONTENT="$1"
|
| 19 |
+
TAGS="${2:-cli}"
|
| 20 |
+
CTX="${3:-}"
|
| 21 |
+
else
|
| 22 |
+
echo "Enter memory content (what was done/decided/fixed):"
|
| 23 |
+
read -r CONTENT
|
| 24 |
+
echo "Tags (comma-separated, e.g. bugfix,python,auth):"
|
| 25 |
+
read -r TAGS
|
| 26 |
+
echo "Context/project ID (optional, press Enter to skip):"
|
| 27 |
+
read -r CTX
|
| 28 |
+
fi
|
| 29 |
+
|
| 30 |
+
if [[ -z "$CONTENT" ]]; then
|
| 31 |
+
echo "No content provided, nothing stored." >&2
|
| 32 |
+
exit 0
|
| 33 |
+
fi
|
| 34 |
+
|
| 35 |
+
ARGS=(store "$CONTENT" --source "manual-session" --tags "$TAGS")
|
| 36 |
+
if [[ -n "$CTX" ]]; then
|
| 37 |
+
ARGS+=(--ctx "$CTX")
|
| 38 |
+
fi
|
| 39 |
+
|
| 40 |
+
python3 "$BRIDGE_PY" "${ARGS[@]}"
|
mnemocore_verify.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import shutil
|
| 4 |
+
import pytest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
# Set dummy test config environment
|
| 9 |
+
os.environ["HAIM_API_KEY"] = "test-key"
|
| 10 |
+
|
| 11 |
+
from mnemocore.core.config import HAIMConfig, PathsConfig, TierConfig
|
| 12 |
+
from mnemocore.core.binary_hdv import TextEncoder, BinaryHDV
|
| 13 |
+
from mnemocore.core.hnsw_index import HNSWIndexManager
|
| 14 |
+
from mnemocore.core.engine import HAIMEngine
|
| 15 |
+
from mnemocore.core.tier_manager import TierManager
|
| 16 |
+
from unittest.mock import patch
|
| 17 |
+
|
| 18 |
+
@pytest.fixture(autouse=True)
|
| 19 |
+
def setup_test_env():
|
| 20 |
+
# Force all components to use test_data_verify as their data dir
|
| 21 |
+
# to prevent polluting/reading the user's real ./data folder
|
| 22 |
+
test_dir = Path("./test_data_verify")
|
| 23 |
+
test_dir.mkdir(exist_ok=True)
|
| 24 |
+
|
| 25 |
+
cfg = HAIMConfig(
|
| 26 |
+
paths=PathsConfig(
|
| 27 |
+
data_dir=str(test_dir),
|
| 28 |
+
warm_mmap_dir=str(test_dir / "warm"),
|
| 29 |
+
cold_archive_dir=str(test_dir / "cold")
|
| 30 |
+
)
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
with patch('mnemocore.core.config.get_config', return_value=cfg), \
|
| 34 |
+
patch('mnemocore.core.hnsw_index.get_config', return_value=cfg), \
|
| 35 |
+
patch('mnemocore.core.engine.get_config', return_value=cfg):
|
| 36 |
+
yield cfg
|
| 37 |
+
|
| 38 |
+
if test_dir.exists():
|
| 39 |
+
shutil.rmtree(test_dir)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.mark.asyncio
|
| 43 |
+
async def test_text_encoder_normalization():
|
| 44 |
+
"""Verify BUG-02: Text normalization fixes identical string variances"""
|
| 45 |
+
encoder = TextEncoder(dimension=1024)
|
| 46 |
+
hdv1 = encoder.encode("Hello World")
|
| 47 |
+
hdv2 = encoder.encode("hello, world!")
|
| 48 |
+
|
| 49 |
+
assert (hdv1.data == hdv2.data).all(), "Normalization failed: Different HDVs for identical texts"
|
| 50 |
+
|
| 51 |
+
def test_hnsw_singleton():
|
| 52 |
+
"""Verify BUG-08: HNSWIndexManager is a thread-safe singleton"""
|
| 53 |
+
HNSWIndexManager._instance = None
|
| 54 |
+
idx1 = HNSWIndexManager(dimension=1024)
|
| 55 |
+
idx2 = HNSWIndexManager(dimension=1024)
|
| 56 |
+
assert idx1 is idx2, "HNSWIndexManager is not a singleton"
|
| 57 |
+
|
| 58 |
+
def test_hnsw_index_add_search():
|
| 59 |
+
"""Verify BUG-01 & BUG-03: Vector cache lost / Position mapping"""
|
| 60 |
+
HNSWIndexManager._instance = None
|
| 61 |
+
idx = HNSWIndexManager(dimension=1024)
|
| 62 |
+
|
| 63 |
+
# Optional cleanup if it's reused
|
| 64 |
+
idx._id_map = []
|
| 65 |
+
idx._vector_store = []
|
| 66 |
+
if idx._index:
|
| 67 |
+
idx._index.reset()
|
| 68 |
+
|
| 69 |
+
vec1 = BinaryHDV.random(1024)
|
| 70 |
+
vec2 = BinaryHDV.random(1024)
|
| 71 |
+
|
| 72 |
+
idx.add("test_node_1", vec1.data)
|
| 73 |
+
idx.add("test_node_2", vec2.data)
|
| 74 |
+
|
| 75 |
+
assert "test_node_1" in idx._id_map, "ID Map does not contain node 1"
|
| 76 |
+
assert "test_node_2" in idx._id_map, "ID Map does not contain node 2"
|
| 77 |
+
|
| 78 |
+
# The search should return test_node_1 as the top result for vec1.data
|
| 79 |
+
res = idx.search(vec1.data, top_k=1)
|
| 80 |
+
assert res[0][0] == "test_node_1", f"Incorrect search return: {res}"
|
| 81 |
+
|
| 82 |
+
@pytest.mark.asyncio
|
| 83 |
+
async def test_agent_isolation():
|
| 84 |
+
"""Verify BUG-09: Agent namespace isolation via engine and tier manager"""
|
| 85 |
+
HNSWIndexManager._instance = None
|
| 86 |
+
|
| 87 |
+
test_data_dir = Path("./test_data_verify")
|
| 88 |
+
test_data_dir.mkdir(exist_ok=True)
|
| 89 |
+
|
| 90 |
+
config = HAIMConfig(
|
| 91 |
+
qdrant=None,
|
| 92 |
+
paths=PathsConfig(
|
| 93 |
+
data_dir=str(test_data_dir),
|
| 94 |
+
warm_mmap_dir=str(test_data_dir / "warm"),
|
| 95 |
+
cold_archive_dir=str(test_data_dir / "cold")
|
| 96 |
+
),
|
| 97 |
+
tiers_hot=TierConfig(max_memories=1000, ltp_threshold_min=0.0)
|
| 98 |
+
)
|
| 99 |
+
# Prevent newly created memories (LTP=0.5) from being eagerly demoted
|
| 100 |
+
# We run purely local/in-memory for this unit test
|
| 101 |
+
|
| 102 |
+
tier_manager = TierManager(config=config, qdrant_store=None)
|
| 103 |
+
engine = HAIMEngine(
|
| 104 |
+
persist_path=str(test_data_dir / "memory.jsonl"),
|
| 105 |
+
config=config,
|
| 106 |
+
tier_manager=tier_manager
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
await engine.initialize()
|
| 111 |
+
|
| 112 |
+
# Store two memories, isolated
|
| 113 |
+
await engine.store("Secret logic for agent 1", metadata={"agent_id": "agent_alpha"})
|
| 114 |
+
await engine.store("Public logic for agent 2", metadata={"agent_id": "agent_beta"})
|
| 115 |
+
|
| 116 |
+
# Search global
|
| 117 |
+
res_global = await engine.query("logic", top_k=5)
|
| 118 |
+
# We expect 2 given we just pushed 2
|
| 119 |
+
assert len(res_global) >= 2, f"Global search should return at least 2 memories, got {len(res_global)}"
|
| 120 |
+
|
| 121 |
+
# Search isolated by agent_alpha
|
| 122 |
+
res_isolated = await engine.query("logic", top_k=5, metadata_filter={"agent_id": "agent_alpha"})
|
| 123 |
+
|
| 124 |
+
assert len(res_isolated) > 0, "Should find at least 1 memory for agent_alpha"
|
| 125 |
+
for nid, score in res_isolated:
|
| 126 |
+
node = await engine.get_memory(nid)
|
| 127 |
+
assert node.metadata.get("agent_id") == "agent_alpha", "Found leaked memory from another agent namespace!"
|
| 128 |
+
|
| 129 |
+
finally:
|
| 130 |
+
await engine.close()
|
| 131 |
+
# Clean up test dir
|
| 132 |
+
if test_data_dir.exists():
|
| 133 |
+
shutil.rmtree(test_data_dir)
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
pytest.main(["-v", __file__])
|
src/mnemocore/agent_interface.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cognitive Memory Client
|
| 3 |
+
=======================
|
| 4 |
+
The high-level facade for autonomous agents to interact with the MnemoCore AGI Memory Substrate.
|
| 5 |
+
Provides easy methods for observation, episodic sequence tracking, and working memory recall.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import List, Optional, Any, Tuple
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
from .core.engine import HAIMEngine
|
| 12 |
+
from .core.working_memory import WorkingMemoryService, WorkingMemoryItem
|
| 13 |
+
from .core.episodic_store import EpisodicStoreService
|
| 14 |
+
from .core.semantic_store import SemanticStoreService
|
| 15 |
+
from .core.procedural_store import ProceduralStoreService
|
| 16 |
+
from .core.meta_memory import MetaMemoryService, SelfImprovementProposal
|
| 17 |
+
from .core.memory_model import Procedure
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
class CognitiveMemoryClient:
|
| 22 |
+
"""
|
| 23 |
+
Plug-and-play cognitive memory facade for agent frameworks (LangGraph, AutoGen, OpenClaw, etc.).
|
| 24 |
+
"""
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
engine: HAIMEngine,
|
| 28 |
+
wm: WorkingMemoryService,
|
| 29 |
+
episodic: EpisodicStoreService,
|
| 30 |
+
semantic: SemanticStoreService,
|
| 31 |
+
procedural: ProceduralStoreService,
|
| 32 |
+
meta: MetaMemoryService,
|
| 33 |
+
):
|
| 34 |
+
self.engine = engine
|
| 35 |
+
self.wm = wm
|
| 36 |
+
self.episodic = episodic
|
| 37 |
+
self.semantic = semantic
|
| 38 |
+
self.procedural = procedural
|
| 39 |
+
self.meta = meta
|
| 40 |
+
|
| 41 |
+
# --- Observation & WM ---
|
| 42 |
+
|
| 43 |
+
def observe(self, agent_id: str, content: str, kind: str = "observation", importance: float = 0.5, tags: Optional[List[str]] = None, **meta) -> str:
|
| 44 |
+
"""
|
| 45 |
+
Push a new observation or thought directly into the agent's short-term Working Memory.
|
| 46 |
+
"""
|
| 47 |
+
import uuid
|
| 48 |
+
from datetime import datetime
|
| 49 |
+
item_id = f"wm_{uuid.uuid4().hex[:8]}"
|
| 50 |
+
|
| 51 |
+
item = WorkingMemoryItem(
|
| 52 |
+
id=item_id,
|
| 53 |
+
agent_id=agent_id,
|
| 54 |
+
created_at=datetime.utcnow(),
|
| 55 |
+
ttl_seconds=3600, # 1 hour default
|
| 56 |
+
content=content,
|
| 57 |
+
kind=kind, # type: ignore
|
| 58 |
+
importance=importance,
|
| 59 |
+
tags=tags or [],
|
| 60 |
+
hdv=None # Could encode via engine in future
|
| 61 |
+
)
|
| 62 |
+
self.wm.push_item(agent_id, item)
|
| 63 |
+
logger.debug(f"Agent {agent_id} observed: {content[:30]}...")
|
| 64 |
+
return item_id
|
| 65 |
+
|
| 66 |
+
def get_working_context(self, agent_id: str, limit: int = 16) -> List[WorkingMemoryItem]:
|
| 67 |
+
"""
|
| 68 |
+
Read the active, un-pruned context out of the agent's working memory buffer.
|
| 69 |
+
"""
|
| 70 |
+
state = self.wm.get_state(agent_id)
|
| 71 |
+
if not state:
|
| 72 |
+
return []
|
| 73 |
+
|
| 74 |
+
return state.items[-limit:]
|
| 75 |
+
|
| 76 |
+
# --- Episodic ---
|
| 77 |
+
|
| 78 |
+
def start_episode(self, agent_id: str, goal: str, context: Optional[str] = None) -> str:
|
| 79 |
+
"""Begin a new temporally-linked event sequence."""
|
| 80 |
+
return self.episodic.start_episode(agent_id, goal=goal, context=context)
|
| 81 |
+
|
| 82 |
+
def append_event(self, episode_id: str, kind: str, content: str, **meta) -> None:
|
| 83 |
+
"""Log an action or outcome to an ongoing episode."""
|
| 84 |
+
self.episodic.append_event(episode_id, kind, content, meta)
|
| 85 |
+
|
| 86 |
+
def end_episode(self, episode_id: str, outcome: str, reward: Optional[float] = None) -> None:
|
| 87 |
+
"""Seal an episode, logging its final success or failure state."""
|
| 88 |
+
self.episodic.end_episode(episode_id, outcome, reward)
|
| 89 |
+
|
| 90 |
+
# --- Semantic / Retrieval ---
|
| 91 |
+
|
| 92 |
+
async def recall(
|
| 93 |
+
self,
|
| 94 |
+
agent_id: str,
|
| 95 |
+
query: str,
|
| 96 |
+
context: Optional[str] = None,
|
| 97 |
+
top_k: int = 8,
|
| 98 |
+
modes: Tuple[str, ...] = ("episodic", "semantic")
|
| 99 |
+
) -> List[dict]:
|
| 100 |
+
"""
|
| 101 |
+
A unified query interface that checks Working Memory, Episodic History, and the Semantic Vector Store.
|
| 102 |
+
Currently delegates heavily to the backing HAIMEngine, but can be augmented to return semantic concepts.
|
| 103 |
+
"""
|
| 104 |
+
results = []
|
| 105 |
+
|
| 106 |
+
# 1. Broad retrieval via existing HAIM engine (SM / general memories)
|
| 107 |
+
if "semantic" in modes:
|
| 108 |
+
engine_results = await self.engine.query(query, top_k=top_k)
|
| 109 |
+
for mem_id, score in engine_results:
|
| 110 |
+
node = await self.engine.tier_manager.get_memory(mem_id) # Fix: tier_manager.get_memory is async
|
| 111 |
+
if node:
|
| 112 |
+
results.append({"source": "semantic/engine", "content": node.content, "score": score})
|
| 113 |
+
|
| 114 |
+
# 2. Local episodic retrieval
|
| 115 |
+
if "episodic" in modes:
|
| 116 |
+
recent_eps = self.episodic.get_recent(agent_id, limit=top_k, context=context)
|
| 117 |
+
for ep in recent_eps:
|
| 118 |
+
summary = f"Episode(goal={ep.goal}, outcome={ep.outcome}, events={len(ep.events)})"
|
| 119 |
+
results.append({"source": "episodic", "content": summary, "score": ep.reliability})
|
| 120 |
+
|
| 121 |
+
# Sort and trim mixed results
|
| 122 |
+
results.sort(key=lambda x: x.get("score", 0.0), reverse=True)
|
| 123 |
+
return results[:top_k]
|
| 124 |
+
|
| 125 |
+
# --- Procedural ---
|
| 126 |
+
|
| 127 |
+
def suggest_procedures(self, agent_id: str, query: str, top_k: int = 5) -> List[Procedure]:
|
| 128 |
+
"""Fetch executable tool-patterns based on the agent's intent."""
|
| 129 |
+
return self.procedural.find_applicable_procedures(query, agent_id=agent_id, top_k=top_k)
|
| 130 |
+
|
| 131 |
+
def record_procedure_outcome(self, proc_id: str, success: bool) -> None:
|
| 132 |
+
"""Report on the utility of a chosen procedure."""
|
| 133 |
+
self.procedural.record_procedure_outcome(proc_id, success)
|
| 134 |
+
|
| 135 |
+
# --- Meta / Self-awareness ---
|
| 136 |
+
|
| 137 |
+
def get_knowledge_gaps(self, agent_id: str, lookback_hours: int = 24) -> List[dict]:
|
| 138 |
+
"""Return currently open knowledge gaps identified by the Pulse loop."""
|
| 139 |
+
# Stubbed: Would interact with gap_detector
|
| 140 |
+
return []
|
| 141 |
+
|
| 142 |
+
def get_self_improvement_proposals(self) -> List[SelfImprovementProposal]:
|
| 143 |
+
"""Retrieve system-generated proposals to improve operation or prompt alignment."""
|
| 144 |
+
return self.meta.list_proposals()
|
| 145 |
+
|
src/mnemocore/api/main.py
CHANGED
|
@@ -156,9 +156,22 @@ async def lifespan(app: FastAPI):
|
|
| 156 |
persist_path="./data/memory.jsonl",
|
| 157 |
config=config,
|
| 158 |
tier_manager=tier_manager,
|
|
|
|
|
|
|
|
|
|
| 159 |
)
|
| 160 |
await engine.initialize()
|
| 161 |
app.state.engine = engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
yield
|
| 164 |
|
|
@@ -377,7 +390,8 @@ async def query_memory(
|
|
| 377 |
API_REQUEST_COUNT.labels(method="POST", endpoint="/query", status="200").inc()
|
| 378 |
|
| 379 |
# CPU heavy vector search (offloaded inside engine)
|
| 380 |
-
|
|
|
|
| 381 |
|
| 382 |
formatted = []
|
| 383 |
for mem_id, score in results:
|
|
@@ -476,6 +490,52 @@ async def delete_memory(
|
|
| 476 |
|
| 477 |
return {"ok": True, "deleted": memory_id}
|
| 478 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
# --- Conceptual Endpoints ---
|
| 480 |
|
| 481 |
@app.post(
|
|
@@ -627,6 +687,310 @@ async def rlm_query(
|
|
| 627 |
}
|
| 628 |
|
| 629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
if __name__ == "__main__":
|
| 631 |
import uvicorn
|
| 632 |
uvicorn.run(app, host="0.0.0.0", port=8100)
|
|
|
|
| 156 |
persist_path="./data/memory.jsonl",
|
| 157 |
config=config,
|
| 158 |
tier_manager=tier_manager,
|
| 159 |
+
working_memory=container.working_memory,
|
| 160 |
+
episodic_store=container.episodic_store,
|
| 161 |
+
semantic_store=container.semantic_store,
|
| 162 |
)
|
| 163 |
await engine.initialize()
|
| 164 |
app.state.engine = engine
|
| 165 |
+
# Also expose the cognitive client to app state for agentic frameworks
|
| 166 |
+
from mnemocore.agent_interface import CognitiveMemoryClient
|
| 167 |
+
app.state.cognitive_client = CognitiveMemoryClient(
|
| 168 |
+
engine=engine,
|
| 169 |
+
wm=container.working_memory,
|
| 170 |
+
episodic=container.episodic_store,
|
| 171 |
+
semantic=container.semantic_store,
|
| 172 |
+
procedural=container.procedural_store,
|
| 173 |
+
meta=container.meta_memory,
|
| 174 |
+
)
|
| 175 |
|
| 176 |
yield
|
| 177 |
|
|
|
|
| 390 |
API_REQUEST_COUNT.labels(method="POST", endpoint="/query", status="200").inc()
|
| 391 |
|
| 392 |
# CPU heavy vector search (offloaded inside engine)
|
| 393 |
+
metadata_filter = {"agent_id": req.agent_id} if req.agent_id else None
|
| 394 |
+
results = await engine.query(req.query, top_k=req.top_k, metadata_filter=metadata_filter)
|
| 395 |
|
| 396 |
formatted = []
|
| 397 |
for mem_id, score in results:
|
|
|
|
| 490 |
|
| 491 |
return {"ok": True, "deleted": memory_id}
|
| 492 |
|
| 493 |
+
# --- Phase 5: Cognitive Client Endpoints ---
|
| 494 |
+
|
| 495 |
+
class ObserveRequest(BaseModel):
|
| 496 |
+
agent_id: str
|
| 497 |
+
content: str
|
| 498 |
+
kind: str = "observation"
|
| 499 |
+
importance: float = 0.5
|
| 500 |
+
tags: Optional[List[str]] = None
|
| 501 |
+
|
| 502 |
+
@app.post("/wm/observe", dependencies=[Depends(get_api_key)])
|
| 503 |
+
async def observe_context(req: ObserveRequest, request: Request):
|
| 504 |
+
"""Push an observation explicitly into Working Memory."""
|
| 505 |
+
client = request.app.state.cognitive_client
|
| 506 |
+
if not client:
|
| 507 |
+
raise HTTPException(status_code=503, detail="Cognitive Client unavailable")
|
| 508 |
+
item_id = client.observe(
|
| 509 |
+
agent_id=req.agent_id,
|
| 510 |
+
content=req.content,
|
| 511 |
+
kind=req.kind,
|
| 512 |
+
importance=req.importance,
|
| 513 |
+
tags=req.tags
|
| 514 |
+
)
|
| 515 |
+
return {"ok": True, "item_id": item_id}
|
| 516 |
+
|
| 517 |
+
@app.get("/wm/context/{agent_id}", dependencies=[Depends(get_api_key)])
|
| 518 |
+
async def get_working_context(agent_id: str, limit: int = 16, request: Request = None):
|
| 519 |
+
"""Read active Working Memory context."""
|
| 520 |
+
client = request.app.state.cognitive_client
|
| 521 |
+
items = client.get_working_context(agent_id, limit=limit)
|
| 522 |
+
return {"ok": True, "items": [
|
| 523 |
+
{"id": i.id, "content": i.content, "kind": i.kind, "importance": i.importance}
|
| 524 |
+
for i in items
|
| 525 |
+
]}
|
| 526 |
+
|
| 527 |
+
class EpisodeStartRequest(BaseModel):
|
| 528 |
+
agent_id: str
|
| 529 |
+
goal: str
|
| 530 |
+
context: Optional[str] = None
|
| 531 |
+
|
| 532 |
+
@app.post("/episodes/start", dependencies=[Depends(get_api_key)])
|
| 533 |
+
async def start_episode(req: EpisodeStartRequest, request: Request):
|
| 534 |
+
"""Start a new episode chain."""
|
| 535 |
+
client = request.app.state.cognitive_client
|
| 536 |
+
ep_id = client.start_episode(req.agent_id, goal=req.goal, context=req.context)
|
| 537 |
+
return {"ok": True, "episode_id": ep_id}
|
| 538 |
+
|
| 539 |
# --- Conceptual Endpoints ---
|
| 540 |
|
| 541 |
@app.post(
|
|
|
|
| 687 |
}
|
| 688 |
|
| 689 |
|
| 690 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 691 |
+
# Phase 5.0 — Agent 1: Trust & Provenance Endpoints
|
| 692 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 693 |
+
|
| 694 |
+
@app.get(
|
| 695 |
+
"/memory/{memory_id}/lineage",
|
| 696 |
+
dependencies=[Depends(get_api_key)],
|
| 697 |
+
tags=["Phase 5.0 — Trust"],
|
| 698 |
+
summary="Get full provenance lineage for a memory",
|
| 699 |
+
)
|
| 700 |
+
async def get_memory_lineage(
|
| 701 |
+
memory_id: str,
|
| 702 |
+
engine: HAIMEngine = Depends(get_engine),
|
| 703 |
+
):
|
| 704 |
+
"""
|
| 705 |
+
Return the complete provenance lineage of a memory:
|
| 706 |
+
origin (who created it, how, when) and all transformation events
|
| 707 |
+
(consolidated, verified, contradicted, archived, …).
|
| 708 |
+
"""
|
| 709 |
+
node = await engine.get_memory(memory_id)
|
| 710 |
+
if not node:
|
| 711 |
+
raise MemoryNotFoundError(memory_id)
|
| 712 |
+
|
| 713 |
+
prov = getattr(node, "provenance", None)
|
| 714 |
+
if prov is None:
|
| 715 |
+
return {
|
| 716 |
+
"ok": True,
|
| 717 |
+
"memory_id": memory_id,
|
| 718 |
+
"provenance": None,
|
| 719 |
+
"message": "No provenance record attached to this memory.",
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
return {
|
| 723 |
+
"ok": True,
|
| 724 |
+
"memory_id": memory_id,
|
| 725 |
+
"provenance": prov.to_dict(),
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
@app.get(
|
| 730 |
+
"/memory/{memory_id}/confidence",
|
| 731 |
+
dependencies=[Depends(get_api_key)],
|
| 732 |
+
tags=["Phase 5.0 — Trust"],
|
| 733 |
+
summary="Get confidence envelope for a memory",
|
| 734 |
+
)
|
| 735 |
+
async def get_memory_confidence(
|
| 736 |
+
memory_id: str,
|
| 737 |
+
engine: HAIMEngine = Depends(get_engine),
|
| 738 |
+
):
|
| 739 |
+
"""
|
| 740 |
+
Return a structured confidence envelope for a memory, combining:
|
| 741 |
+
- Bayesian reliability (BayesianLTP posterior mean)
|
| 742 |
+
- access_count (evidence strength)
|
| 743 |
+
- staleness (days since last verification)
|
| 744 |
+
- source_type trust weight
|
| 745 |
+
- contradiction flag
|
| 746 |
+
|
| 747 |
+
Level: high | medium | low | contradicted | stale
|
| 748 |
+
"""
|
| 749 |
+
from mnemocore.core.confidence import build_confidence_envelope
|
| 750 |
+
|
| 751 |
+
node = await engine.get_memory(memory_id)
|
| 752 |
+
if not node:
|
| 753 |
+
raise MemoryNotFoundError(memory_id)
|
| 754 |
+
|
| 755 |
+
prov = getattr(node, "provenance", None)
|
| 756 |
+
envelope = build_confidence_envelope(node, prov)
|
| 757 |
+
|
| 758 |
+
return {
|
| 759 |
+
"ok": True,
|
| 760 |
+
"memory_id": memory_id,
|
| 761 |
+
"confidence": envelope,
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 766 |
+
# Phase 5.0 — Agent 3 stub: Proactive Recall
|
| 767 |
+
# (Full implementation added by Agent 3 workstream)
|
| 768 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 769 |
+
|
| 770 |
+
@app.get(
|
| 771 |
+
"/proactive",
|
| 772 |
+
dependencies=[Depends(get_api_key)],
|
| 773 |
+
tags=["Phase 5.0 — Autonomy"],
|
| 774 |
+
summary="Retrieve contextually relevant memories without explicit query",
|
| 775 |
+
)
|
| 776 |
+
async def get_proactive_memories(
|
| 777 |
+
agent_id: Optional[str] = None,
|
| 778 |
+
limit: int = 10,
|
| 779 |
+
engine: HAIMEngine = Depends(get_engine),
|
| 780 |
+
):
|
| 781 |
+
"""
|
| 782 |
+
Proactive recall stub (Phase 5.0 / Agent 3).
|
| 783 |
+
Returns the most recently active high-LTP memories as a stand-in
|
| 784 |
+
until the full ProactiveRecallDaemon is implemented.
|
| 785 |
+
"""
|
| 786 |
+
nodes = await engine.tier_manager.get_hot_snapshot() if hasattr(engine, "tier_manager") else []
|
| 787 |
+
sorted_nodes = sorted(nodes, key=lambda n: n.ltp_strength, reverse=True)[:limit]
|
| 788 |
+
|
| 789 |
+
from mnemocore.core.confidence import build_confidence_envelope
|
| 790 |
+
results = []
|
| 791 |
+
for n in sorted_nodes:
|
| 792 |
+
prov = getattr(n, "provenance", None)
|
| 793 |
+
results.append({
|
| 794 |
+
"id": n.id,
|
| 795 |
+
"content": n.content,
|
| 796 |
+
"ltp_strength": round(n.ltp_strength, 4),
|
| 797 |
+
"confidence": build_confidence_envelope(n, prov),
|
| 798 |
+
"tier": getattr(n, "tier", "hot"),
|
| 799 |
+
})
|
| 800 |
+
|
| 801 |
+
return {"ok": True, "proactive_results": results, "count": len(results)}
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 805 |
+
# Phase 5.0 — Agent 2: Memory Lifecycle Endpoints
|
| 806 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 807 |
+
|
| 808 |
+
@app.get(
|
| 809 |
+
"/contradictions",
|
| 810 |
+
dependencies=[Depends(get_api_key)],
|
| 811 |
+
tags=["Phase 5.0 — Lifecycle"],
|
| 812 |
+
summary="List active contradiction groups requiring resolution",
|
| 813 |
+
)
|
| 814 |
+
async def list_contradictions(
|
| 815 |
+
unresolved_only: bool = True,
|
| 816 |
+
):
|
| 817 |
+
"""
|
| 818 |
+
Returns all detected contradiction groups from the ContradictionRegistry.
|
| 819 |
+
By default only unresolved contradictions are returned.
|
| 820 |
+
"""
|
| 821 |
+
from mnemocore.core.contradiction import get_contradiction_detector
|
| 822 |
+
detector = get_contradiction_detector()
|
| 823 |
+
records = detector.registry.list_all(unresolved_only=unresolved_only)
|
| 824 |
+
return {
|
| 825 |
+
"ok": True,
|
| 826 |
+
"count": len(records),
|
| 827 |
+
"contradictions": [r.to_dict() for r in records],
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
class ResolveContradictionRequest(BaseModel):
|
| 832 |
+
note: Optional[str] = None
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
@app.post(
|
| 836 |
+
"/contradictions/{group_id}/resolve",
|
| 837 |
+
dependencies=[Depends(get_api_key)],
|
| 838 |
+
tags=["Phase 5.0 — Lifecycle"],
|
| 839 |
+
summary="Mark a contradiction group as resolved",
|
| 840 |
+
)
|
| 841 |
+
async def resolve_contradiction(group_id: str, req: ResolveContradictionRequest):
|
| 842 |
+
"""Manually resolve a detected contradiction."""
|
| 843 |
+
from mnemocore.core.contradiction import get_contradiction_detector
|
| 844 |
+
detector = get_contradiction_detector()
|
| 845 |
+
success = detector.registry.resolve(group_id, note=req.note)
|
| 846 |
+
if not success:
|
| 847 |
+
raise HTTPException(status_code=404, detail=f"Contradiction group {group_id!r} not found.")
|
| 848 |
+
return {"ok": True, "resolved_group_id": group_id}
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 852 |
+
# Phase 5.0 — Agent 3: Autonomous Cognition Endpoints
|
| 853 |
+
# ───────────��─────────────────────────────────────────────────────────────────
|
| 854 |
+
|
| 855 |
+
@app.get(
|
| 856 |
+
"/memory/{memory_id}/emotional-tag",
|
| 857 |
+
dependencies=[Depends(get_api_key)],
|
| 858 |
+
tags=["Phase 5.0 — Autonomy"],
|
| 859 |
+
summary="Get emotional (valence/arousal) tag for a memory",
|
| 860 |
+
)
|
| 861 |
+
async def get_emotional_tag_ep(
|
| 862 |
+
memory_id: str,
|
| 863 |
+
engine: HAIMEngine = Depends(get_engine),
|
| 864 |
+
):
|
| 865 |
+
"""Return the valence/arousal emotional metadata for a memory."""
|
| 866 |
+
from mnemocore.core.emotional_tag import get_emotional_tag
|
| 867 |
+
node = await engine.get_memory(memory_id)
|
| 868 |
+
if not node:
|
| 869 |
+
raise MemoryNotFoundError(memory_id)
|
| 870 |
+
tag = get_emotional_tag(node)
|
| 871 |
+
return {
|
| 872 |
+
"ok": True,
|
| 873 |
+
"memory_id": memory_id,
|
| 874 |
+
"emotional_tag": {
|
| 875 |
+
"valence": tag.valence,
|
| 876 |
+
"arousal": tag.arousal,
|
| 877 |
+
"salience": round(tag.salience(), 4),
|
| 878 |
+
},
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
class EmotionalTagPatchRequest(BaseModel):
|
| 883 |
+
valence: float
|
| 884 |
+
arousal: float
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
@app.patch(
|
| 888 |
+
"/memory/{memory_id}/emotional-tag",
|
| 889 |
+
dependencies=[Depends(get_api_key)],
|
| 890 |
+
tags=["Phase 5.0 — Autonomy"],
|
| 891 |
+
summary="Attach or update emotional tag on a memory",
|
| 892 |
+
)
|
| 893 |
+
async def patch_emotional_tag(
|
| 894 |
+
memory_id: str,
|
| 895 |
+
req: EmotionalTagPatchRequest,
|
| 896 |
+
engine: HAIMEngine = Depends(get_engine),
|
| 897 |
+
):
|
| 898 |
+
from mnemocore.core.emotional_tag import EmotionalTag, attach_emotional_tag
|
| 899 |
+
node = await engine.get_memory(memory_id)
|
| 900 |
+
if not node:
|
| 901 |
+
raise MemoryNotFoundError(memory_id)
|
| 902 |
+
tag = EmotionalTag(valence=req.valence, arousal=req.arousal)
|
| 903 |
+
attach_emotional_tag(node, tag)
|
| 904 |
+
return {"ok": True, "memory_id": memory_id, "emotional_tag": tag.to_metadata_dict()}
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 908 |
+
# Phase 5.0 — Agent 4: Prediction Endpoints
|
| 909 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 910 |
+
|
| 911 |
+
_prediction_store_instance = None
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
def _get_prediction_store(engine: HAIMEngine = Depends(get_engine)):
|
| 915 |
+
from mnemocore.core.prediction_store import PredictionStore
|
| 916 |
+
global _prediction_store_instance
|
| 917 |
+
if _prediction_store_instance is None:
|
| 918 |
+
_prediction_store_instance = PredictionStore(engine=engine)
|
| 919 |
+
return _prediction_store_instance
|
| 920 |
+
|
| 921 |
+
|
| 922 |
+
class CreatePredictionRequest(BaseModel):
|
| 923 |
+
content: str
|
| 924 |
+
confidence: float = 0.5
|
| 925 |
+
deadline_days: Optional[float] = None
|
| 926 |
+
related_memory_ids: Optional[List[str]] = None
|
| 927 |
+
tags: Optional[List[str]] = None
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
class VerifyPredictionRequest(BaseModel):
|
| 931 |
+
success: bool
|
| 932 |
+
notes: Optional[str] = None
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
@app.post(
|
| 936 |
+
"/predictions",
|
| 937 |
+
dependencies=[Depends(get_api_key)],
|
| 938 |
+
tags=["Phase 5.0 — Prediction"],
|
| 939 |
+
summary="Store a new forward-looking prediction",
|
| 940 |
+
)
|
| 941 |
+
async def create_prediction(req: CreatePredictionRequest):
|
| 942 |
+
from mnemocore.core.prediction_store import PredictionStore
|
| 943 |
+
global _prediction_store_instance
|
| 944 |
+
if _prediction_store_instance is None:
|
| 945 |
+
_prediction_store_instance = PredictionStore()
|
| 946 |
+
pred_id = _prediction_store_instance.create(
|
| 947 |
+
content=req.content,
|
| 948 |
+
confidence=req.confidence,
|
| 949 |
+
deadline_days=req.deadline_days,
|
| 950 |
+
related_memory_ids=req.related_memory_ids,
|
| 951 |
+
tags=req.tags,
|
| 952 |
+
)
|
| 953 |
+
pred = _prediction_store_instance.get(pred_id)
|
| 954 |
+
return {"ok": True, "prediction": pred.to_dict()}
|
| 955 |
+
|
| 956 |
+
|
| 957 |
+
@app.get(
|
| 958 |
+
"/predictions",
|
| 959 |
+
dependencies=[Depends(get_api_key)],
|
| 960 |
+
tags=["Phase 5.0 — Prediction"],
|
| 961 |
+
summary="List all predictions",
|
| 962 |
+
)
|
| 963 |
+
async def list_predictions(status: Optional[str] = None):
|
| 964 |
+
from mnemocore.core.prediction_store import PredictionStore
|
| 965 |
+
global _prediction_store_instance
|
| 966 |
+
if _prediction_store_instance is None:
|
| 967 |
+
_prediction_store_instance = PredictionStore()
|
| 968 |
+
return {
|
| 969 |
+
"ok": True,
|
| 970 |
+
"predictions": [
|
| 971 |
+
p.to_dict()
|
| 972 |
+
for p in _prediction_store_instance.list_all(status=status)
|
| 973 |
+
],
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
|
| 977 |
+
@app.post(
|
| 978 |
+
"/predictions/{pred_id}/verify",
|
| 979 |
+
dependencies=[Depends(get_api_key)],
|
| 980 |
+
tags=["Phase 5.0 — Prediction"],
|
| 981 |
+
summary="Verify or falsify a prediction",
|
| 982 |
+
)
|
| 983 |
+
async def verify_prediction(pred_id: str, req: VerifyPredictionRequest):
|
| 984 |
+
from mnemocore.core.prediction_store import PredictionStore
|
| 985 |
+
global _prediction_store_instance
|
| 986 |
+
if _prediction_store_instance is None:
|
| 987 |
+
_prediction_store_instance = PredictionStore()
|
| 988 |
+
pred = await _prediction_store_instance.verify(pred_id, success=req.success, notes=req.notes)
|
| 989 |
+
if pred is None:
|
| 990 |
+
raise HTTPException(status_code=404, detail=f"Prediction {pred_id!r} not found.")
|
| 991 |
+
return {"ok": True, "prediction": pred.to_dict()}
|
| 992 |
+
|
| 993 |
+
|
| 994 |
if __name__ == "__main__":
|
| 995 |
import uvicorn
|
| 996 |
uvicorn.run(app, host="0.0.0.0", port=8100)
|
src/mnemocore/core/agent_profile.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Profiles
|
| 3 |
+
==============
|
| 4 |
+
Persistent state encompassing quirks, long-term alignment details, and tooling preferences per individual actor.
|
| 5 |
+
Allows multiple independent agents to interact cleanly without memory namespace collisions.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Dict, List, Optional, Any
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
import threading
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class AgentProfile:
|
| 18 |
+
id: str
|
| 19 |
+
name: str
|
| 20 |
+
description: str
|
| 21 |
+
created_at: datetime
|
| 22 |
+
last_active: datetime
|
| 23 |
+
# Hard bounds over behavior: e.g. "Do not delete files without explicit prompt"
|
| 24 |
+
core_directives: List[str] = field(default_factory=list)
|
| 25 |
+
# Flexible learned preferences
|
| 26 |
+
preferences: dict[str, Any] = field(default_factory=dict)
|
| 27 |
+
# Agent-specific metrics
|
| 28 |
+
reliability_score: float = 1.0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class AgentProfileService:
|
| 32 |
+
def __init__(self):
|
| 33 |
+
# Local state dict, should back out to SQLite or Redis
|
| 34 |
+
self._profiles: Dict[str, AgentProfile] = {}
|
| 35 |
+
self._lock = threading.RLock()
|
| 36 |
+
|
| 37 |
+
def get_or_create_profile(self, agent_id: str, name: str = "Unknown Agent") -> AgentProfile:
|
| 38 |
+
"""Retrieve the identity profile for an agent, constructing it if completely uninitialized."""
|
| 39 |
+
with self._lock:
|
| 40 |
+
if agent_id not in self._profiles:
|
| 41 |
+
self._profiles[agent_id] = AgentProfile(
|
| 42 |
+
id=agent_id,
|
| 43 |
+
name=name,
|
| 44 |
+
description=f"Auto-generated profile for {agent_id}",
|
| 45 |
+
created_at=datetime.utcnow(),
|
| 46 |
+
last_active=datetime.utcnow()
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
profile = self._profiles[agent_id]
|
| 50 |
+
profile.last_active = datetime.utcnow()
|
| 51 |
+
return profile
|
| 52 |
+
|
| 53 |
+
def update_preferences(self, agent_id: str, new_preferences: dict[str, Any]) -> None:
|
| 54 |
+
"""Merge learned trait or task preferences into an agent's persistent identity."""
|
| 55 |
+
with self._lock:
|
| 56 |
+
profile = self.get_or_create_profile(agent_id)
|
| 57 |
+
profile.preferences.update(new_preferences)
|
| 58 |
+
logger.debug(f"Updated preferences for agent {agent_id}.")
|
| 59 |
+
|
| 60 |
+
def adjust_reliability(self, agent_id: str, points: float) -> None:
|
| 61 |
+
"""Alter universal trust rating of the agent based on episodic action evaluations."""
|
| 62 |
+
with self._lock:
|
| 63 |
+
profile = self.get_or_create_profile(agent_id)
|
| 64 |
+
profile.reliability_score = max(0.0, min(1.0, profile.reliability_score + points))
|
| 65 |
+
|
src/mnemocore/core/anticipatory.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from loguru import logger
|
| 3 |
+
|
| 4 |
+
from .config import AnticipatoryConfig
|
| 5 |
+
from .synapse_index import SynapseIndex
|
| 6 |
+
from .tier_manager import TierManager
|
| 7 |
+
from .topic_tracker import TopicTracker
|
| 8 |
+
|
| 9 |
+
class AnticipatoryEngine:
|
| 10 |
+
"""
|
| 11 |
+
Phase 13.2: Anticipatory Memory
|
| 12 |
+
Predicts which memories the user is likely to request next based on the
|
| 13 |
+
current topic trajectory and graph structure, and pre-loads them into the HOT tier.
|
| 14 |
+
"""
|
| 15 |
+
def __init__(
|
| 16 |
+
self,
|
| 17 |
+
config: AnticipatoryConfig,
|
| 18 |
+
synapse_index: SynapseIndex,
|
| 19 |
+
tier_manager: TierManager,
|
| 20 |
+
topic_tracker: TopicTracker
|
| 21 |
+
):
|
| 22 |
+
self.config = config
|
| 23 |
+
self.synapse_index = synapse_index
|
| 24 |
+
self.tier_manager = tier_manager
|
| 25 |
+
self.topic_tracker = topic_tracker
|
| 26 |
+
|
| 27 |
+
async def predict_and_preload(self, current_node_id: str) -> List[str]:
|
| 28 |
+
"""
|
| 29 |
+
Predicts surrounding context from the current node and ensures they are preloaded.
|
| 30 |
+
Uses the multi-hop network in the SynapseIndex to find likely next nodes.
|
| 31 |
+
"""
|
| 32 |
+
if not self.config.enabled:
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
# Get neighbors up to predictive depth
|
| 36 |
+
# We use a relatively low depth to avoid flooding the HOT tier
|
| 37 |
+
neighbors = self.synapse_index.get_multi_hop_neighbors(
|
| 38 |
+
current_node_id,
|
| 39 |
+
depth=self.config.predictive_depth
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# We'll just take the top 5 highest-weighted neighbors
|
| 43 |
+
# Sort by path weight (which multi-hop computes)
|
| 44 |
+
sorted_neighbors = sorted(neighbors.items(), key=lambda x: x[1], reverse=True)[:5]
|
| 45 |
+
target_ids = [nid for nid, weight in sorted_neighbors if nid != current_node_id]
|
| 46 |
+
|
| 47 |
+
if target_ids:
|
| 48 |
+
logger.debug(f"Anticipatory engine pre-loading {len(target_ids)} predicted nodes.")
|
| 49 |
+
await self.tier_manager.anticipate(target_ids)
|
| 50 |
+
|
| 51 |
+
return target_ids
|
src/mnemocore/core/binary_hdv.py
CHANGED
|
@@ -22,6 +22,21 @@ import hashlib
|
|
| 22 |
from typing import List, Optional, Tuple
|
| 23 |
|
| 24 |
import numpy as np
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class BinaryHDV:
|
|
@@ -138,12 +153,13 @@ class BinaryHDV:
|
|
| 138 |
"""
|
| 139 |
Hamming distance: count of differing bits.
|
| 140 |
|
| 141 |
-
Uses
|
| 142 |
Range: [0, dimension].
|
| 143 |
"""
|
| 144 |
assert self.dimension == other.dimension
|
| 145 |
xor_result = np.bitwise_xor(self.data, other.data)
|
| 146 |
-
|
|
|
|
| 147 |
|
| 148 |
def normalized_distance(self, other: "BinaryHDV") -> float:
|
| 149 |
"""Hamming distance normalized to [0.0, 1.0]."""
|
|
@@ -210,7 +226,8 @@ class BinaryHDV:
|
|
| 210 |
return cls(data=data, dimension=dimension)
|
| 211 |
|
| 212 |
def __repr__(self) -> str:
|
| 213 |
-
|
|
|
|
| 214 |
return f"BinaryHDV(dim={self.dimension}, popcount={popcount}/{self.dimension})"
|
| 215 |
|
| 216 |
def __eq__(self, other: object) -> bool:
|
|
@@ -383,26 +400,37 @@ class TextEncoder:
|
|
| 383 |
"""
|
| 384 |
Encode a text string to a binary HDV.
|
| 385 |
|
| 386 |
-
Tokenization: simple whitespace split
|
| 387 |
Each token is bound with its position via XOR(token, permute(position_marker, i)).
|
| 388 |
All position-bound tokens are bundled via majority vote.
|
| 389 |
"""
|
| 390 |
-
|
|
|
|
|
|
|
| 391 |
if not tokens:
|
| 392 |
return BinaryHDV.random(self.dimension)
|
| 393 |
|
| 394 |
if len(tokens) == 1:
|
| 395 |
return self.get_token_vector(tokens[0])
|
| 396 |
|
| 397 |
-
# Build position-bound token vectors
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
def encode_with_context(
|
| 408 |
self, text: str, context_hdv: BinaryHDV
|
|
@@ -415,21 +443,3 @@ class TextEncoder:
|
|
| 415 |
"""
|
| 416 |
content_hdv = self.encode(text)
|
| 417 |
return content_hdv.xor_bind(context_hdv)
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
# ======================================================================
|
| 421 |
-
# Internal helpers
|
| 422 |
-
# ======================================================================
|
| 423 |
-
|
| 424 |
-
# Cached lookup table for popcount (bits set per byte value 0-255)
|
| 425 |
-
_POPCOUNT_TABLE: Optional[np.ndarray] = None
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
def _build_popcount_table() -> np.ndarray:
|
| 429 |
-
"""Build or return cached popcount lookup table for bytes (0-255)."""
|
| 430 |
-
global _POPCOUNT_TABLE
|
| 431 |
-
if _POPCOUNT_TABLE is None:
|
| 432 |
-
_POPCOUNT_TABLE = np.array(
|
| 433 |
-
[bin(i).count("1") for i in range(256)], dtype=np.int32
|
| 434 |
-
)
|
| 435 |
-
return _POPCOUNT_TABLE
|
|
|
|
| 22 |
from typing import List, Optional, Tuple
|
| 23 |
|
| 24 |
import numpy as np
|
| 25 |
+
import re
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Cached lookup table for popcount (bits set per byte value 0-255)
|
| 29 |
+
_POPCOUNT_TABLE: Optional[np.ndarray] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _build_popcount_table() -> np.ndarray:
|
| 33 |
+
"""Build or return cached popcount lookup table for bytes (0-255)."""
|
| 34 |
+
global _POPCOUNT_TABLE
|
| 35 |
+
if _POPCOUNT_TABLE is None:
|
| 36 |
+
_POPCOUNT_TABLE = np.array(
|
| 37 |
+
[bin(i).count("1") for i in range(256)], dtype=np.int32
|
| 38 |
+
)
|
| 39 |
+
return _POPCOUNT_TABLE
|
| 40 |
|
| 41 |
|
| 42 |
class BinaryHDV:
|
|
|
|
| 153 |
"""
|
| 154 |
Hamming distance: count of differing bits.
|
| 155 |
|
| 156 |
+
Uses lookup table for speed (replacing unpackbits).
|
| 157 |
Range: [0, dimension].
|
| 158 |
"""
|
| 159 |
assert self.dimension == other.dimension
|
| 160 |
xor_result = np.bitwise_xor(self.data, other.data)
|
| 161 |
+
# Optimized: use precomputed popcount table instead of unpacking bits
|
| 162 |
+
return int(_build_popcount_table()[xor_result].sum())
|
| 163 |
|
| 164 |
def normalized_distance(self, other: "BinaryHDV") -> float:
|
| 165 |
"""Hamming distance normalized to [0.0, 1.0]."""
|
|
|
|
| 226 |
return cls(data=data, dimension=dimension)
|
| 227 |
|
| 228 |
def __repr__(self) -> str:
|
| 229 |
+
# Optimized: use precomputed popcount table
|
| 230 |
+
popcount = int(_build_popcount_table()[self.data].sum())
|
| 231 |
return f"BinaryHDV(dim={self.dimension}, popcount={popcount}/{self.dimension})"
|
| 232 |
|
| 233 |
def __eq__(self, other: object) -> bool:
|
|
|
|
| 400 |
"""
|
| 401 |
Encode a text string to a binary HDV.
|
| 402 |
|
| 403 |
+
Tokenization: simple whitespace split after normalization.
|
| 404 |
Each token is bound with its position via XOR(token, permute(position_marker, i)).
|
| 405 |
All position-bound tokens are bundled via majority vote.
|
| 406 |
"""
|
| 407 |
+
# BUG-02 Fix: strip punctuation and normalize spaces
|
| 408 |
+
normalized = re.sub(r'[^\w\s]', '', text).lower()
|
| 409 |
+
tokens = normalized.split()
|
| 410 |
if not tokens:
|
| 411 |
return BinaryHDV.random(self.dimension)
|
| 412 |
|
| 413 |
if len(tokens) == 1:
|
| 414 |
return self.get_token_vector(tokens[0])
|
| 415 |
|
| 416 |
+
# Build position-bound token vectors (#27)
|
| 417 |
+
# Optimized: Batch process data instead of multiple object instantiations
|
| 418 |
+
token_hdvs = [self.get_token_vector(t) for t in tokens]
|
| 419 |
+
packed_data = np.stack([v.data for v in token_hdvs], axis=0)
|
| 420 |
+
all_bits = np.unpackbits(packed_data, axis=1)
|
| 421 |
+
|
| 422 |
+
# Apply position-based permutations (roll)
|
| 423 |
+
for i in range(len(tokens)):
|
| 424 |
+
if i > 0:
|
| 425 |
+
all_bits[i] = np.roll(all_bits[i], i)
|
| 426 |
|
| 427 |
+
# Vectorized majority vote (equivalent to majority_bundle)
|
| 428 |
+
sums = all_bits.sum(axis=0)
|
| 429 |
+
threshold = len(tokens) / 2.0
|
| 430 |
+
result_bits = np.zeros(self.dimension, dtype=np.uint8)
|
| 431 |
+
result_bits[sums > threshold] = 1
|
| 432 |
+
|
| 433 |
+
return BinaryHDV(data=np.packbits(result_bits), dimension=self.dimension)
|
| 434 |
|
| 435 |
def encode_with_context(
|
| 436 |
self, text: str, context_hdv: BinaryHDV
|
|
|
|
| 443 |
"""
|
| 444 |
content_hdv = self.encode(text)
|
| 445 |
return content_hdv.xor_bind(context_hdv)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/mnemocore/core/confidence.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Confidence Calibration Module (Phase 5.0)
|
| 3 |
+
==========================================
|
| 4 |
+
Generates structured confidence envelopes for retrieved memories,
|
| 5 |
+
combining all available trust signals into a single queryable object.
|
| 6 |
+
|
| 7 |
+
Signals used:
|
| 8 |
+
- BayesianLTP reliability (mean of Beta posterior)
|
| 9 |
+
- access_count (low count → less evidence)
|
| 10 |
+
- staleness (days since last verification)
|
| 11 |
+
- source type (external ≤ user_correction vs observation)
|
| 12 |
+
- contradiction flag (from ProvenanceRecord)
|
| 13 |
+
|
| 14 |
+
Output: a ConfidenceEnvelope dict appended to every query response,
|
| 15 |
+
enabling consuming agents to make trust-aware decisions.
|
| 16 |
+
|
| 17 |
+
Public API:
|
| 18 |
+
env = ConfidenceEnvelopeGenerator.build(node, provenance)
|
| 19 |
+
level = env["level"] # "high" | "medium" | "low" | "contradicted" | "stale"
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from datetime import datetime, timezone
|
| 25 |
+
from typing import TYPE_CHECKING, Any, Dict, Optional
|
| 26 |
+
|
| 27 |
+
if TYPE_CHECKING:
|
| 28 |
+
from .node import MemoryNode
|
| 29 |
+
from .provenance import ProvenanceRecord
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ------------------------------------------------------------------ #
|
| 33 |
+
# Confidence levels (ordered by trust) #
|
| 34 |
+
# ------------------------------------------------------------------ #
|
| 35 |
+
|
| 36 |
+
LEVEL_HIGH = "high"
|
| 37 |
+
LEVEL_MEDIUM = "medium"
|
| 38 |
+
LEVEL_LOW = "low"
|
| 39 |
+
LEVEL_CONTRADICTED = "contradicted"
|
| 40 |
+
LEVEL_STALE = "stale"
|
| 41 |
+
|
| 42 |
+
# Thresholds
|
| 43 |
+
RELIABILITY_HIGH_THRESHOLD = 0.80
|
| 44 |
+
RELIABILITY_MEDIUM_THRESHOLD = 0.50
|
| 45 |
+
ACCESS_COUNT_MIN_EVIDENCE = 2 # Less than this → low confidence
|
| 46 |
+
ACCESS_COUNT_HIGH_EVIDENCE = 5 # At least this → supports high confidence
|
| 47 |
+
STALENESS_STALE_DAYS = 30 # Days without verification → stale
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ------------------------------------------------------------------ #
|
| 51 |
+
# Source-type trust weights #
|
| 52 |
+
# ------------------------------------------------------------------ #
|
| 53 |
+
|
| 54 |
+
SOURCE_TRUST: Dict[str, float] = {
|
| 55 |
+
"observation": 1.0,
|
| 56 |
+
"inference": 0.8,
|
| 57 |
+
"external_sync": 0.75,
|
| 58 |
+
"dream": 0.6,
|
| 59 |
+
"consolidation": 0.85,
|
| 60 |
+
"prediction": 0.5,
|
| 61 |
+
"user_correction": 1.0,
|
| 62 |
+
"unknown": 0.5,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ------------------------------------------------------------------ #
|
| 67 |
+
# Confidence Envelope Generator #
|
| 68 |
+
# ------------------------------------------------------------------ #
|
| 69 |
+
|
| 70 |
+
class ConfidenceEnvelopeGenerator:
|
| 71 |
+
"""
|
| 72 |
+
Builds a confidence_envelope dict for a MemoryNode.
|
| 73 |
+
|
| 74 |
+
Does NOT mutate the node — only reads fields.
|
| 75 |
+
Thread-safe; no shared state.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
@staticmethod
|
| 79 |
+
def _reliability(node: "MemoryNode") -> float:
|
| 80 |
+
"""
|
| 81 |
+
Extract reliability float from the node.
|
| 82 |
+
Falls back to ltp_strength if no Bayesian state is attached.
|
| 83 |
+
"""
|
| 84 |
+
bayes = getattr(node, "_bayes", None)
|
| 85 |
+
if bayes is not None:
|
| 86 |
+
return float(bayes.mean)
|
| 87 |
+
return float(getattr(node, "ltp_strength", 0.5))
|
| 88 |
+
|
| 89 |
+
@staticmethod
|
| 90 |
+
def _staleness_days(node: "MemoryNode", provenance: Optional["ProvenanceRecord"]) -> float:
|
| 91 |
+
"""Days since last verification, or days since last access."""
|
| 92 |
+
if provenance:
|
| 93 |
+
# Find the most recent 'verified' event
|
| 94 |
+
for evt in reversed(provenance.lineage):
|
| 95 |
+
if evt.event == "verified" and evt.outcome is True:
|
| 96 |
+
try:
|
| 97 |
+
ts = datetime.fromisoformat(evt.timestamp)
|
| 98 |
+
if ts.tzinfo is None:
|
| 99 |
+
ts = ts.replace(tzinfo=timezone.utc)
|
| 100 |
+
delta = datetime.now(timezone.utc) - ts
|
| 101 |
+
return delta.total_seconds() / 86400.0
|
| 102 |
+
except (ValueError, TypeError):
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
# Fall back to last_accessed on the node
|
| 106 |
+
last = getattr(node, "last_accessed", None)
|
| 107 |
+
if last is not None:
|
| 108 |
+
if getattr(last, "tzinfo", None) is None:
|
| 109 |
+
last = last.replace(tzinfo=timezone.utc)
|
| 110 |
+
delta = datetime.now(timezone.utc) - last
|
| 111 |
+
return delta.total_seconds() / 86400.0
|
| 112 |
+
|
| 113 |
+
return 0.0
|
| 114 |
+
|
| 115 |
+
@classmethod
|
| 116 |
+
def build(
|
| 117 |
+
cls,
|
| 118 |
+
node: "MemoryNode",
|
| 119 |
+
provenance: Optional["ProvenanceRecord"] = None,
|
| 120 |
+
) -> Dict[str, Any]:
|
| 121 |
+
"""
|
| 122 |
+
Build a full confidence_envelope dict for the given node.
|
| 123 |
+
|
| 124 |
+
Returns a dict suitable for direct JSON serialization.
|
| 125 |
+
"""
|
| 126 |
+
reliability = cls._reliability(node)
|
| 127 |
+
access_count: int = getattr(node, "access_count", 1)
|
| 128 |
+
staleness: float = cls._staleness_days(node, provenance)
|
| 129 |
+
|
| 130 |
+
# Determine source type for trust weighting
|
| 131 |
+
source_type = "unknown"
|
| 132 |
+
if provenance:
|
| 133 |
+
source_type = provenance.origin.type
|
| 134 |
+
source_trust = SOURCE_TRUST.get(source_type, 0.5)
|
| 135 |
+
|
| 136 |
+
# Contradiction check
|
| 137 |
+
is_contradicted = provenance.is_contradicted() if provenance else False
|
| 138 |
+
|
| 139 |
+
# Last verified date (human-readable)
|
| 140 |
+
last_verified: Optional[str] = None
|
| 141 |
+
if provenance:
|
| 142 |
+
for evt in reversed(provenance.lineage):
|
| 143 |
+
if evt.event == "verified" and evt.outcome is True:
|
| 144 |
+
last_verified = evt.timestamp
|
| 145 |
+
break
|
| 146 |
+
|
| 147 |
+
# ---- Determine level ------------------------------------ #
|
| 148 |
+
if is_contradicted:
|
| 149 |
+
level = LEVEL_CONTRADICTED
|
| 150 |
+
elif staleness > STALENESS_STALE_DAYS:
|
| 151 |
+
level = LEVEL_STALE
|
| 152 |
+
elif (
|
| 153 |
+
reliability >= RELIABILITY_HIGH_THRESHOLD
|
| 154 |
+
and access_count >= ACCESS_COUNT_HIGH_EVIDENCE
|
| 155 |
+
and source_trust >= 0.75
|
| 156 |
+
):
|
| 157 |
+
level = LEVEL_HIGH
|
| 158 |
+
elif reliability >= RELIABILITY_MEDIUM_THRESHOLD and access_count >= ACCESS_COUNT_MIN_EVIDENCE:
|
| 159 |
+
level = LEVEL_MEDIUM
|
| 160 |
+
else:
|
| 161 |
+
level = LEVEL_LOW
|
| 162 |
+
|
| 163 |
+
envelope: Dict[str, Any] = {
|
| 164 |
+
"level": level,
|
| 165 |
+
"reliability": round(reliability, 4),
|
| 166 |
+
"access_count": access_count,
|
| 167 |
+
"staleness_days": round(staleness, 1),
|
| 168 |
+
"source_type": source_type,
|
| 169 |
+
"source_trust": round(source_trust, 2),
|
| 170 |
+
"is_contradicted": is_contradicted,
|
| 171 |
+
}
|
| 172 |
+
if last_verified:
|
| 173 |
+
envelope["last_verified"] = last_verified
|
| 174 |
+
|
| 175 |
+
return envelope
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ------------------------------------------------------------------ #
|
| 179 |
+
# Convenience function #
|
| 180 |
+
# ------------------------------------------------------------------ #
|
| 181 |
+
|
| 182 |
+
def build_confidence_envelope(
|
| 183 |
+
node: "MemoryNode",
|
| 184 |
+
provenance: Optional["ProvenanceRecord"] = None,
|
| 185 |
+
) -> Dict[str, Any]:
|
| 186 |
+
"""
|
| 187 |
+
Module-level shortcut for ConfidenceEnvelopeGenerator.build().
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
node: MemoryNode to evaluate.
|
| 191 |
+
provenance: Optional ProvenanceRecord for the node.
|
| 192 |
+
|
| 193 |
+
Returns:
|
| 194 |
+
confidence_envelope dict with level, reliability, staleness, etc.
|
| 195 |
+
"""
|
| 196 |
+
return ConfidenceEnvelopeGenerator.build(node, provenance)
|
src/mnemocore/core/config.py
CHANGED
|
@@ -139,6 +139,37 @@ class EncodingConfig:
|
|
| 139 |
token_method: str = "bundle"
|
| 140 |
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
@dataclass(frozen=True)
|
| 143 |
class DreamLoopConfig:
|
| 144 |
"""Configuration for the dream loop (subconscious background processing)."""
|
|
@@ -194,11 +225,19 @@ class SubconsciousAIConfig:
|
|
| 194 |
max_memories_per_cycle: int = 10 # Process at most N memories per pulse
|
| 195 |
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
@dataclass(frozen=True)
|
| 198 |
class HAIMConfig:
|
| 199 |
"""Root configuration for the HAIM system."""
|
| 200 |
|
| 201 |
-
version: str = "
|
| 202 |
dimensionality: int = 16384
|
| 203 |
encoding: EncodingConfig = field(default_factory=EncodingConfig)
|
| 204 |
tiers_hot: TierConfig = field(
|
|
@@ -230,8 +269,13 @@ class HAIMConfig:
|
|
| 230 |
paths: PathsConfig = field(default_factory=PathsConfig)
|
| 231 |
consolidation: ConsolidationConfig = field(default_factory=ConsolidationConfig)
|
| 232 |
attention_masking: AttentionMaskingConfig = field(default_factory=AttentionMaskingConfig)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
dream_loop: DreamLoopConfig = field(default_factory=DreamLoopConfig)
|
| 234 |
subconscious_ai: SubconsciousAIConfig = field(default_factory=SubconsciousAIConfig)
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
def _env_override(key: str, default):
|
|
@@ -347,15 +391,6 @@ def load_config(path: Optional[Path] = None) -> HAIMConfig:
|
|
| 347 |
token_method=enc_raw.get("token_method", "bundle"),
|
| 348 |
)
|
| 349 |
|
| 350 |
-
# Build LTP config
|
| 351 |
-
ltp_raw = raw.get("ltp") or {}
|
| 352 |
-
ltp = LTPConfig(
|
| 353 |
-
initial_importance=ltp_raw.get("initial_importance", 0.5),
|
| 354 |
-
decay_lambda=ltp_raw.get("decay_lambda", 0.01),
|
| 355 |
-
permanence_threshold=ltp_raw.get("permanence_threshold", 0.95),
|
| 356 |
-
half_life_days=ltp_raw.get("half_life_days", 30.0),
|
| 357 |
-
)
|
| 358 |
-
|
| 359 |
# Build paths config
|
| 360 |
paths_raw = raw.get("paths") or {}
|
| 361 |
paths = PathsConfig(
|
|
@@ -499,6 +534,37 @@ def load_config(path: Optional[Path] = None) -> HAIMConfig:
|
|
| 499 |
model=_env_override("DREAM_LOOP_MODEL", dream_raw.get("model", "gemma3:1b")),
|
| 500 |
)
|
| 501 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
# Build subconscious AI config (Phase 4.4 BETA)
|
| 503 |
sub_raw = raw.get("subconscious_ai") or {}
|
| 504 |
subconscious_ai = SubconsciousAIConfig(
|
|
@@ -524,8 +590,17 @@ def load_config(path: Optional[Path] = None) -> HAIMConfig:
|
|
| 524 |
max_memories_per_cycle=_env_override("SUBCONSCIOUS_AI_MAX_MEMORIES_PER_CYCLE", sub_raw.get("max_memories_per_cycle", 10)),
|
| 525 |
)
|
| 526 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
return HAIMConfig(
|
| 528 |
-
version=raw.get("version", "
|
| 529 |
dimensionality=dimensionality,
|
| 530 |
encoding=encoding,
|
| 531 |
tiers_hot=_build_tier("hot", hot_raw),
|
|
@@ -542,8 +617,13 @@ def load_config(path: Optional[Path] = None) -> HAIMConfig:
|
|
| 542 |
paths=paths,
|
| 543 |
consolidation=consolidation,
|
| 544 |
attention_masking=attention_masking,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
dream_loop=dream_loop,
|
| 546 |
subconscious_ai=subconscious_ai,
|
|
|
|
| 547 |
)
|
| 548 |
|
| 549 |
|
|
|
|
| 139 |
token_method: str = "bundle"
|
| 140 |
|
| 141 |
|
| 142 |
+
@dataclass(frozen=True)
|
| 143 |
+
class SynapseConfig:
|
| 144 |
+
"""Configuration for Phase 12.1: Aggressive Synapse Formation"""
|
| 145 |
+
similarity_threshold: float = 0.5
|
| 146 |
+
auto_bind_on_store: bool = True
|
| 147 |
+
multi_hop_depth: int = 2
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@dataclass(frozen=True)
|
| 151 |
+
class ContextConfig:
|
| 152 |
+
"""Configuration for Phase 12.2: Contextual Awareness"""
|
| 153 |
+
enabled: bool = True
|
| 154 |
+
shift_threshold: float = 0.3
|
| 155 |
+
rolling_window_size: int = 5
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@dataclass(frozen=True)
|
| 159 |
+
class PreferenceConfig:
|
| 160 |
+
"""Configuration for Phase 12.3: Preference Learning"""
|
| 161 |
+
enabled: bool = True
|
| 162 |
+
learning_rate: float = 0.1
|
| 163 |
+
history_limit: int = 100
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@dataclass(frozen=True)
|
| 167 |
+
class AnticipatoryConfig:
|
| 168 |
+
"""Configuration for Phase 13.2: Anticipatory Memory"""
|
| 169 |
+
enabled: bool = True
|
| 170 |
+
predictive_depth: int = 1
|
| 171 |
+
|
| 172 |
+
|
| 173 |
@dataclass(frozen=True)
|
| 174 |
class DreamLoopConfig:
|
| 175 |
"""Configuration for the dream loop (subconscious background processing)."""
|
|
|
|
| 225 |
max_memories_per_cycle: int = 10 # Process at most N memories per pulse
|
| 226 |
|
| 227 |
|
| 228 |
+
@dataclass(frozen=True)
|
| 229 |
+
class PulseConfig:
|
| 230 |
+
"""Configuration for Phase 5 AGI Pulse Loop orchestrator."""
|
| 231 |
+
enabled: bool = True
|
| 232 |
+
interval_seconds: int = 30
|
| 233 |
+
max_agents_per_tick: int = 50
|
| 234 |
+
max_episodes_per_tick: int = 200
|
| 235 |
+
|
| 236 |
@dataclass(frozen=True)
|
| 237 |
class HAIMConfig:
|
| 238 |
"""Root configuration for the HAIM system."""
|
| 239 |
|
| 240 |
+
version: str = "4.5"
|
| 241 |
dimensionality: int = 16384
|
| 242 |
encoding: EncodingConfig = field(default_factory=EncodingConfig)
|
| 243 |
tiers_hot: TierConfig = field(
|
|
|
|
| 269 |
paths: PathsConfig = field(default_factory=PathsConfig)
|
| 270 |
consolidation: ConsolidationConfig = field(default_factory=ConsolidationConfig)
|
| 271 |
attention_masking: AttentionMaskingConfig = field(default_factory=AttentionMaskingConfig)
|
| 272 |
+
synapse: SynapseConfig = field(default_factory=SynapseConfig)
|
| 273 |
+
context: ContextConfig = field(default_factory=ContextConfig)
|
| 274 |
+
preference: PreferenceConfig = field(default_factory=PreferenceConfig)
|
| 275 |
+
anticipatory: AnticipatoryConfig = field(default_factory=AnticipatoryConfig)
|
| 276 |
dream_loop: DreamLoopConfig = field(default_factory=DreamLoopConfig)
|
| 277 |
subconscious_ai: SubconsciousAIConfig = field(default_factory=SubconsciousAIConfig)
|
| 278 |
+
pulse: PulseConfig = field(default_factory=PulseConfig)
|
| 279 |
|
| 280 |
|
| 281 |
def _env_override(key: str, default):
|
|
|
|
| 391 |
token_method=enc_raw.get("token_method", "bundle"),
|
| 392 |
)
|
| 393 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
# Build paths config
|
| 395 |
paths_raw = raw.get("paths") or {}
|
| 396 |
paths = PathsConfig(
|
|
|
|
| 534 |
model=_env_override("DREAM_LOOP_MODEL", dream_raw.get("model", "gemma3:1b")),
|
| 535 |
)
|
| 536 |
|
| 537 |
+
# Build synapse config (Phase 12.1)
|
| 538 |
+
syn_raw = raw.get("synapse") or {}
|
| 539 |
+
synapse = SynapseConfig(
|
| 540 |
+
similarity_threshold=_env_override("SYNAPSE_SIMILARITY_THRESHOLD", syn_raw.get("similarity_threshold", 0.5)),
|
| 541 |
+
auto_bind_on_store=_env_override("SYNAPSE_AUTO_BIND_ON_STORE", syn_raw.get("auto_bind_on_store", True)),
|
| 542 |
+
multi_hop_depth=_env_override("SYNAPSE_MULTI_HOP_DEPTH", syn_raw.get("multi_hop_depth", 2)),
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
# Build context config (Phase 12.2)
|
| 546 |
+
ctx_raw = raw.get("context") or {}
|
| 547 |
+
context = ContextConfig(
|
| 548 |
+
enabled=_env_override("CONTEXT_ENABLED", ctx_raw.get("enabled", True)),
|
| 549 |
+
shift_threshold=_env_override("CONTEXT_SHIFT_THRESHOLD", ctx_raw.get("shift_threshold", 0.3)),
|
| 550 |
+
rolling_window_size=_env_override("CONTEXT_ROLLING_WINDOW_SIZE", ctx_raw.get("rolling_window_size", 5)),
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
# Build preference config (Phase 12.3)
|
| 554 |
+
pref_raw = raw.get("preference") or {}
|
| 555 |
+
preference = PreferenceConfig(
|
| 556 |
+
enabled=_env_override("PREFERENCE_ENABLED", pref_raw.get("enabled", True)),
|
| 557 |
+
learning_rate=_env_override("PREFERENCE_LEARNING_RATE", pref_raw.get("learning_rate", 0.1)),
|
| 558 |
+
history_limit=_env_override("PREFERENCE_HISTORY_LIMIT", pref_raw.get("history_limit", 100)),
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
# Build anticipatory config (Phase 13.2)
|
| 562 |
+
ant_raw = raw.get("anticipatory") or {}
|
| 563 |
+
anticipatory = AnticipatoryConfig(
|
| 564 |
+
enabled=_env_override("ANTICIPATORY_ENABLED", ant_raw.get("enabled", True)),
|
| 565 |
+
predictive_depth=_env_override("ANTICIPATORY_PREDICTIVE_DEPTH", ant_raw.get("predictive_depth", 1)),
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
# Build subconscious AI config (Phase 4.4 BETA)
|
| 569 |
sub_raw = raw.get("subconscious_ai") or {}
|
| 570 |
subconscious_ai = SubconsciousAIConfig(
|
|
|
|
| 590 |
max_memories_per_cycle=_env_override("SUBCONSCIOUS_AI_MAX_MEMORIES_PER_CYCLE", sub_raw.get("max_memories_per_cycle", 10)),
|
| 591 |
)
|
| 592 |
|
| 593 |
+
# Build pulse config (Phase 5.0)
|
| 594 |
+
pulse_raw = raw.get("pulse") or {}
|
| 595 |
+
pulse = PulseConfig(
|
| 596 |
+
enabled=_env_override("PULSE_ENABLED", pulse_raw.get("enabled", True)),
|
| 597 |
+
interval_seconds=_env_override("PULSE_INTERVAL_SECONDS", pulse_raw.get("interval_seconds", 30)),
|
| 598 |
+
max_agents_per_tick=_env_override("PULSE_MAX_AGENTS_PER_TICK", pulse_raw.get("max_agents_per_tick", 50)),
|
| 599 |
+
max_episodes_per_tick=_env_override("PULSE_MAX_EPISODES_PER_TICK", pulse_raw.get("max_episodes_per_tick", 200)),
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
return HAIMConfig(
|
| 603 |
+
version=raw.get("version", "4.5"),
|
| 604 |
dimensionality=dimensionality,
|
| 605 |
encoding=encoding,
|
| 606 |
tiers_hot=_build_tier("hot", hot_raw),
|
|
|
|
| 617 |
paths=paths,
|
| 618 |
consolidation=consolidation,
|
| 619 |
attention_masking=attention_masking,
|
| 620 |
+
synapse=synapse,
|
| 621 |
+
context=context,
|
| 622 |
+
preference=preference,
|
| 623 |
+
anticipatory=anticipatory,
|
| 624 |
dream_loop=dream_loop,
|
| 625 |
subconscious_ai=subconscious_ai,
|
| 626 |
+
pulse=pulse,
|
| 627 |
)
|
| 628 |
|
| 629 |
|
src/mnemocore/core/container.py
CHANGED
|
@@ -12,6 +12,14 @@ from .config import HAIMConfig
|
|
| 12 |
from .async_storage import AsyncRedisStorage
|
| 13 |
from .qdrant_store import QdrantStore
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@dataclass
|
| 17 |
class Container:
|
|
@@ -21,6 +29,14 @@ class Container:
|
|
| 21 |
config: HAIMConfig
|
| 22 |
redis_storage: Optional[AsyncRedisStorage] = None
|
| 23 |
qdrant_store: Optional[QdrantStore] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def build_container(config: HAIMConfig) -> Container:
|
|
@@ -57,6 +73,14 @@ def build_container(config: HAIMConfig) -> Container:
|
|
| 57 |
hnsw_ef_construct=config.qdrant.hnsw_ef_construct,
|
| 58 |
)
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
return container
|
| 61 |
|
| 62 |
|
|
|
|
| 12 |
from .async_storage import AsyncRedisStorage
|
| 13 |
from .qdrant_store import QdrantStore
|
| 14 |
|
| 15 |
+
# Phase 5 AGI Services
|
| 16 |
+
from .working_memory import WorkingMemoryService
|
| 17 |
+
from .episodic_store import EpisodicStoreService
|
| 18 |
+
from .semantic_store import SemanticStoreService
|
| 19 |
+
from .procedural_store import ProceduralStoreService
|
| 20 |
+
from .meta_memory import MetaMemoryService
|
| 21 |
+
from .agent_profile import AgentProfileService
|
| 22 |
+
|
| 23 |
|
| 24 |
@dataclass
|
| 25 |
class Container:
|
|
|
|
| 29 |
config: HAIMConfig
|
| 30 |
redis_storage: Optional[AsyncRedisStorage] = None
|
| 31 |
qdrant_store: Optional[QdrantStore] = None
|
| 32 |
+
|
| 33 |
+
# Phase 5 Services
|
| 34 |
+
working_memory: Optional[WorkingMemoryService] = None
|
| 35 |
+
episodic_store: Optional[EpisodicStoreService] = None
|
| 36 |
+
semantic_store: Optional[SemanticStoreService] = None
|
| 37 |
+
procedural_store: Optional[ProceduralStoreService] = None
|
| 38 |
+
meta_memory: Optional[MetaMemoryService] = None
|
| 39 |
+
agent_profiles: Optional[AgentProfileService] = None
|
| 40 |
|
| 41 |
|
| 42 |
def build_container(config: HAIMConfig) -> Container:
|
|
|
|
| 73 |
hnsw_ef_construct=config.qdrant.hnsw_ef_construct,
|
| 74 |
)
|
| 75 |
|
| 76 |
+
# Initialize Phase 5 AGI Services
|
| 77 |
+
container.working_memory = WorkingMemoryService()
|
| 78 |
+
container.episodic_store = EpisodicStoreService()
|
| 79 |
+
container.semantic_store = SemanticStoreService(qdrant_store=container.qdrant_store)
|
| 80 |
+
container.procedural_store = ProceduralStoreService()
|
| 81 |
+
container.meta_memory = MetaMemoryService()
|
| 82 |
+
container.agent_profiles = AgentProfileService()
|
| 83 |
+
|
| 84 |
return container
|
| 85 |
|
| 86 |
|
src/mnemocore/core/contradiction.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Contradiction Detection Module (Phase 5.0)
|
| 3 |
+
==========================================
|
| 4 |
+
Detects contradicting memories in MnemoCore using a two-stage pipeline:
|
| 5 |
+
|
| 6 |
+
Stage 1: TextEncoder similarity search (fast, vector-based)
|
| 7 |
+
- At /store time: compare new memory against top-5 existing memories
|
| 8 |
+
- If similarity > SIMILARITY_THRESHOLD (0.80) → proceed to Stage 2
|
| 9 |
+
|
| 10 |
+
Stage 2: LLM-based semantic comparison (accurate, but heavier)
|
| 11 |
+
- Uses SubconsciousAI connector to evaluate if two memories actually contradict
|
| 12 |
+
- Avoids false positives from paraphrases (similarity doesn't mean contradiction)
|
| 13 |
+
|
| 14 |
+
On confirmed contradiction:
|
| 15 |
+
- Both memories receive a 'contradiction_group_id' in their provenance lineage
|
| 16 |
+
- Both are flagged in their metadata
|
| 17 |
+
- The API returns an alert in the store response
|
| 18 |
+
- Entries are added to a ContradictionRegistry for the /contradictions endpoint
|
| 19 |
+
|
| 20 |
+
Background scan:
|
| 21 |
+
- ContradictionDetector.scan(nodes) can be called from ConsolidationWorker
|
| 22 |
+
|
| 23 |
+
Public API:
|
| 24 |
+
detector = ContradictionDetector(engine)
|
| 25 |
+
result = await detector.check_on_store(new_content, new_node, existing_nodes)
|
| 26 |
+
all = detector.registry.list_all()
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import uuid
|
| 32 |
+
from dataclasses import dataclass, field
|
| 33 |
+
from datetime import datetime, timezone
|
| 34 |
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
| 35 |
+
|
| 36 |
+
from loguru import logger
|
| 37 |
+
|
| 38 |
+
if TYPE_CHECKING:
|
| 39 |
+
from .node import MemoryNode
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ------------------------------------------------------------------ #
|
| 43 |
+
# Thresholds #
|
| 44 |
+
# ------------------------------------------------------------------ #
|
| 45 |
+
|
| 46 |
+
SIMILARITY_THRESHOLD: float = 0.80 # Above this → suspect contradiction
|
| 47 |
+
LLM_CONFIRM_MIN_SCORE: float = 0.70 # LLM contradiction confidence minimum
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ------------------------------------------------------------------ #
|
| 51 |
+
# ContradictionRecord #
|
| 52 |
+
# ------------------------------------------------------------------ #
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class ContradictionRecord:
|
| 56 |
+
"""A detected contradiction between two memories."""
|
| 57 |
+
group_id: str = field(default_factory=lambda: f"cg_{uuid.uuid4().hex[:12]}")
|
| 58 |
+
memory_a_id: str = ""
|
| 59 |
+
memory_b_id: str = ""
|
| 60 |
+
similarity_score: float = 0.0
|
| 61 |
+
llm_confirmed: bool = False
|
| 62 |
+
detected_at: str = field(
|
| 63 |
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
| 64 |
+
)
|
| 65 |
+
resolved: bool = False
|
| 66 |
+
resolution_note: Optional[str] = None
|
| 67 |
+
|
| 68 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 69 |
+
return {
|
| 70 |
+
"group_id": self.group_id,
|
| 71 |
+
"memory_a_id": self.memory_a_id,
|
| 72 |
+
"memory_b_id": self.memory_b_id,
|
| 73 |
+
"similarity_score": round(self.similarity_score, 4),
|
| 74 |
+
"llm_confirmed": self.llm_confirmed,
|
| 75 |
+
"detected_at": self.detected_at,
|
| 76 |
+
"resolved": self.resolved,
|
| 77 |
+
"resolution_note": self.resolution_note,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ------------------------------------------------------------------ #
|
| 82 |
+
# ContradictionRegistry #
|
| 83 |
+
# ------------------------------------------------------------------ #
|
| 84 |
+
|
| 85 |
+
class ContradictionRegistry:
|
| 86 |
+
"""In-memory store of detected contradictions (survives until restart)."""
|
| 87 |
+
|
| 88 |
+
def __init__(self) -> None:
|
| 89 |
+
self._records: Dict[str, ContradictionRecord] = {}
|
| 90 |
+
|
| 91 |
+
def register(self, record: ContradictionRecord) -> None:
|
| 92 |
+
self._records[record.group_id] = record
|
| 93 |
+
|
| 94 |
+
def resolve(self, group_id: str, note: Optional[str] = None) -> bool:
|
| 95 |
+
if group_id in self._records:
|
| 96 |
+
self._records[group_id].resolved = True
|
| 97 |
+
self._records[group_id].resolution_note = note
|
| 98 |
+
return True
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
def list_all(self, unresolved_only: bool = True) -> List[ContradictionRecord]:
|
| 102 |
+
recs = list(self._records.values())
|
| 103 |
+
if unresolved_only:
|
| 104 |
+
recs = [r for r in recs if not r.resolved]
|
| 105 |
+
return sorted(recs, key=lambda r: r.detected_at, reverse=True)
|
| 106 |
+
|
| 107 |
+
def list_for_memory(self, memory_id: str) -> List[ContradictionRecord]:
|
| 108 |
+
return [
|
| 109 |
+
r for r in self._records.values()
|
| 110 |
+
if r.memory_a_id == memory_id or r.memory_b_id == memory_id
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
def __len__(self) -> int:
|
| 114 |
+
return len([r for r in self._records.values() if not r.resolved])
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ------------------------------------------------------------------ #
|
| 118 |
+
# ContradictionDetector #
|
| 119 |
+
# ------------------------------------------------------------------ #
|
| 120 |
+
|
| 121 |
+
class ContradictionDetector:
|
| 122 |
+
"""
|
| 123 |
+
Two-stage contradiction detector.
|
| 124 |
+
|
| 125 |
+
Stage 1: Vector similarity via the engine's binary HDV comparison.
|
| 126 |
+
Stage 2: LLM semantic check via SubconsciousAI (optional).
|
| 127 |
+
"""
|
| 128 |
+
|
| 129 |
+
def __init__(
|
| 130 |
+
self,
|
| 131 |
+
engine=None, # HAIMEngine — optional; if None, similarity check uses fallback
|
| 132 |
+
similarity_threshold: float = SIMILARITY_THRESHOLD,
|
| 133 |
+
top_k: int = 5,
|
| 134 |
+
use_llm: bool = True,
|
| 135 |
+
) -> None:
|
| 136 |
+
self.engine = engine
|
| 137 |
+
self.similarity_threshold = similarity_threshold
|
| 138 |
+
self.top_k = top_k
|
| 139 |
+
self.use_llm = use_llm
|
| 140 |
+
self.registry = ContradictionRegistry()
|
| 141 |
+
|
| 142 |
+
# ---- Similarity helpers -------------------------------------- #
|
| 143 |
+
|
| 144 |
+
def _hamming_similarity(self, node_a: "MemoryNode", node_b: "MemoryNode") -> float:
|
| 145 |
+
"""
|
| 146 |
+
Compute binary HDV similarity between two nodes.
|
| 147 |
+
Similarity = 1 - normalized_hamming_distance.
|
| 148 |
+
"""
|
| 149 |
+
try:
|
| 150 |
+
import numpy as np
|
| 151 |
+
a = node_a.hdv.data
|
| 152 |
+
b = node_b.hdv.data
|
| 153 |
+
xor = np.bitwise_xor(a, b)
|
| 154 |
+
ham = float(bin(int.from_bytes(xor.tobytes(), "little")).count("1"))
|
| 155 |
+
dim = len(a) * 8
|
| 156 |
+
return 1.0 - ham / dim
|
| 157 |
+
except Exception:
|
| 158 |
+
return 0.0
|
| 159 |
+
|
| 160 |
+
# ---- LLM contradiction check --------------------------------- #
|
| 161 |
+
|
| 162 |
+
async def _llm_contradicts(
|
| 163 |
+
self, content_a: str, content_b: str
|
| 164 |
+
) -> Tuple[bool, float]:
|
| 165 |
+
"""
|
| 166 |
+
Ask SubconsciousAI if two contents contradict each other.
|
| 167 |
+
Returns (is_contradiction, confidence_score).
|
| 168 |
+
Falls back to False if LLM is unavailable.
|
| 169 |
+
"""
|
| 170 |
+
if not self.engine or not self.use_llm:
|
| 171 |
+
return False, 0.0
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
subcon = getattr(self.engine, "subconscious_ai", None)
|
| 175 |
+
if subcon is None:
|
| 176 |
+
return False, 0.0
|
| 177 |
+
|
| 178 |
+
prompt = (
|
| 179 |
+
"Do the following two statements contradict each other? "
|
| 180 |
+
"Answer with a JSON object: {\"contradiction\": true/false, \"confidence\": 0.0-1.0}.\n\n"
|
| 181 |
+
f"Statement A: {content_a[:500]}\n"
|
| 182 |
+
f"Statement B: {content_b[:500]}"
|
| 183 |
+
)
|
| 184 |
+
raw = await subcon.generate(prompt, max_tokens=64)
|
| 185 |
+
import json as _json
|
| 186 |
+
parsed = _json.loads(raw.strip())
|
| 187 |
+
return bool(parsed.get("contradiction", False)), float(parsed.get("confidence", 0.0))
|
| 188 |
+
except Exception as exc:
|
| 189 |
+
logger.debug(f"LLM contradiction check failed: {exc}")
|
| 190 |
+
return False, 0.0
|
| 191 |
+
|
| 192 |
+
# ---- Flag helpers ------------------------------------------- #
|
| 193 |
+
|
| 194 |
+
def _flag_node(self, node: "MemoryNode", group_id: str) -> None:
|
| 195 |
+
"""Attach contradiction metadata to a node's provenance and metadata fields."""
|
| 196 |
+
node.metadata["contradiction_group_id"] = group_id
|
| 197 |
+
node.metadata["contradicted_at"] = datetime.now(timezone.utc).isoformat()
|
| 198 |
+
|
| 199 |
+
prov = getattr(node, "provenance", None)
|
| 200 |
+
if prov is not None:
|
| 201 |
+
prov.mark_contradicted(group_id)
|
| 202 |
+
|
| 203 |
+
# ---- Main API ------------------------------------------------ #
|
| 204 |
+
|
| 205 |
+
async def check_on_store(
|
| 206 |
+
self,
|
| 207 |
+
new_node: "MemoryNode",
|
| 208 |
+
candidates: Optional[List["MemoryNode"]] = None,
|
| 209 |
+
) -> Optional[ContradictionRecord]:
|
| 210 |
+
"""
|
| 211 |
+
Check a newly stored node against existing memories.
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
new_node: The node just stored.
|
| 215 |
+
candidates: Optional pre-fetched list of nodes to compare against.
|
| 216 |
+
If None and engine is available, fetches via HDV search.
|
| 217 |
+
|
| 218 |
+
Returns:
|
| 219 |
+
ContradictionRecord if a contradiction was detected, else None.
|
| 220 |
+
"""
|
| 221 |
+
# Fetch candidates if not provided
|
| 222 |
+
if candidates is None and self.engine is not None:
|
| 223 |
+
try:
|
| 224 |
+
results = await self.engine.query(
|
| 225 |
+
new_node.content, top_k=self.top_k
|
| 226 |
+
)
|
| 227 |
+
nodes = []
|
| 228 |
+
for mem_id, _score in results:
|
| 229 |
+
n = await self.engine.get_memory(mem_id)
|
| 230 |
+
if n and n.id != new_node.id:
|
| 231 |
+
nodes.append(n)
|
| 232 |
+
candidates = nodes
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.debug(f"ContradictionDetector: candidate fetch failed: {e}")
|
| 235 |
+
candidates = []
|
| 236 |
+
|
| 237 |
+
if not candidates:
|
| 238 |
+
return None
|
| 239 |
+
|
| 240 |
+
# Stage 1: similarity filter
|
| 241 |
+
high_sim_candidates = []
|
| 242 |
+
for cand in candidates:
|
| 243 |
+
sim = self._hamming_similarity(new_node, cand)
|
| 244 |
+
if sim >= self.similarity_threshold:
|
| 245 |
+
high_sim_candidates.append((cand, sim))
|
| 246 |
+
|
| 247 |
+
if not high_sim_candidates:
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
# Stage 2: LLM confirmation for the highest-similarity candidate
|
| 251 |
+
high_sim_candidates.sort(key=lambda x: x[1], reverse=True)
|
| 252 |
+
top_cand, top_sim = high_sim_candidates[0]
|
| 253 |
+
|
| 254 |
+
is_contradiction = False
|
| 255 |
+
llm_confirmed = False
|
| 256 |
+
|
| 257 |
+
if self.use_llm:
|
| 258 |
+
is_contradiction, conf = await self._llm_contradicts(
|
| 259 |
+
new_node.content, top_cand.content
|
| 260 |
+
)
|
| 261 |
+
llm_confirmed = is_contradiction and conf >= LLM_CONFIRM_MIN_SCORE
|
| 262 |
+
else:
|
| 263 |
+
# Without LLM, use high similarity as a soft contradiction signal
|
| 264 |
+
is_contradiction = top_sim >= 0.90
|
| 265 |
+
llm_confirmed = False
|
| 266 |
+
|
| 267 |
+
if not is_contradiction:
|
| 268 |
+
return None
|
| 269 |
+
|
| 270 |
+
# Register the contradiction
|
| 271 |
+
record = ContradictionRecord(
|
| 272 |
+
memory_a_id=new_node.id,
|
| 273 |
+
memory_b_id=top_cand.id,
|
| 274 |
+
similarity_score=top_sim,
|
| 275 |
+
llm_confirmed=llm_confirmed,
|
| 276 |
+
)
|
| 277 |
+
self.registry.register(record)
|
| 278 |
+
self._flag_node(new_node, record.group_id)
|
| 279 |
+
self._flag_node(top_cand, record.group_id)
|
| 280 |
+
|
| 281 |
+
logger.warning(
|
| 282 |
+
f"⚠️ Contradiction detected: {new_node.id[:8]} ↔ {top_cand.id[:8]} "
|
| 283 |
+
f"(sim={top_sim:.3f}, llm_confirmed={llm_confirmed}, group={record.group_id})"
|
| 284 |
+
)
|
| 285 |
+
return record
|
| 286 |
+
|
| 287 |
+
async def scan(self, nodes: "List[MemoryNode]") -> List[ContradictionRecord]:
|
| 288 |
+
"""
|
| 289 |
+
Background scan: compare each node against its peers in the provided list.
|
| 290 |
+
Called periodically from ConsolidationWorker.
|
| 291 |
+
|
| 292 |
+
Returns all newly detected contradiction records.
|
| 293 |
+
"""
|
| 294 |
+
found: List[ContradictionRecord] = []
|
| 295 |
+
n = len(nodes)
|
| 296 |
+
for i in range(n):
|
| 297 |
+
for j in range(i + 1, n):
|
| 298 |
+
sim = self._hamming_similarity(nodes[i], nodes[j])
|
| 299 |
+
if sim < self.similarity_threshold:
|
| 300 |
+
continue
|
| 301 |
+
is_contradiction, _ = await self._llm_contradicts(
|
| 302 |
+
nodes[i].content, nodes[j].content
|
| 303 |
+
)
|
| 304 |
+
if not is_contradiction:
|
| 305 |
+
continue
|
| 306 |
+
record = ContradictionRecord(
|
| 307 |
+
memory_a_id=nodes[i].id,
|
| 308 |
+
memory_b_id=nodes[j].id,
|
| 309 |
+
similarity_score=sim,
|
| 310 |
+
llm_confirmed=True,
|
| 311 |
+
)
|
| 312 |
+
self.registry.register(record)
|
| 313 |
+
self._flag_node(nodes[i], record.group_id)
|
| 314 |
+
self._flag_node(nodes[j], record.group_id)
|
| 315 |
+
found.append(record)
|
| 316 |
+
|
| 317 |
+
if found:
|
| 318 |
+
logger.info(f"ContradictionDetector background scan: {len(found)} contradictions found in {n} nodes")
|
| 319 |
+
return found
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# ------------------------------------------------------------------ #
|
| 323 |
+
# Module singleton #
|
| 324 |
+
# ------------------------------------------------------------------ #
|
| 325 |
+
|
| 326 |
+
_DETECTOR: ContradictionDetector | None = None
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def get_contradiction_detector(engine=None) -> ContradictionDetector:
|
| 330 |
+
"""Return the shared ContradictionDetector singleton."""
|
| 331 |
+
global _DETECTOR
|
| 332 |
+
if _DETECTOR is None:
|
| 333 |
+
_DETECTOR = ContradictionDetector(engine=engine)
|
| 334 |
+
elif engine is not None and _DETECTOR.engine is None:
|
| 335 |
+
_DETECTOR.engine = engine
|
| 336 |
+
return _DETECTOR
|
src/mnemocore/core/cross_domain.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cross-Domain Association Builder (Phase 5.0 — Agent 3)
|
| 3 |
+
=======================================================
|
| 4 |
+
Automatically links memories across three semantic domains:
|
| 5 |
+
|
| 6 |
+
strategic – goals, decisions, roadmaps, strategies
|
| 7 |
+
operational – code, bugs, documentation, tasks
|
| 8 |
+
personal – preferences, habits, relationships, context
|
| 9 |
+
|
| 10 |
+
Cross-domain synapses improve holistic reasoning: when a strategic
|
| 11 |
+
goal changes, the system can surface related operational tasks or
|
| 12 |
+
personal context without being explicitly queried.
|
| 13 |
+
|
| 14 |
+
Implementation:
|
| 15 |
+
- Each memory is tagged with a `domain` in its metadata (or inferred)
|
| 16 |
+
- CrossDomainSynapseBuilder monitors recently stored memories
|
| 17 |
+
- Co-occurrence within a time window → create a cross-domain synapse
|
| 18 |
+
- Synapse weight is damped (0.2×) relative to intra-domain (1.0×)
|
| 19 |
+
|
| 20 |
+
Integration with RippleContext:
|
| 21 |
+
- ripple_context.py uses domain_weight when propagating context
|
| 22 |
+
- Cross-domain propagation uses CROSS_DOMAIN_WEIGHT as the multiplier
|
| 23 |
+
|
| 24 |
+
Public API:
|
| 25 |
+
builder = CrossDomainSynapseBuilder(engine)
|
| 26 |
+
await builder.process_new_memory(node) # call after /store
|
| 27 |
+
pairs = await builder.scan_recent(hours=1) # background scan
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
from datetime import datetime, timezone, timedelta
|
| 33 |
+
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple
|
| 34 |
+
from loguru import logger
|
| 35 |
+
|
| 36 |
+
if TYPE_CHECKING:
|
| 37 |
+
from .node import MemoryNode
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ------------------------------------------------------------------ #
|
| 41 |
+
# Constants #
|
| 42 |
+
# ------------------------------------------------------------------ #
|
| 43 |
+
|
| 44 |
+
DOMAINS = {"strategic", "operational", "personal"}
|
| 45 |
+
DEFAULT_DOMAIN = "operational"
|
| 46 |
+
|
| 47 |
+
# Weight applied to cross-domain synapses (vs 1.0 for intra-domain)
|
| 48 |
+
CROSS_DOMAIN_WEIGHT: float = 0.2
|
| 49 |
+
|
| 50 |
+
# Time window for co-occurrence detection (hours)
|
| 51 |
+
COOCCURRENCE_WINDOW_HOURS: float = 2.0
|
| 52 |
+
|
| 53 |
+
# Keywords used to infer domain automatically if not tagged
|
| 54 |
+
DOMAIN_KEYWORDS: Dict[str, List[str]] = {
|
| 55 |
+
"strategic": [
|
| 56 |
+
"goal", "strategy", "roadmap", "vision", "mission", "objective",
|
| 57 |
+
"decision", "priority", "kpi", "okr", "plan", "budget", "market",
|
| 58 |
+
],
|
| 59 |
+
"personal": [
|
| 60 |
+
"prefer", "habit", "feel", "emotion", "prefer", "like", "dislike",
|
| 61 |
+
"relationship", "trust", "colleague", "friend", "name", "remember me",
|
| 62 |
+
],
|
| 63 |
+
"operational": [
|
| 64 |
+
"code", "bug", "fix", "implement", "test", "deploy", "api",
|
| 65 |
+
"function", "class", "module", "error", "exception", "task", "ticket",
|
| 66 |
+
],
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ------------------------------------------------------------------ #
|
| 71 |
+
# Domain inference #
|
| 72 |
+
# ------------------------------------------------------------------ #
|
| 73 |
+
|
| 74 |
+
def infer_domain(content: str, metadata: Optional[Dict] = None) -> str:
|
| 75 |
+
"""
|
| 76 |
+
Infer the semantic domain of a memory from its content and metadata.
|
| 77 |
+
|
| 78 |
+
Priority:
|
| 79 |
+
1. metadata["domain"] if set
|
| 80 |
+
2. keyword match in content (highest score wins)
|
| 81 |
+
3. DEFAULT_DOMAIN ("operational")
|
| 82 |
+
"""
|
| 83 |
+
if metadata and "domain" in metadata:
|
| 84 |
+
d = metadata["domain"].lower()
|
| 85 |
+
return d if d in DOMAINS else DEFAULT_DOMAIN
|
| 86 |
+
|
| 87 |
+
content_lower = content.lower()
|
| 88 |
+
best_domain = DEFAULT_DOMAIN
|
| 89 |
+
best_count = 0
|
| 90 |
+
|
| 91 |
+
for domain, keywords in DOMAIN_KEYWORDS.items():
|
| 92 |
+
count = sum(1 for kw in keywords if kw in content_lower)
|
| 93 |
+
if count > best_count:
|
| 94 |
+
best_count = count
|
| 95 |
+
best_domain = domain
|
| 96 |
+
|
| 97 |
+
return best_domain
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ------------------------------------------------------------------ #
|
| 101 |
+
# CrossDomainSynapseBuilder #
|
| 102 |
+
# ------------------------------------------------------------------ #
|
| 103 |
+
|
| 104 |
+
class CrossDomainSynapseBuilder:
|
| 105 |
+
"""
|
| 106 |
+
Detects cross-domain co-occurrences and requests synapse creation.
|
| 107 |
+
|
| 108 |
+
Works by maintaining a rolling buffer of recently stored memories,
|
| 109 |
+
then pairing memories from different domains that appeared within
|
| 110 |
+
COOCCURRENCE_WINDOW_HOURS of each other.
|
| 111 |
+
"""
|
| 112 |
+
|
| 113 |
+
def __init__(
|
| 114 |
+
self,
|
| 115 |
+
engine=None, # HAIMEngine
|
| 116 |
+
window_hours: float = COOCCURRENCE_WINDOW_HOURS,
|
| 117 |
+
cross_domain_weight: float = CROSS_DOMAIN_WEIGHT,
|
| 118 |
+
) -> None:
|
| 119 |
+
self.engine = engine
|
| 120 |
+
self.window = timedelta(hours=window_hours)
|
| 121 |
+
self.weight = cross_domain_weight
|
| 122 |
+
# Buffer: list of (node_id, domain, stored_at)
|
| 123 |
+
self._buffer: List[Tuple[str, str, datetime]] = []
|
| 124 |
+
|
| 125 |
+
# ---- Domain helpers ------------------------------------------ #
|
| 126 |
+
|
| 127 |
+
def tag_domain(self, node: "MemoryNode") -> str:
|
| 128 |
+
"""Infer and write domain tag to node.metadata. Returns domain string."""
|
| 129 |
+
domain = infer_domain(node.content, getattr(node, "metadata", {}))
|
| 130 |
+
if hasattr(node, "metadata"):
|
| 131 |
+
node.metadata["domain"] = domain
|
| 132 |
+
return domain
|
| 133 |
+
|
| 134 |
+
# ---- Synapse creation --------------------------------------- #
|
| 135 |
+
|
| 136 |
+
async def _create_synapse(self, id_a: str, id_b: str) -> None:
|
| 137 |
+
"""
|
| 138 |
+
Request synapse creation between two nodes via the engine's synapse index.
|
| 139 |
+
Weight is damped by CROSS_DOMAIN_WEIGHT.
|
| 140 |
+
"""
|
| 141 |
+
if self.engine is None:
|
| 142 |
+
logger.debug(f"CrossDomain: no engine, skipping synapse {id_a[:8]} ↔ {id_b[:8]}")
|
| 143 |
+
return
|
| 144 |
+
try:
|
| 145 |
+
synapse_index = getattr(self.engine, "synapse_index", None)
|
| 146 |
+
if synapse_index is not None:
|
| 147 |
+
synapse_index.add_or_strengthen(id_a, id_b, delta=self.weight)
|
| 148 |
+
logger.debug(
|
| 149 |
+
f"CrossDomain synapse created: {id_a[:8]} ↔ {id_b[:8]} weight={self.weight}"
|
| 150 |
+
)
|
| 151 |
+
except Exception as exc:
|
| 152 |
+
logger.debug(f"CrossDomain synapse creation failed: {exc}")
|
| 153 |
+
|
| 154 |
+
# ---- Main API ----------------------------------------------- #
|
| 155 |
+
|
| 156 |
+
async def process_new_memory(self, node: "MemoryNode") -> List[Tuple[str, str]]:
|
| 157 |
+
"""
|
| 158 |
+
Called after a new memory is stored.
|
| 159 |
+
Tags its domain and checks for cross-domain co-occurrences in the buffer.
|
| 160 |
+
|
| 161 |
+
Returns list of (id_a, id_b) pairs for which synapses were created.
|
| 162 |
+
"""
|
| 163 |
+
domain = self.tag_domain(node)
|
| 164 |
+
now = datetime.now(timezone.utc)
|
| 165 |
+
|
| 166 |
+
# Cut stale entries from buffer
|
| 167 |
+
cutoff = now - self.window
|
| 168 |
+
self._buffer = [(nid, d, ts) for nid, d, ts in self._buffer if ts >= cutoff]
|
| 169 |
+
|
| 170 |
+
# Find cross-domain pairs with current node
|
| 171 |
+
pairs: List[Tuple[str, str]] = []
|
| 172 |
+
already_seen: Set[str] = set()
|
| 173 |
+
for existing_id, existing_domain, _ts in self._buffer:
|
| 174 |
+
if existing_domain != domain and existing_id not in already_seen:
|
| 175 |
+
await self._create_synapse(node.id, existing_id)
|
| 176 |
+
pairs.append((node.id, existing_id))
|
| 177 |
+
already_seen.add(existing_id)
|
| 178 |
+
|
| 179 |
+
# Add current node to buffer
|
| 180 |
+
self._buffer.append((node.id, domain, now))
|
| 181 |
+
|
| 182 |
+
if pairs:
|
| 183 |
+
logger.info(
|
| 184 |
+
f"CrossDomain: {len(pairs)} cross-domain synapses created for node {node.id[:8]} (domain={domain})"
|
| 185 |
+
)
|
| 186 |
+
return pairs
|
| 187 |
+
|
| 188 |
+
async def scan_recent(self, hours: float = 1.0) -> List[Tuple[str, str]]:
|
| 189 |
+
"""
|
| 190 |
+
Scan the current buffer for any unpaired cross-domain co-occurrences.
|
| 191 |
+
Returns all cross-domain pairs.
|
| 192 |
+
"""
|
| 193 |
+
now = datetime.now(timezone.utc)
|
| 194 |
+
cutoff = now - timedelta(hours=hours)
|
| 195 |
+
recent = [(nid, d, ts) for nid, d, ts in self._buffer if ts >= cutoff]
|
| 196 |
+
|
| 197 |
+
pairs: List[Tuple[str, str]] = []
|
| 198 |
+
n = len(recent)
|
| 199 |
+
for i in range(n):
|
| 200 |
+
for j in range(i + 1, n):
|
| 201 |
+
id_i, dom_i, _ = recent[i]
|
| 202 |
+
id_j, dom_j, _ = recent[j]
|
| 203 |
+
if dom_i != dom_j:
|
| 204 |
+
await self._create_synapse(id_i, id_j)
|
| 205 |
+
pairs.append((id_i, id_j))
|
| 206 |
+
|
| 207 |
+
return pairs
|
| 208 |
+
|
| 209 |
+
def clear_buffer(self) -> None:
|
| 210 |
+
"""Reset the co-occurrence buffer."""
|
| 211 |
+
self._buffer.clear()
|
src/mnemocore/core/emotional_tag.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Emotional Tagging Module (Phase 5.0 — Agent 3)
|
| 3 |
+
================================================
|
| 4 |
+
Adds valence/arousal emotional metadata to MemoryNode storage.
|
| 5 |
+
|
| 6 |
+
Based on affective computing research (Russell's circumplex model):
|
| 7 |
+
- emotional_valence: float in [-1.0, 1.0]
|
| 8 |
+
-1.0 = extremely negative (fear, grief)
|
| 9 |
+
0.0 = neutral
|
| 10 |
+
+1.0 = extremely positive (joy, excitement)
|
| 11 |
+
|
| 12 |
+
- emotional_arousal: float in [0.0, 1.0]
|
| 13 |
+
0.0 = calm / low energy
|
| 14 |
+
1.0 = highly activated / intense
|
| 15 |
+
|
| 16 |
+
These signals are used by the SubconsciousAI dream cycle to prioritize
|
| 17 |
+
consolidation of high-valence, high-arousal memories (the most
|
| 18 |
+
biologically significant ones).
|
| 19 |
+
|
| 20 |
+
Public API:
|
| 21 |
+
tag = EmotionalTag(valence=0.8, arousal=0.9)
|
| 22 |
+
meta = tag.to_metadata_dict()
|
| 23 |
+
tag_back = EmotionalTag.from_metadata(node.metadata)
|
| 24 |
+
score = tag.salience() # combined importance weight
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
from dataclasses import dataclass
|
| 30 |
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
| 31 |
+
|
| 32 |
+
if TYPE_CHECKING:
|
| 33 |
+
from .node import MemoryNode
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ------------------------------------------------------------------ #
|
| 37 |
+
# EmotionalTag #
|
| 38 |
+
# ------------------------------------------------------------------ #
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class EmotionalTag:
|
| 42 |
+
"""
|
| 43 |
+
Two-dimensional emotional metadata for a memory.
|
| 44 |
+
|
| 45 |
+
valence ∈ [-1.0, 1.0] (-1 = very negative, +1 = very positive)
|
| 46 |
+
arousal ∈ [ 0.0, 1.0] ( 0 = calm, 1 = highly activated)
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
valence: float = 0.0
|
| 50 |
+
arousal: float = 0.0
|
| 51 |
+
|
| 52 |
+
def __post_init__(self) -> None:
|
| 53 |
+
self.valence = float(max(-1.0, min(1.0, self.valence)))
|
| 54 |
+
self.arousal = float(max(0.0, min(1.0, self.arousal)))
|
| 55 |
+
|
| 56 |
+
# ---- Salience ------------------------------------------------ #
|
| 57 |
+
|
| 58 |
+
def salience(self) -> float:
|
| 59 |
+
"""
|
| 60 |
+
Combined salience score for dream cycle prioritization.
|
| 61 |
+
High |valence| AND high arousal = most memorable / worth consolidating.
|
| 62 |
+
|
| 63 |
+
Returns a float in [0.0, 1.0].
|
| 64 |
+
"""
|
| 65 |
+
return abs(self.valence) * self.arousal
|
| 66 |
+
|
| 67 |
+
def is_emotionally_significant(self, threshold: float = 0.3) -> bool:
|
| 68 |
+
"""True if the salience is above the given threshold."""
|
| 69 |
+
return self.salience() >= threshold
|
| 70 |
+
|
| 71 |
+
# ---- Serialization ------------------------------------------- #
|
| 72 |
+
|
| 73 |
+
def to_metadata_dict(self) -> Dict[str, Any]:
|
| 74 |
+
return {
|
| 75 |
+
"emotional_valence": self.valence,
|
| 76 |
+
"emotional_arousal": self.arousal,
|
| 77 |
+
"emotional_salience": round(self.salience(), 4),
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
@classmethod
|
| 81 |
+
def from_metadata(cls, metadata: Dict[str, Any]) -> "EmotionalTag":
|
| 82 |
+
"""Extract an EmotionalTag from a MemoryNode's metadata dict."""
|
| 83 |
+
return cls(
|
| 84 |
+
valence=float(metadata.get("emotional_valence", 0.0)),
|
| 85 |
+
arousal=float(metadata.get("emotional_arousal", 0.0)),
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
@classmethod
|
| 89 |
+
def from_node(cls, node: "MemoryNode") -> "EmotionalTag":
|
| 90 |
+
"""Extract emotional tag directly from a MemoryNode."""
|
| 91 |
+
return cls.from_metadata(getattr(node, "metadata", {}))
|
| 92 |
+
|
| 93 |
+
# ---- Helpers -------------------------------------------------- #
|
| 94 |
+
|
| 95 |
+
@classmethod
|
| 96 |
+
def neutral(cls) -> "EmotionalTag":
|
| 97 |
+
return cls(valence=0.0, arousal=0.0)
|
| 98 |
+
|
| 99 |
+
@classmethod
|
| 100 |
+
def high_positive(cls) -> "EmotionalTag":
|
| 101 |
+
"""Factory for highly positive, highly aroused tags (e.g. breakthrough)."""
|
| 102 |
+
return cls(valence=1.0, arousal=1.0)
|
| 103 |
+
|
| 104 |
+
@classmethod
|
| 105 |
+
def high_negative(cls) -> "EmotionalTag":
|
| 106 |
+
"""Factory for highly negative, highly aroused tags (e.g. critical failure)."""
|
| 107 |
+
return cls(valence=-1.0, arousal=1.0)
|
| 108 |
+
|
| 109 |
+
def __repr__(self) -> str:
|
| 110 |
+
return f"EmotionalTag(valence={self.valence:+.2f}, arousal={self.arousal:.2f}, salience={self.salience():.2f})"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ------------------------------------------------------------------ #
|
| 114 |
+
# Node helpers #
|
| 115 |
+
# ------------------------------------------------------------------ #
|
| 116 |
+
|
| 117 |
+
def attach_emotional_tag(node: "MemoryNode", tag: EmotionalTag) -> None:
|
| 118 |
+
"""Write emotional metadata into node.metadata in place."""
|
| 119 |
+
node.metadata.update(tag.to_metadata_dict())
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def get_emotional_tag(node: "MemoryNode") -> EmotionalTag:
|
| 123 |
+
"""Read the emotional tag from a node's metadata (returns neutral if absent)."""
|
| 124 |
+
return EmotionalTag.from_node(node)
|
src/mnemocore/core/engine.py
CHANGED
|
@@ -39,6 +39,11 @@ from .gap_filler import GapFiller, GapFillerConfig
|
|
| 39 |
from .synapse_index import SynapseIndex
|
| 40 |
from .subconscious_ai import SubconsciousAIWorker
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# Phase 4.5: Recursive Synthesis Engine
|
| 43 |
from .recursive_synthesizer import RecursiveSynthesizer, SynthesizerConfig
|
| 44 |
|
|
@@ -71,6 +76,9 @@ class HAIMEngine:
|
|
| 71 |
persist_path: Optional[str] = None,
|
| 72 |
config: Optional[HAIMConfig] = None,
|
| 73 |
tier_manager: Optional[TierManager] = None,
|
|
|
|
|
|
|
|
|
|
| 74 |
):
|
| 75 |
"""
|
| 76 |
Initialize HAIMEngine with optional dependency injection.
|
|
@@ -80,6 +88,9 @@ class HAIMEngine:
|
|
| 80 |
persist_path: Path to memory persistence file.
|
| 81 |
config: Configuration object. If None, uses global get_config().
|
| 82 |
tier_manager: TierManager instance. If None, creates a new one.
|
|
|
|
|
|
|
|
|
|
| 83 |
"""
|
| 84 |
self.config = config or get_config()
|
| 85 |
self.dimension = self.config.dimensionality
|
|
@@ -89,6 +100,11 @@ class HAIMEngine:
|
|
| 89 |
|
| 90 |
# Core Components
|
| 91 |
self.tier_manager = tier_manager or TierManager(config=self.config)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
self.binary_encoder = TextEncoder(self.dimension)
|
| 93 |
|
| 94 |
# ── Phase 3.x: synapse raw dicts (kept for backward compat) ──
|
|
@@ -132,6 +148,23 @@ class HAIMEngine:
|
|
| 132 |
|
| 133 |
# ── Phase 4.5: recursive synthesizer ───────────────────────────
|
| 134 |
self._recursive_synthesizer: Optional[RecursiveSynthesizer] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
# Conceptual Layer (VSA Soul)
|
| 137 |
data_dir = self.config.paths.data_dir
|
|
@@ -337,6 +370,24 @@ class HAIMEngine:
|
|
| 337 |
"""
|
| 338 |
_is_gap_fill = metadata.get("source") == "llm_gap_fill"
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
self.subconscious_queue.append(node.id)
|
| 341 |
|
| 342 |
if not _is_gap_fill:
|
|
@@ -346,6 +397,9 @@ class HAIMEngine:
|
|
| 346 |
# Main store() method - Orchestration only
|
| 347 |
# ==========================================================================
|
| 348 |
|
|
|
|
|
|
|
|
|
|
| 349 |
@timer(STORE_DURATION_SECONDS, labels={"tier": "hot"})
|
| 350 |
@traced("store_memory")
|
| 351 |
async def store(
|
|
@@ -359,20 +413,38 @@ class HAIMEngine:
|
|
| 359 |
Store new memory with holographic encoding.
|
| 360 |
|
| 361 |
This method orchestrates the memory storage pipeline:
|
| 362 |
-
1.
|
| 363 |
-
2.
|
| 364 |
-
3.
|
| 365 |
-
4.
|
|
|
|
| 366 |
|
| 367 |
Args:
|
| 368 |
-
content: The text content to store.
|
| 369 |
metadata: Optional metadata dictionary.
|
| 370 |
goal_id: Optional goal identifier for context binding.
|
| 371 |
project_id: Optional project identifier for isolation masking (Phase 4.1).
|
| 372 |
|
| 373 |
Returns:
|
| 374 |
The unique identifier of the stored memory node.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
# 1. Encode input and bind goal context
|
| 377 |
encoded_vec, updated_metadata = await self._encode_input(content, metadata, goal_id)
|
| 378 |
|
|
@@ -387,6 +459,35 @@ class HAIMEngine:
|
|
| 387 |
# 3. Create and persist memory node
|
| 388 |
node = await self._persist_memory(content, encoded_vec, updated_metadata)
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
# 4. Trigger post-store processing
|
| 391 |
await self._trigger_post_store(node, updated_metadata)
|
| 392 |
|
|
@@ -412,24 +513,24 @@ class HAIMEngine:
|
|
| 412 |
if node_id in self.subconscious_queue:
|
| 413 |
self.subconscious_queue.remove(node_id)
|
| 414 |
|
| 415 |
-
# 3. Phase 4.0: clean up via SynapseIndex (O(k))
|
|
|
|
| 416 |
async with self.synapse_lock:
|
| 417 |
removed_count = self._synapse_index.remove_node(node_id)
|
| 418 |
|
| 419 |
-
# Rebuild legacy dicts
|
| 420 |
-
self.synapses = dict(self._synapse_index.items())
|
| 421 |
-
self.synapse_adjacency = {}
|
| 422 |
-
for syn in self._synapse_index.values():
|
| 423 |
-
self.synapse_adjacency.setdefault(syn.neuron_a_id, [])
|
| 424 |
-
self.synapse_adjacency.setdefault(syn.neuron_b_id, [])
|
| 425 |
-
self.synapse_adjacency[syn.neuron_a_id].append(syn)
|
| 426 |
-
self.synapse_adjacency[syn.neuron_b_id].append(syn)
|
| 427 |
-
|
| 428 |
if removed_count:
|
| 429 |
await self._save_synapses()
|
| 430 |
|
| 431 |
return deleted
|
| 432 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
async def close(self):
|
| 434 |
"""Perform graceful shutdown of engine components."""
|
| 435 |
logger.info("Shutting down HAIMEngine...")
|
|
@@ -461,6 +562,8 @@ class HAIMEngine:
|
|
| 461 |
chrono_weight: bool = True,
|
| 462 |
chrono_lambda: float = 0.0001,
|
| 463 |
include_neighbors: bool = False,
|
|
|
|
|
|
|
| 464 |
) -> List[Tuple[str, float]]:
|
| 465 |
"""
|
| 466 |
Query memories using Hamming distance.
|
|
@@ -480,10 +583,18 @@ class HAIMEngine:
|
|
| 480 |
Formula: Final_Score = Semantic_Similarity * (1 / (1 + lambda * Time_Delta))
|
| 481 |
- chrono_lambda: Decay rate in seconds^-1 (default: 0.0001 ~ 2.7h half-life).
|
| 482 |
- include_neighbors: Also fetch temporal neighbors (previous/next) for top results.
|
|
|
|
|
|
|
|
|
|
| 483 |
"""
|
| 484 |
# Encode Query
|
| 485 |
query_vec = await self._run_in_thread(self.binary_encoder.encode, query_text)
|
| 486 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
# Phase 4.1: Apply project isolation mask to query
|
| 488 |
if project_id:
|
| 489 |
query_vec = self.isolation_masker.apply_mask(query_vec, project_id)
|
|
@@ -494,6 +605,8 @@ class HAIMEngine:
|
|
| 494 |
query_vec,
|
| 495 |
top_k=top_k * 2,
|
| 496 |
time_range=time_range,
|
|
|
|
|
|
|
| 497 |
)
|
| 498 |
|
| 499 |
scores: Dict[str, float] = {}
|
|
@@ -515,13 +628,38 @@ class HAIMEngine:
|
|
| 515 |
if chrono_weight and score > 0:
|
| 516 |
mem = mem_map.get(nid)
|
| 517 |
if mem:
|
| 518 |
-
time_delta = now_ts - mem.created_at.timestamp() # seconds since creation
|
| 519 |
# Formula: Final = Semantic * (1 / (1 + lambda * time_delta))
|
| 520 |
decay_factor = 1.0 / (1.0 + chrono_lambda * time_delta)
|
| 521 |
score = score * decay_factor
|
| 522 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
scores[nid] = score
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
# 2. Associative Spreading (via SynapseIndex for O(1) adjacency lookup)
|
| 526 |
if associative_jump and self._synapse_index:
|
| 527 |
top_seeds = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:3]
|
|
@@ -540,6 +678,15 @@ class HAIMEngine:
|
|
| 540 |
if neighbor not in augmented_scores:
|
| 541 |
mem = await self.tier_manager.get_memory(neighbor)
|
| 542 |
if mem:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
augmented_scores[neighbor] = query_vec.similarity(mem.hdv)
|
| 544 |
|
| 545 |
if neighbor in augmented_scores:
|
|
@@ -595,6 +742,15 @@ class HAIMEngine:
|
|
| 595 |
if mem.previous_id:
|
| 596 |
prev_mem = await self.tier_manager.get_memory(mem.previous_id)
|
| 597 |
if prev_mem and prev_mem.id not in scores:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
neighbor_ids.add(prev_mem.id)
|
| 599 |
|
| 600 |
# Try to find the memory that follows this one (has this as previous_id).
|
|
@@ -614,8 +770,35 @@ class HAIMEngine:
|
|
| 614 |
# Re-sort after adding neighbors, but preserve query() top_k contract.
|
| 615 |
top_results = sorted(top_results, key=lambda x: x[1], reverse=True)[:top_k]
|
| 616 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
return top_results
|
| 618 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
async def _background_dream(self, depth: int = 2):
|
| 620 |
"""
|
| 621 |
Passive Subconscious – strengthen synapses in idle cycles.
|
|
@@ -668,12 +851,32 @@ class HAIMEngine:
|
|
| 668 |
|
| 669 |
return sorted(active_nodes, key=score, reverse=True)[:max_collapse]
|
| 670 |
|
| 671 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
"""
|
| 673 |
Bind two memories by ID.
|
| 674 |
|
| 675 |
-
|
| 676 |
-
|
|
|
|
| 677 |
"""
|
| 678 |
mem_a = await self.tier_manager.get_memory(id_a)
|
| 679 |
mem_b = await self.tier_manager.get_memory(id_b)
|
|
@@ -682,17 +885,7 @@ class HAIMEngine:
|
|
| 682 |
return
|
| 683 |
|
| 684 |
async with self.synapse_lock:
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
# Keep legacy dict in sync for any external code still using it
|
| 688 |
-
synapse_key = tuple(sorted([id_a, id_b]))
|
| 689 |
-
self.synapses[synapse_key] = syn
|
| 690 |
-
self.synapse_adjacency.setdefault(synapse_key[0], [])
|
| 691 |
-
self.synapse_adjacency.setdefault(synapse_key[1], [])
|
| 692 |
-
if syn not in self.synapse_adjacency[synapse_key[0]]:
|
| 693 |
-
self.synapse_adjacency[synapse_key[0]].append(syn)
|
| 694 |
-
if syn not in self.synapse_adjacency[synapse_key[1]]:
|
| 695 |
-
self.synapse_adjacency[synapse_key[1]].append(syn)
|
| 696 |
|
| 697 |
await self._save_synapses()
|
| 698 |
|
|
@@ -712,26 +905,17 @@ class HAIMEngine:
|
|
| 712 |
Also syncs any legacy dict entries into the index before compacting.
|
| 713 |
"""
|
| 714 |
async with self.synapse_lock:
|
| 715 |
-
#
|
| 716 |
-
#
|
| 717 |
-
for
|
| 718 |
if self._synapse_index.get(syn.neuron_a_id, syn.neuron_b_id) is None:
|
| 719 |
self._synapse_index.register(syn)
|
| 720 |
|
| 721 |
removed = self._synapse_index.compact(threshold)
|
| 722 |
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
self.synapse_adjacency = {}
|
| 727 |
-
for syn in self._synapse_index.values():
|
| 728 |
-
self.synapse_adjacency.setdefault(syn.neuron_a_id, [])
|
| 729 |
-
self.synapse_adjacency.setdefault(syn.neuron_b_id, [])
|
| 730 |
-
self.synapse_adjacency[syn.neuron_a_id].append(syn)
|
| 731 |
-
self.synapse_adjacency[syn.neuron_b_id].append(syn)
|
| 732 |
-
|
| 733 |
-
logger.info(f"cleanup_decay: pruned {removed} synapses below {threshold}")
|
| 734 |
-
await self._save_synapses()
|
| 735 |
|
| 736 |
async def get_stats(self) -> Dict[str, Any]:
|
| 737 |
"""Aggregate statistics from engine components."""
|
|
@@ -936,18 +1120,9 @@ class HAIMEngine:
|
|
| 936 |
def _load():
|
| 937 |
self._synapse_index.load_from_file(self.synapse_path)
|
| 938 |
|
|
|
|
| 939 |
await self._run_in_thread(_load)
|
| 940 |
|
| 941 |
-
# Rebuild legacy dicts from SynapseIndex for backward compat
|
| 942 |
-
async with self.synapse_lock:
|
| 943 |
-
self.synapses = dict(self._synapse_index.items())
|
| 944 |
-
self.synapse_adjacency = {}
|
| 945 |
-
for syn in self._synapse_index.values():
|
| 946 |
-
self.synapse_adjacency.setdefault(syn.neuron_a_id, [])
|
| 947 |
-
self.synapse_adjacency.setdefault(syn.neuron_b_id, [])
|
| 948 |
-
self.synapse_adjacency[syn.neuron_a_id].append(syn)
|
| 949 |
-
self.synapse_adjacency[syn.neuron_b_id].append(syn)
|
| 950 |
-
|
| 951 |
async def _save_synapses(self):
|
| 952 |
"""
|
| 953 |
Save synapses to disk in JSONL format.
|
|
|
|
| 39 |
from .synapse_index import SynapseIndex
|
| 40 |
from .subconscious_ai import SubconsciousAIWorker
|
| 41 |
|
| 42 |
+
# Phase 5 AGI Stores
|
| 43 |
+
from .working_memory import WorkingMemoryService
|
| 44 |
+
from .episodic_store import EpisodicStoreService
|
| 45 |
+
from .semantic_store import SemanticStoreService
|
| 46 |
+
|
| 47 |
# Phase 4.5: Recursive Synthesis Engine
|
| 48 |
from .recursive_synthesizer import RecursiveSynthesizer, SynthesizerConfig
|
| 49 |
|
|
|
|
| 76 |
persist_path: Optional[str] = None,
|
| 77 |
config: Optional[HAIMConfig] = None,
|
| 78 |
tier_manager: Optional[TierManager] = None,
|
| 79 |
+
working_memory: Optional[WorkingMemoryService] = None,
|
| 80 |
+
episodic_store: Optional[EpisodicStoreService] = None,
|
| 81 |
+
semantic_store: Optional[SemanticStoreService] = None,
|
| 82 |
):
|
| 83 |
"""
|
| 84 |
Initialize HAIMEngine with optional dependency injection.
|
|
|
|
| 88 |
persist_path: Path to memory persistence file.
|
| 89 |
config: Configuration object. If None, uses global get_config().
|
| 90 |
tier_manager: TierManager instance. If None, creates a new one.
|
| 91 |
+
working_memory: Optional Phase 5 WM service.
|
| 92 |
+
episodic_store: Optional Phase 5 EM service.
|
| 93 |
+
semantic_store: Optional Phase 5 Semantic service.
|
| 94 |
"""
|
| 95 |
self.config = config or get_config()
|
| 96 |
self.dimension = self.config.dimensionality
|
|
|
|
| 100 |
|
| 101 |
# Core Components
|
| 102 |
self.tier_manager = tier_manager or TierManager(config=self.config)
|
| 103 |
+
|
| 104 |
+
# Phase 5 Components
|
| 105 |
+
self.working_memory = working_memory
|
| 106 |
+
self.episodic_store = episodic_store
|
| 107 |
+
self.semantic_store = semantic_store
|
| 108 |
self.binary_encoder = TextEncoder(self.dimension)
|
| 109 |
|
| 110 |
# ── Phase 3.x: synapse raw dicts (kept for backward compat) ──
|
|
|
|
| 148 |
|
| 149 |
# ── Phase 4.5: recursive synthesizer ───────────────────────────
|
| 150 |
self._recursive_synthesizer: Optional[RecursiveSynthesizer] = None
|
| 151 |
+
|
| 152 |
+
# ── Phase 12.2: Contextual Topic Tracker ───────────────────────
|
| 153 |
+
from .topic_tracker import TopicTracker
|
| 154 |
+
self.topic_tracker = TopicTracker(self.config.context, self.dimension)
|
| 155 |
+
|
| 156 |
+
# ── Phase 12.3: Preference Learning ────────────────────────────
|
| 157 |
+
from .preference_store import PreferenceStore
|
| 158 |
+
self.preference_store = PreferenceStore(self.config.preference, self.dimension)
|
| 159 |
+
|
| 160 |
+
# ── Phase 13.2: Anticipatory Memory ────────────────────────────
|
| 161 |
+
from .anticipatory import AnticipatoryEngine
|
| 162 |
+
self.anticipatory_engine = AnticipatoryEngine(
|
| 163 |
+
self.config.anticipatory,
|
| 164 |
+
self._synapse_index,
|
| 165 |
+
self.tier_manager,
|
| 166 |
+
self.topic_tracker
|
| 167 |
+
)
|
| 168 |
|
| 169 |
# Conceptual Layer (VSA Soul)
|
| 170 |
data_dir = self.config.paths.data_dir
|
|
|
|
| 370 |
"""
|
| 371 |
_is_gap_fill = metadata.get("source") == "llm_gap_fill"
|
| 372 |
|
| 373 |
+
# Phase 12.1: Aggressive Synapse Formation (Auto-bind).
|
| 374 |
+
# Fix 4: collect all bindings first, persist synapses only once at the end.
|
| 375 |
+
if hasattr(self.config, 'synapse') and self.config.synapse.auto_bind_on_store:
|
| 376 |
+
similar_nodes = await self.query(
|
| 377 |
+
node.content,
|
| 378 |
+
top_k=3,
|
| 379 |
+
associative_jump=False,
|
| 380 |
+
track_gaps=False,
|
| 381 |
+
)
|
| 382 |
+
bind_pairs = [
|
| 383 |
+
(node.id, neighbor_id)
|
| 384 |
+
for neighbor_id, similarity in similar_nodes
|
| 385 |
+
if neighbor_id != node.id
|
| 386 |
+
and similarity >= self.config.synapse.similarity_threshold
|
| 387 |
+
]
|
| 388 |
+
if bind_pairs:
|
| 389 |
+
await self._auto_bind_batch(bind_pairs)
|
| 390 |
+
|
| 391 |
self.subconscious_queue.append(node.id)
|
| 392 |
|
| 393 |
if not _is_gap_fill:
|
|
|
|
| 397 |
# Main store() method - Orchestration only
|
| 398 |
# ==========================================================================
|
| 399 |
|
| 400 |
+
# Maximum allowed content length (Fix 5: input validation)
|
| 401 |
+
_MAX_CONTENT_LENGTH: int = 100_000
|
| 402 |
+
|
| 403 |
@timer(STORE_DURATION_SECONDS, labels={"tier": "hot"})
|
| 404 |
@traced("store_memory")
|
| 405 |
async def store(
|
|
|
|
| 413 |
Store new memory with holographic encoding.
|
| 414 |
|
| 415 |
This method orchestrates the memory storage pipeline:
|
| 416 |
+
1. Validate input
|
| 417 |
+
2. Encode input content
|
| 418 |
+
3. Evaluate tier placement via EIG
|
| 419 |
+
4. Persist to storage
|
| 420 |
+
5. Trigger post-store processing
|
| 421 |
|
| 422 |
Args:
|
| 423 |
+
content: The text content to store. Must be non-empty and ≤100 000 chars.
|
| 424 |
metadata: Optional metadata dictionary.
|
| 425 |
goal_id: Optional goal identifier for context binding.
|
| 426 |
project_id: Optional project identifier for isolation masking (Phase 4.1).
|
| 427 |
|
| 428 |
Returns:
|
| 429 |
The unique identifier of the stored memory node.
|
| 430 |
+
|
| 431 |
+
Raises:
|
| 432 |
+
ValueError: If content is empty or exceeds the maximum allowed length.
|
| 433 |
+
RuntimeError: If the engine has not been initialized via initialize().
|
| 434 |
"""
|
| 435 |
+
# Fix 5: Input validation
|
| 436 |
+
if not content or not content.strip():
|
| 437 |
+
raise ValueError("Memory content cannot be empty or whitespace-only.")
|
| 438 |
+
if len(content) > self._MAX_CONTENT_LENGTH:
|
| 439 |
+
raise ValueError(
|
| 440 |
+
f"Memory content is too long ({len(content):,} chars). "
|
| 441 |
+
f"Maximum: {self._MAX_CONTENT_LENGTH:,}."
|
| 442 |
+
)
|
| 443 |
+
if not self._initialized:
|
| 444 |
+
raise RuntimeError(
|
| 445 |
+
"HAIMEngine.initialize() must be awaited before calling store()."
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
# 1. Encode input and bind goal context
|
| 449 |
encoded_vec, updated_metadata = await self._encode_input(content, metadata, goal_id)
|
| 450 |
|
|
|
|
| 459 |
# 3. Create and persist memory node
|
| 460 |
node = await self._persist_memory(content, encoded_vec, updated_metadata)
|
| 461 |
|
| 462 |
+
# Phase 5.1: If agent_id in metadata, push to Working Memory and log Episode event
|
| 463 |
+
agent_id = updated_metadata.get("agent_id")
|
| 464 |
+
if agent_id:
|
| 465 |
+
if self.working_memory:
|
| 466 |
+
from .memory_model import WorkingMemoryItem
|
| 467 |
+
self.working_memory.push_item(
|
| 468 |
+
agent_id,
|
| 469 |
+
WorkingMemoryItem(
|
| 470 |
+
id=f"wm_{node.id[:8]}",
|
| 471 |
+
agent_id=agent_id,
|
| 472 |
+
created_at=datetime.utcnow(),
|
| 473 |
+
ttl_seconds=3600,
|
| 474 |
+
content=content,
|
| 475 |
+
kind="observation",
|
| 476 |
+
importance=node.epistemic_value or 0.5,
|
| 477 |
+
tags=updated_metadata.get("tags", []),
|
| 478 |
+
hdv=encoded_vec
|
| 479 |
+
)
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
episode_id = updated_metadata.get("episode_id")
|
| 483 |
+
if episode_id and self.episodic_store:
|
| 484 |
+
self.episodic_store.append_event(
|
| 485 |
+
episode_id=episode_id,
|
| 486 |
+
kind="observation",
|
| 487 |
+
content=content,
|
| 488 |
+
metadata=updated_metadata
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
# 4. Trigger post-store processing
|
| 492 |
await self._trigger_post_store(node, updated_metadata)
|
| 493 |
|
|
|
|
| 513 |
if node_id in self.subconscious_queue:
|
| 514 |
self.subconscious_queue.remove(node_id)
|
| 515 |
|
| 516 |
+
# 3. Phase 4.0: clean up via SynapseIndex (O(k)).
|
| 517 |
+
# Fix 2: legacy dict rebuild removed — _synapse_index is authoritative.
|
| 518 |
async with self.synapse_lock:
|
| 519 |
removed_count = self._synapse_index.remove_node(node_id)
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
if removed_count:
|
| 522 |
await self._save_synapses()
|
| 523 |
|
| 524 |
return deleted
|
| 525 |
|
| 526 |
+
async def log_decision(self, context_text: str, outcome: float) -> None:
|
| 527 |
+
"""
|
| 528 |
+
Phase 12.3: Logs a user decision or feedback context to update preference vector.
|
| 529 |
+
Outcome should be positive (e.g. 1.0) or negative (e.g. -1.0).
|
| 530 |
+
"""
|
| 531 |
+
vec = await self._run_in_thread(self.binary_encoder.encode, context_text)
|
| 532 |
+
self.preference_store.log_decision(vec, outcome)
|
| 533 |
+
|
| 534 |
async def close(self):
|
| 535 |
"""Perform graceful shutdown of engine components."""
|
| 536 |
logger.info("Shutting down HAIMEngine...")
|
|
|
|
| 562 |
chrono_weight: bool = True,
|
| 563 |
chrono_lambda: float = 0.0001,
|
| 564 |
include_neighbors: bool = False,
|
| 565 |
+
metadata_filter: Optional[Dict[str, Any]] = None,
|
| 566 |
+
include_cold: bool = False,
|
| 567 |
) -> List[Tuple[str, float]]:
|
| 568 |
"""
|
| 569 |
Query memories using Hamming distance.
|
|
|
|
| 583 |
Formula: Final_Score = Semantic_Similarity * (1 / (1 + lambda * Time_Delta))
|
| 584 |
- chrono_lambda: Decay rate in seconds^-1 (default: 0.0001 ~ 2.7h half-life).
|
| 585 |
- include_neighbors: Also fetch temporal neighbors (previous/next) for top results.
|
| 586 |
+
- include_cold: Include COLD tier in the search (bounded linear scan, default False).
|
| 587 |
+
|
| 588 |
+
Fix 3: Triggers anticipatory preloading (Phase 13.2) as fire-and-forget after returning.
|
| 589 |
"""
|
| 590 |
# Encode Query
|
| 591 |
query_vec = await self._run_in_thread(self.binary_encoder.encode, query_text)
|
| 592 |
|
| 593 |
+
# Phase 12.2: Context Tracking
|
| 594 |
+
is_shift, sim = self.topic_tracker.add_query(query_vec)
|
| 595 |
+
if is_shift:
|
| 596 |
+
logger.info(f"Context shifted during query. (sim {sim:.3f})")
|
| 597 |
+
|
| 598 |
# Phase 4.1: Apply project isolation mask to query
|
| 599 |
if project_id:
|
| 600 |
query_vec = self.isolation_masker.apply_mask(query_vec, project_id)
|
|
|
|
| 605 |
query_vec,
|
| 606 |
top_k=top_k * 2,
|
| 607 |
time_range=time_range,
|
| 608 |
+
metadata_filter=metadata_filter,
|
| 609 |
+
include_cold=include_cold,
|
| 610 |
)
|
| 611 |
|
| 612 |
scores: Dict[str, float] = {}
|
|
|
|
| 628 |
if chrono_weight and score > 0:
|
| 629 |
mem = mem_map.get(nid)
|
| 630 |
if mem:
|
| 631 |
+
time_delta = max(0.0, now_ts - mem.created_at.timestamp()) # seconds since creation
|
| 632 |
# Formula: Final = Semantic * (1 / (1 + lambda * time_delta))
|
| 633 |
decay_factor = 1.0 / (1.0 + chrono_lambda * time_delta)
|
| 634 |
score = score * decay_factor
|
| 635 |
|
| 636 |
+
# Phase 12.3: Preference Learning Bias
|
| 637 |
+
if self.preference_store.config.enabled and self.preference_store.preference_vector is not None:
|
| 638 |
+
mem = mem_map.get(nid)
|
| 639 |
+
if not mem:
|
| 640 |
+
mem = await self.tier_manager.get_memory(nid)
|
| 641 |
+
if mem and mem.id not in mem_map:
|
| 642 |
+
mem_map[mem.id] = mem
|
| 643 |
+
if mem:
|
| 644 |
+
score = self.preference_store.bias_score(mem.hdv, score)
|
| 645 |
+
|
| 646 |
scores[nid] = score
|
| 647 |
|
| 648 |
+
# Phase 5.1: Boost context matching Working Memory
|
| 649 |
+
agent_id = metadata_filter.get("agent_id") if metadata_filter else None
|
| 650 |
+
if agent_id and self.working_memory:
|
| 651 |
+
wm_state = self.working_memory.get_state(agent_id)
|
| 652 |
+
if wm_state:
|
| 653 |
+
wm_texts = [item.content for item in wm_state.items]
|
| 654 |
+
if wm_texts:
|
| 655 |
+
# Very lightweight lexical boost for items currently in working memory
|
| 656 |
+
q_lower = query_text.lower()
|
| 657 |
+
for nid in scores:
|
| 658 |
+
mem = mem_map.get(nid) # Assuming already cached from chrono weighting
|
| 659 |
+
if mem and mem.content:
|
| 660 |
+
if any(w_text.lower() in mem.content.lower() for w_text in wm_texts):
|
| 661 |
+
scores[nid] *= 1.15 # 15% boost for WM overlap
|
| 662 |
+
|
| 663 |
# 2. Associative Spreading (via SynapseIndex for O(1) adjacency lookup)
|
| 664 |
if associative_jump and self._synapse_index:
|
| 665 |
top_seeds = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:3]
|
|
|
|
| 678 |
if neighbor not in augmented_scores:
|
| 679 |
mem = await self.tier_manager.get_memory(neighbor)
|
| 680 |
if mem:
|
| 681 |
+
if metadata_filter:
|
| 682 |
+
match = True
|
| 683 |
+
node_meta = mem.metadata or {}
|
| 684 |
+
for k, v in metadata_filter.items():
|
| 685 |
+
if node_meta.get(k) != v:
|
| 686 |
+
match = False
|
| 687 |
+
break
|
| 688 |
+
if not match:
|
| 689 |
+
continue
|
| 690 |
augmented_scores[neighbor] = query_vec.similarity(mem.hdv)
|
| 691 |
|
| 692 |
if neighbor in augmented_scores:
|
|
|
|
| 742 |
if mem.previous_id:
|
| 743 |
prev_mem = await self.tier_manager.get_memory(mem.previous_id)
|
| 744 |
if prev_mem and prev_mem.id not in scores:
|
| 745 |
+
if metadata_filter:
|
| 746 |
+
match = True
|
| 747 |
+
p_meta = prev_mem.metadata or {}
|
| 748 |
+
for k, v in metadata_filter.items():
|
| 749 |
+
if p_meta.get(k) != v:
|
| 750 |
+
match = False
|
| 751 |
+
break
|
| 752 |
+
if not match:
|
| 753 |
+
continue
|
| 754 |
neighbor_ids.add(prev_mem.id)
|
| 755 |
|
| 756 |
# Try to find the memory that follows this one (has this as previous_id).
|
|
|
|
| 770 |
# Re-sort after adding neighbors, but preserve query() top_k contract.
|
| 771 |
top_results = sorted(top_results, key=lambda x: x[1], reverse=True)[:top_k]
|
| 772 |
|
| 773 |
+
# Phase 13.2 (Fix 3): Anticipatory preloading — fire-and-forget so it
|
| 774 |
+
# never blocks the caller. Only activated when the engine is fully warm.
|
| 775 |
+
if top_results and self._initialized and self.config.anticipatory.enabled:
|
| 776 |
+
asyncio.ensure_future(
|
| 777 |
+
self.anticipatory_engine.predict_and_preload(top_results[0][0])
|
| 778 |
+
)
|
| 779 |
+
|
| 780 |
return top_results
|
| 781 |
|
| 782 |
+
async def get_context_nodes(self, top_k: int = 3) -> List[Tuple[str, float]]:
|
| 783 |
+
"""
|
| 784 |
+
Phase 12.2: Contextual Awareness
|
| 785 |
+
Retrieves the top_k most relevant nodes relating to the current topic context vector.
|
| 786 |
+
Should be explicitly used by prompt builders before LLM logic injection.
|
| 787 |
+
"""
|
| 788 |
+
if not self.topic_tracker.config.enabled:
|
| 789 |
+
return []
|
| 790 |
+
|
| 791 |
+
ctx = self.topic_tracker.get_context()
|
| 792 |
+
if ctx is None:
|
| 793 |
+
return []
|
| 794 |
+
|
| 795 |
+
results = await self.tier_manager.search(
|
| 796 |
+
ctx,
|
| 797 |
+
top_k=top_k,
|
| 798 |
+
time_range=None,
|
| 799 |
+
metadata_filter=None,
|
| 800 |
+
)
|
| 801 |
+
return results
|
| 802 |
async def _background_dream(self, depth: int = 2):
|
| 803 |
"""
|
| 804 |
Passive Subconscious – strengthen synapses in idle cycles.
|
|
|
|
| 851 |
|
| 852 |
return sorted(active_nodes, key=score, reverse=True)[:max_collapse]
|
| 853 |
|
| 854 |
+
async def _auto_bind_batch(
|
| 855 |
+
self,
|
| 856 |
+
pairs: List[Tuple[str, str]],
|
| 857 |
+
success: bool = True,
|
| 858 |
+
weight: float = 1.0,
|
| 859 |
+
) -> None:
|
| 860 |
+
"""
|
| 861 |
+
Fix 4: Bind multiple (id_a, id_b) pairs in one pass, saving synapses once.
|
| 862 |
+
|
| 863 |
+
Used by auto-bind in _trigger_post_store() to avoid N disk writes per store.
|
| 864 |
+
"""
|
| 865 |
+
async with self.synapse_lock:
|
| 866 |
+
for id_a, id_b in pairs:
|
| 867 |
+
mem_a = await self.tier_manager.get_memory(id_a)
|
| 868 |
+
mem_b = await self.tier_manager.get_memory(id_b)
|
| 869 |
+
if mem_a and mem_b:
|
| 870 |
+
self._synapse_index.add_or_fire(id_a, id_b, success=success, weight=weight)
|
| 871 |
+
await self._save_synapses()
|
| 872 |
+
|
| 873 |
+
async def bind_memories(self, id_a: str, id_b: str, success: bool = True, weight: float = 1.0):
|
| 874 |
"""
|
| 875 |
Bind two memories by ID.
|
| 876 |
|
| 877 |
+
Fix 2: delegates exclusively to SynapseIndex — legacy dict sync removed.
|
| 878 |
+
The legacy self.synapses / self.synapse_adjacency attributes remain for
|
| 879 |
+
backward compatibility but are only populated at startup from disk.
|
| 880 |
"""
|
| 881 |
mem_a = await self.tier_manager.get_memory(id_a)
|
| 882 |
mem_b = await self.tier_manager.get_memory(id_b)
|
|
|
|
| 885 |
return
|
| 886 |
|
| 887 |
async with self.synapse_lock:
|
| 888 |
+
self._synapse_index.add_or_fire(id_a, id_b, success=success, weight=weight)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 889 |
|
| 890 |
await self._save_synapses()
|
| 891 |
|
|
|
|
| 905 |
Also syncs any legacy dict entries into the index before compacting.
|
| 906 |
"""
|
| 907 |
async with self.synapse_lock:
|
| 908 |
+
# Retain legacy→index sync so tests that write to self.synapses directly
|
| 909 |
+
# still get their entries registered (Fix 2: sync only in this direction).
|
| 910 |
+
for syn in list(self.synapses.values()):
|
| 911 |
if self._synapse_index.get(syn.neuron_a_id, syn.neuron_b_id) is None:
|
| 912 |
self._synapse_index.register(syn)
|
| 913 |
|
| 914 |
removed = self._synapse_index.compact(threshold)
|
| 915 |
|
| 916 |
+
if removed:
|
| 917 |
+
logger.info(f"cleanup_decay: pruned {removed} synapses below {threshold}")
|
| 918 |
+
await self._save_synapses()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
|
| 920 |
async def get_stats(self) -> Dict[str, Any]:
|
| 921 |
"""Aggregate statistics from engine components."""
|
|
|
|
| 1120 |
def _load():
|
| 1121 |
self._synapse_index.load_from_file(self.synapse_path)
|
| 1122 |
|
| 1123 |
+
# Fix 2: _synapse_index is authoritative — legacy dicts no longer rebuilt.
|
| 1124 |
await self._run_in_thread(_load)
|
| 1125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1126 |
async def _save_synapses(self):
|
| 1127 |
"""
|
| 1128 |
Save synapses to disk in JSONL format.
|
src/mnemocore/core/episodic_store.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Episodic Store Service
|
| 3 |
+
======================
|
| 4 |
+
Manages sequences of events (Episodes), chaining them chronologically.
|
| 5 |
+
Provides the foundation for episodic recall and narrative tracking over time.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Dict, List, Optional, Any
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import threading
|
| 11 |
+
import uuid
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
from .memory_model import Episode, EpisodeEvent
|
| 15 |
+
from .tier_manager import TierManager
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class EpisodicStoreService:
|
| 21 |
+
def __init__(self, tier_manager: Optional[TierManager] = None):
|
| 22 |
+
self._tier_manager = tier_manager
|
| 23 |
+
# In-memory index of active episodes; eventually backed by SQLite/Qdrant
|
| 24 |
+
self._active_episodes: Dict[str, Episode] = {}
|
| 25 |
+
# Simple backward index map from agent to sorted list of historical episodes
|
| 26 |
+
self._agent_history: Dict[str, List[Episode]] = {}
|
| 27 |
+
self._lock = threading.RLock()
|
| 28 |
+
|
| 29 |
+
def start_episode(
|
| 30 |
+
self, agent_id: str, goal: Optional[str] = None, context: Optional[str] = None
|
| 31 |
+
) -> str:
|
| 32 |
+
with self._lock:
|
| 33 |
+
ep_id = f"ep_{uuid.uuid4().hex[:12]}"
|
| 34 |
+
|
| 35 |
+
# Find previous absolute episode for this agent to populate links_prev
|
| 36 |
+
prev_links = []
|
| 37 |
+
if agent_id in self._agent_history and self._agent_history[agent_id]:
|
| 38 |
+
last_ep = self._agent_history[agent_id][-1]
|
| 39 |
+
prev_links.append(last_ep.id)
|
| 40 |
+
|
| 41 |
+
new_ep = Episode(
|
| 42 |
+
id=ep_id,
|
| 43 |
+
agent_id=agent_id,
|
| 44 |
+
started_at=datetime.utcnow(),
|
| 45 |
+
ended_at=None,
|
| 46 |
+
goal=goal,
|
| 47 |
+
context=context,
|
| 48 |
+
events=[],
|
| 49 |
+
outcome="in_progress",
|
| 50 |
+
reward=None,
|
| 51 |
+
links_prev=prev_links,
|
| 52 |
+
links_next=[],
|
| 53 |
+
ltp_strength=0.0,
|
| 54 |
+
reliability=1.0,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Link the previous episode forward
|
| 58 |
+
if prev_links:
|
| 59 |
+
last_ep_id = prev_links[0]
|
| 60 |
+
last_ep = self._get_historical_ep(agent_id, last_ep_id)
|
| 61 |
+
if last_ep and new_ep.id not in last_ep.links_next:
|
| 62 |
+
last_ep.links_next.append(new_ep.id)
|
| 63 |
+
|
| 64 |
+
self._active_episodes[ep_id] = new_ep
|
| 65 |
+
return ep_id
|
| 66 |
+
|
| 67 |
+
def append_event(
|
| 68 |
+
self,
|
| 69 |
+
episode_id: str,
|
| 70 |
+
kind: str,
|
| 71 |
+
content: str,
|
| 72 |
+
metadata: Optional[dict[str, Any]] = None,
|
| 73 |
+
) -> None:
|
| 74 |
+
with self._lock:
|
| 75 |
+
ep = self._active_episodes.get(episode_id)
|
| 76 |
+
if not ep:
|
| 77 |
+
logger.warning(f"Attempted to append event to inactive or not found episode: {episode_id}")
|
| 78 |
+
return
|
| 79 |
+
|
| 80 |
+
event = EpisodeEvent(
|
| 81 |
+
timestamp=datetime.utcnow(),
|
| 82 |
+
kind=kind, # type: ignore
|
| 83 |
+
content=content,
|
| 84 |
+
metadata=metadata or {},
|
| 85 |
+
)
|
| 86 |
+
ep.events.append(event)
|
| 87 |
+
|
| 88 |
+
def end_episode(
|
| 89 |
+
self, episode_id: str, outcome: str, reward: Optional[float] = None
|
| 90 |
+
) -> None:
|
| 91 |
+
with self._lock:
|
| 92 |
+
ep = self._active_episodes.pop(episode_id, None)
|
| 93 |
+
if not ep:
|
| 94 |
+
logger.warning(f"Attempted to end inactive or not found episode: {episode_id}")
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
ep.ended_at = datetime.utcnow()
|
| 98 |
+
ep.outcome = outcome # type: ignore
|
| 99 |
+
ep.reward = reward
|
| 100 |
+
|
| 101 |
+
agent_history = self._agent_history.setdefault(ep.agent_id, [])
|
| 102 |
+
agent_history.append(ep)
|
| 103 |
+
|
| 104 |
+
# Sort by start time just to ensure chronological order is preserved
|
| 105 |
+
agent_history.sort(key=lambda x: x.started_at)
|
| 106 |
+
|
| 107 |
+
logger.debug(f"Ended episode {episode_id} with outcome {outcome}")
|
| 108 |
+
|
| 109 |
+
def get_episode(self, episode_id: str) -> Optional[Episode]:
|
| 110 |
+
with self._lock:
|
| 111 |
+
# Check active first
|
| 112 |
+
if episode_id in self._active_episodes:
|
| 113 |
+
return self._active_episodes[episode_id]
|
| 114 |
+
# Then check history
|
| 115 |
+
for history in self._agent_history.values():
|
| 116 |
+
for ep in history:
|
| 117 |
+
if ep.id == episode_id:
|
| 118 |
+
return ep
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
def get_recent(
|
| 122 |
+
self, agent_id: str, limit: int = 5, context: Optional[str] = None
|
| 123 |
+
) -> List[Episode]:
|
| 124 |
+
with self._lock:
|
| 125 |
+
history = self._agent_history.get(agent_id, [])
|
| 126 |
+
|
| 127 |
+
# Active episodes count too
|
| 128 |
+
active = [ep for ep in self._active_episodes.values() if ep.agent_id == agent_id]
|
| 129 |
+
|
| 130 |
+
combined = history + active
|
| 131 |
+
combined.sort(key=lambda x: x.started_at, reverse=True)
|
| 132 |
+
|
| 133 |
+
if context:
|
| 134 |
+
combined = [ep for ep in combined if ep.context == context]
|
| 135 |
+
|
| 136 |
+
return combined[:limit]
|
| 137 |
+
|
| 138 |
+
def _get_historical_ep(self, agent_id: str, episode_id: str) -> Optional[Episode]:
|
| 139 |
+
history = self._agent_history.get(agent_id, [])
|
| 140 |
+
for ep in history:
|
| 141 |
+
if ep.id == episode_id:
|
| 142 |
+
return ep
|
| 143 |
+
return None
|
| 144 |
+
|
src/mnemocore/core/forgetting_curve.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Forgetting Curve Manager (Phase 5.0)
|
| 3 |
+
=====================================
|
| 4 |
+
Implements Ebbinghaus-based spaced repetition scheduling for MnemoCore.
|
| 5 |
+
|
| 6 |
+
The ForgettingCurveManager layers on top of AdaptiveDecayEngine to:
|
| 7 |
+
1. Schedule "review" events at optimal intervals (spaced repetition)
|
| 8 |
+
2. Decide whether low-retention memories should be consolidated vs. deleted
|
| 9 |
+
3. Work collaboratively with the ConsolidationWorker
|
| 10 |
+
|
| 11 |
+
Key idea: at each review interval, the system re-evaluates a memory's EIG.
|
| 12 |
+
- High EIG + low retention → CONSOLIDATE (absorb into a stronger anchor)
|
| 13 |
+
- Low EIG + low retention → ARCHIVE / EVICT
|
| 14 |
+
|
| 15 |
+
The review scheduling uses the SuperMemo-inspired interval:
|
| 16 |
+
next_review_days = S_i * ln(1 / TARGET_RETENTION)^-1
|
| 17 |
+
|
| 18 |
+
where TARGET_RETENTION = 0.70 (retain 70% at next review point).
|
| 19 |
+
|
| 20 |
+
Public API:
|
| 21 |
+
manager = ForgettingCurveManager(engine)
|
| 22 |
+
await manager.run_once(nodes) # scan HOT/WARM nodes, schedule reviews
|
| 23 |
+
schedule = manager.get_schedule() # sorted list of upcoming reviews
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import asyncio
|
| 29 |
+
import math
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from datetime import datetime, timezone, timedelta
|
| 32 |
+
from typing import TYPE_CHECKING, Dict, List, Optional
|
| 33 |
+
|
| 34 |
+
from loguru import logger
|
| 35 |
+
|
| 36 |
+
from .temporal_decay import AdaptiveDecayEngine, get_adaptive_decay_engine
|
| 37 |
+
|
| 38 |
+
if TYPE_CHECKING:
|
| 39 |
+
from .node import MemoryNode
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ------------------------------------------------------------------ #
|
| 43 |
+
# Constants #
|
| 44 |
+
# ------------------------------------------------------------------ #
|
| 45 |
+
|
| 46 |
+
TARGET_RETENTION: float = 0.70 # Retention level at which we schedule the next review
|
| 47 |
+
MIN_EIG_TO_CONSOLIDATE: float = 0.3 # Minimum epistemic value to consolidate instead of evict
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ------------------------------------------------------------------ #
|
| 51 |
+
# Review Schedule Entry #
|
| 52 |
+
# ------------------------------------------------------------------ #
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class ReviewEntry:
|
| 56 |
+
"""A scheduled review for a single memory."""
|
| 57 |
+
memory_id: str
|
| 58 |
+
due_at: datetime # When to review
|
| 59 |
+
current_retention: float # Retention at scheduling time
|
| 60 |
+
stability: float # S_i at scheduling time
|
| 61 |
+
action: str = "review" # "review" | "consolidate" | "evict"
|
| 62 |
+
|
| 63 |
+
def to_dict(self) -> Dict:
|
| 64 |
+
return {
|
| 65 |
+
"memory_id": self.memory_id,
|
| 66 |
+
"due_at": self.due_at.isoformat(),
|
| 67 |
+
"current_retention": round(self.current_retention, 4),
|
| 68 |
+
"stability": round(self.stability, 4),
|
| 69 |
+
"action": self.action,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ------------------------------------------------------------------ #
|
| 74 |
+
# Forgetting Curve Manager #
|
| 75 |
+
# ------------------------------------------------------------------ #
|
| 76 |
+
|
| 77 |
+
class ForgettingCurveManager:
|
| 78 |
+
"""
|
| 79 |
+
Schedules spaced-repetition review events for MemoryNodes.
|
| 80 |
+
|
| 81 |
+
Attach to a running HAIMEngine to enable automatic review scheduling.
|
| 82 |
+
Works in concert with AdaptiveDecayEngine and ConsolidationWorker.
|
| 83 |
+
"""
|
| 84 |
+
|
| 85 |
+
def __init__(
|
| 86 |
+
self,
|
| 87 |
+
engine=None, # HAIMEngine – typed as Any to avoid circular import
|
| 88 |
+
decay_engine: Optional[AdaptiveDecayEngine] = None,
|
| 89 |
+
target_retention: float = TARGET_RETENTION,
|
| 90 |
+
min_eig_to_consolidate: float = MIN_EIG_TO_CONSOLIDATE,
|
| 91 |
+
) -> None:
|
| 92 |
+
self.engine = engine
|
| 93 |
+
self.decay = decay_engine or get_adaptive_decay_engine()
|
| 94 |
+
self.target_retention = target_retention
|
| 95 |
+
self.min_eig_to_consolidate = min_eig_to_consolidate
|
| 96 |
+
self._schedule: List[ReviewEntry] = []
|
| 97 |
+
|
| 98 |
+
# ---- Interval calculation ------------------------------------ #
|
| 99 |
+
|
| 100 |
+
def next_review_days(self, node: "MemoryNode") -> float:
|
| 101 |
+
"""
|
| 102 |
+
Days until the next review should be scheduled.
|
| 103 |
+
|
| 104 |
+
Derived from: TARGET_RETENTION = e^(-next_days / S_i)
|
| 105 |
+
→ next_days = -S_i * ln(TARGET_RETENTION)
|
| 106 |
+
|
| 107 |
+
Example: S_i=5, target=0.70 → next_days = 5 × 0.357 ≈ 1.78 days
|
| 108 |
+
"""
|
| 109 |
+
s_i = self.decay.stability(node)
|
| 110 |
+
# Protect against math domain errors
|
| 111 |
+
target = max(1e-6, min(self.target_retention, 0.999))
|
| 112 |
+
return -s_i * math.log(target)
|
| 113 |
+
|
| 114 |
+
def _determine_action(self, node: "MemoryNode", retention: float) -> str:
|
| 115 |
+
"""
|
| 116 |
+
Decide what to do with a low-retention memory:
|
| 117 |
+
- consolidate: has historical importance (epistemic_value > threshold)
|
| 118 |
+
- evict: low value, low retention
|
| 119 |
+
- review: needs attention but not critical yet
|
| 120 |
+
"""
|
| 121 |
+
if self.decay.should_evict(node):
|
| 122 |
+
eig = getattr(node, "epistemic_value", 0.0)
|
| 123 |
+
if eig >= self.min_eig_to_consolidate:
|
| 124 |
+
return "consolidate"
|
| 125 |
+
return "evict"
|
| 126 |
+
return "review"
|
| 127 |
+
|
| 128 |
+
# ---- Scan and schedule --------------------------------------- #
|
| 129 |
+
|
| 130 |
+
def schedule_reviews(self, nodes: "List[MemoryNode]") -> List[ReviewEntry]:
|
| 131 |
+
"""
|
| 132 |
+
Scan the provided nodes and build a schedule of upcoming reviews.
|
| 133 |
+
Nodes with retention ≤ REVIEW_THRESHOLD are immediately flagged.
|
| 134 |
+
|
| 135 |
+
Returns the new ReviewEntry objects added to the schedule.
|
| 136 |
+
"""
|
| 137 |
+
now = datetime.now(timezone.utc)
|
| 138 |
+
new_entries: List[ReviewEntry] = []
|
| 139 |
+
|
| 140 |
+
for node in nodes:
|
| 141 |
+
retention = self.decay.retention(node)
|
| 142 |
+
s_i = self.decay.stability(node)
|
| 143 |
+
|
| 144 |
+
# Always update review_candidate flag on the node itself
|
| 145 |
+
self.decay.update_review_candidate(node)
|
| 146 |
+
|
| 147 |
+
# Schedule next review based on spaced repetition interval
|
| 148 |
+
days_until = self.next_review_days(node)
|
| 149 |
+
due_at = now + timedelta(days=days_until)
|
| 150 |
+
action = self._determine_action(node, retention)
|
| 151 |
+
|
| 152 |
+
entry = ReviewEntry(
|
| 153 |
+
memory_id=node.id,
|
| 154 |
+
due_at=due_at,
|
| 155 |
+
current_retention=retention,
|
| 156 |
+
stability=s_i,
|
| 157 |
+
action=action,
|
| 158 |
+
)
|
| 159 |
+
new_entries.append(entry)
|
| 160 |
+
|
| 161 |
+
# Merge into the schedule (replace existing entries for same memory_id)
|
| 162 |
+
existing_ids = {e.memory_id for e in self._schedule}
|
| 163 |
+
self._schedule = [
|
| 164 |
+
e for e in self._schedule if e.memory_id not in {n.id for n in nodes}
|
| 165 |
+
]
|
| 166 |
+
self._schedule.extend(new_entries)
|
| 167 |
+
self._schedule.sort(key=lambda e: e.due_at)
|
| 168 |
+
|
| 169 |
+
logger.info(
|
| 170 |
+
f"ForgettingCurveManager: scheduled {len(new_entries)} reviews for {len(nodes)} nodes. "
|
| 171 |
+
f"Total scheduled: {len(self._schedule)}"
|
| 172 |
+
)
|
| 173 |
+
return new_entries
|
| 174 |
+
|
| 175 |
+
def get_schedule(self) -> List[ReviewEntry]:
|
| 176 |
+
"""Return the current review schedule sorted by due_at."""
|
| 177 |
+
return sorted(self._schedule, key=lambda e: e.due_at)
|
| 178 |
+
|
| 179 |
+
def get_due_reviews(self) -> List[ReviewEntry]:
|
| 180 |
+
"""Return entries that are due now (due_at <= now)."""
|
| 181 |
+
now = datetime.now(timezone.utc)
|
| 182 |
+
return [e for e in self._schedule if e.due_at <= now]
|
| 183 |
+
|
| 184 |
+
def get_actions_by_type(self, action: str) -> List[ReviewEntry]:
|
| 185 |
+
"""Filter schedule by action type: 'review', 'consolidate', or 'evict'."""
|
| 186 |
+
return [e for e in self._schedule if e.action == action]
|
| 187 |
+
|
| 188 |
+
def remove_entry(self, memory_id: str) -> None:
|
| 189 |
+
"""Remove a memory from the review schedule (e.g., it was evicted)."""
|
| 190 |
+
self._schedule = [e for e in self._schedule if e.memory_id != memory_id]
|
| 191 |
+
|
| 192 |
+
# ---- Engine integration ------------------------------------- #
|
| 193 |
+
|
| 194 |
+
async def run_once(self) -> Dict:
|
| 195 |
+
"""
|
| 196 |
+
Run a full scan over HOT + WARM nodes and update the review schedule.
|
| 197 |
+
|
| 198 |
+
Returns a stats dict with counts per action.
|
| 199 |
+
"""
|
| 200 |
+
if self.engine is None:
|
| 201 |
+
logger.warning("ForgettingCurveManager: no engine attached, cannot scan tiers.")
|
| 202 |
+
return {}
|
| 203 |
+
|
| 204 |
+
nodes: List["MemoryNode"] = []
|
| 205 |
+
try:
|
| 206 |
+
hot = await self.engine.tier_manager.get_hot_snapshot()
|
| 207 |
+
nodes.extend(hot)
|
| 208 |
+
except Exception as e:
|
| 209 |
+
logger.warning(f"ForgettingCurveManager: could not fetch HOT nodes: {e}")
|
| 210 |
+
|
| 211 |
+
try:
|
| 212 |
+
warm = await self.engine.tier_manager.list_warm(max_results=1000)
|
| 213 |
+
nodes.extend(warm)
|
| 214 |
+
except (AttributeError, Exception) as e:
|
| 215 |
+
logger.debug(f"ForgettingCurveManager: WARM fetch skipped: {e}")
|
| 216 |
+
|
| 217 |
+
entries = self.schedule_reviews(nodes)
|
| 218 |
+
|
| 219 |
+
# Count actions
|
| 220 |
+
from collections import Counter
|
| 221 |
+
action_counts = dict(Counter(e.action for e in entries))
|
| 222 |
+
|
| 223 |
+
logger.info(f"ForgettingCurveManager scan: {action_counts}")
|
| 224 |
+
return {
|
| 225 |
+
"nodes_scanned": len(nodes),
|
| 226 |
+
"entries_scheduled": len(entries),
|
| 227 |
+
"action_counts": action_counts,
|
| 228 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# Convenience import alias
|
| 233 |
+
from typing import Dict # noqa: E402 (already imported above, just ensuring type hint works)
|
src/mnemocore/core/hnsw_index.py
CHANGED
|
@@ -50,18 +50,32 @@ FLAT_THRESHOLD: int = 256 # use flat index below this hop count
|
|
| 50 |
# HNSW Index Manager #
|
| 51 |
# ------------------------------------------------------------------ #
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
class HNSWIndexManager:
|
| 54 |
"""
|
| 55 |
Manages a FAISS HNSW binary ANN index for the HOT tier.
|
|
|
|
| 56 |
|
| 57 |
Automatically switches between:
|
| 58 |
- IndexBinaryFlat (N < FLAT_THRESHOLD — exact, faster for small N)
|
| 59 |
- IndexBinaryHNSW (N ≥ FLAT_THRESHOLD — approx, faster for large N)
|
| 60 |
-
|
| 61 |
-
The index is rebuilt from scratch when switching modes (rare operation).
|
| 62 |
-
All operations are synchronous (called from within asyncio.Lock context).
|
| 63 |
"""
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def __init__(
|
| 66 |
self,
|
| 67 |
dimension: int = 16384,
|
|
@@ -69,52 +83,70 @@ class HNSWIndexManager:
|
|
| 69 |
ef_construction: int = DEFAULT_EF_CONSTRUCTION,
|
| 70 |
ef_search: int = DEFAULT_EF_SEARCH,
|
| 71 |
):
|
|
|
|
|
|
|
|
|
|
| 72 |
self.dimension = dimension
|
| 73 |
self.m = m
|
| 74 |
self.ef_construction = ef_construction
|
| 75 |
self.ef_search = ef_search
|
| 76 |
|
| 77 |
-
|
| 78 |
-
self._id_map: Dict[int, str] = {} # faiss_int_id → node_id
|
| 79 |
-
self._node_map: Dict[str, int] = {} # node_id → faiss_int_id
|
| 80 |
-
self._next_id: int = 1
|
| 81 |
-
self._use_hnsw: bool = False
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
| 84 |
self._index = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
if FAISS_AVAILABLE:
|
| 87 |
-
self.
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
# ---- Index construction -------------------------------------- #
|
| 92 |
|
| 93 |
def _build_flat_index(self) -> None:
|
| 94 |
"""Create a fresh IndexBinaryFlat (exact Hamming ANN)."""
|
| 95 |
-
|
| 96 |
-
self._index = faiss.IndexBinaryIDMap(base)
|
| 97 |
self._use_hnsw = False
|
| 98 |
logger.debug(f"Built FAISS flat binary index (dim={self.dimension})")
|
| 99 |
|
| 100 |
-
def _build_hnsw_index(self
|
| 101 |
"""
|
| 102 |
Build an HNSW binary index and optionally re-populate with existing vectors.
|
| 103 |
-
|
| 104 |
-
Note: FAISS IndexBinaryHNSW does NOT support IDMap natively, so we use a
|
| 105 |
-
custom double-mapping approach: HNSW indices map 1-to-1 to our _id_map.
|
| 106 |
-
We rebuild as IndexBinaryHNSW and re-add all existing vectors.
|
| 107 |
"""
|
| 108 |
hnsw = faiss.IndexBinaryHNSW(self.dimension, self.m)
|
| 109 |
hnsw.hnsw.efConstruction = self.ef_construction
|
| 110 |
hnsw.hnsw.efSearch = self.ef_search
|
| 111 |
|
| 112 |
-
if
|
| 113 |
-
#
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
self._index = hnsw
|
| 120 |
self._use_hnsw = True
|
|
@@ -125,43 +157,18 @@ class HNSWIndexManager:
|
|
| 125 |
|
| 126 |
def _maybe_upgrade_to_hnsw(self) -> None:
|
| 127 |
"""Upgrade to HNSW index if HOT tier has grown large enough."""
|
| 128 |
-
if not FAISS_AVAILABLE:
|
| 129 |
-
return
|
| 130 |
-
if self._use_hnsw:
|
| 131 |
return
|
| 132 |
-
|
|
|
|
| 133 |
return
|
| 134 |
|
| 135 |
logger.info(
|
| 136 |
-
f"HOT tier size ({
|
| 137 |
"— upgrading to HNSW index."
|
| 138 |
)
|
| 139 |
|
| 140 |
-
|
| 141 |
-
# We rebuild from the current flat index contents.
|
| 142 |
-
# Collect all existing (local_pos → node_vector) pairs.
|
| 143 |
-
#
|
| 144 |
-
# For simplicity in this transition we do a full rebuild from scratch:
|
| 145 |
-
# the upgrade happens at most once per process lifetime (HOT usually stays
|
| 146 |
-
# under threshold or once it crosses, it stays crossed).
|
| 147 |
-
existing: List[Tuple[int, np.ndarray]] = []
|
| 148 |
-
for fid, node_id in self._id_map.items():
|
| 149 |
-
# We can't reconstruct vectors from IndexBinaryIDMap cheaply,
|
| 150 |
-
# so we store them in a shadow cache while using the flat index.
|
| 151 |
-
if node_id in self._vector_cache:
|
| 152 |
-
existing.append((fid, self._vector_cache[node_id]))
|
| 153 |
-
|
| 154 |
-
self._build_hnsw_index(existing)
|
| 155 |
-
|
| 156 |
-
# ---- Vector shadow cache (needed for HNSW rebuild) ----------- #
|
| 157 |
-
# HNSW indices don't support IDMap; we cache raw vectors separately
|
| 158 |
-
# so we can rebuild on threshold-crossing.
|
| 159 |
-
|
| 160 |
-
@property
|
| 161 |
-
def _vector_cache(self) -> Dict[str, np.ndarray]:
|
| 162 |
-
if not hasattr(self, "_vcache"):
|
| 163 |
-
object.__setattr__(self, "_vcache", {})
|
| 164 |
-
return self._vcache # type: ignore[attr-defined]
|
| 165 |
|
| 166 |
# ---- Public API --------------------------------------------- #
|
| 167 |
|
|
@@ -176,77 +183,66 @@ class HNSWIndexManager:
|
|
| 176 |
if not FAISS_AVAILABLE or self._index is None:
|
| 177 |
return
|
| 178 |
|
| 179 |
-
|
| 180 |
-
self._next_id += 1
|
| 181 |
-
self._id_map[fid] = node_id
|
| 182 |
-
self._node_map[node_id] = fid
|
| 183 |
-
self._vector_cache[node_id] = hdv_data.copy()
|
| 184 |
-
|
| 185 |
-
vec = np.expand_dims(hdv_data, axis=0)
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
# HNSW.add() — position is implicit (sequential)
|
| 190 |
self._index.add(vec)
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
return
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
|
| 201 |
def remove(self, node_id: str) -> None:
|
| 202 |
"""
|
| 203 |
Remove a node from the index.
|
| 204 |
-
|
| 205 |
-
For HNSW (no IDMap), we mark the node as deleted in our bookkeeping
|
| 206 |
-
and rebuild the index lazily when the deletion rate exceeds 20%.
|
| 207 |
"""
|
| 208 |
if not FAISS_AVAILABLE or self._index is None:
|
| 209 |
return
|
| 210 |
|
| 211 |
-
|
| 212 |
-
if fid is None:
|
| 213 |
-
return
|
| 214 |
-
|
| 215 |
-
self._id_map.pop(fid, None)
|
| 216 |
-
self._vector_cache.pop(node_id, None)
|
| 217 |
-
|
| 218 |
-
if not self._use_hnsw:
|
| 219 |
try:
|
| 220 |
-
|
| 221 |
-
self.
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
def search(self, query_data: np.ndarray, top_k: int = 10) -> List[Tuple[str, float]]:
|
| 243 |
"""
|
| 244 |
Search for top-k nearest neighbours.
|
| 245 |
|
| 246 |
-
Args:
|
| 247 |
-
query_data: Packed uint8 query array (D/8 bytes).
|
| 248 |
-
top_k: Number of results to return.
|
| 249 |
-
|
| 250 |
Returns:
|
| 251 |
List of (node_id, similarity_score) sorted by descending similarity.
|
| 252 |
similarity = 1 - normalised_hamming_distance ∈ [0, 1].
|
|
@@ -254,8 +250,25 @@ class HNSWIndexManager:
|
|
| 254 |
if not FAISS_AVAILABLE or self._index is None or not self._id_map:
|
| 255 |
return []
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
try:
|
| 261 |
distances, ids = self._index.search(q, k)
|
|
@@ -265,43 +278,59 @@ class HNSWIndexManager:
|
|
| 265 |
|
| 266 |
results: List[Tuple[str, float]] = []
|
| 267 |
for dist, idx in zip(distances[0], ids[0]):
|
| 268 |
-
if idx =
|
| 269 |
continue
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
node_id = self._id_map.get(int(idx))
|
| 276 |
-
|
| 277 |
-
if node_id:
|
| 278 |
-
sim = 1.0 - float(dist) / self.dimension
|
| 279 |
results.append((node_id, sim))
|
|
|
|
|
|
|
| 280 |
|
| 281 |
return results
|
| 282 |
|
| 283 |
-
def
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
@property
|
| 303 |
def size(self) -> int:
|
| 304 |
-
return len(self._id_map)
|
| 305 |
|
| 306 |
@property
|
| 307 |
def index_type(self) -> str:
|
|
@@ -318,4 +347,5 @@ class HNSWIndexManager:
|
|
| 318 |
"ef_construction": self.ef_construction if self._use_hnsw else None,
|
| 319 |
"ef_search": self.ef_search if self._use_hnsw else None,
|
| 320 |
"faiss_available": FAISS_AVAILABLE,
|
|
|
|
| 321 |
}
|
|
|
|
| 50 |
# HNSW Index Manager #
|
| 51 |
# ------------------------------------------------------------------ #
|
| 52 |
|
| 53 |
+
import json
|
| 54 |
+
from pathlib import Path
|
| 55 |
+
from threading import Lock
|
| 56 |
+
from .config import get_config
|
| 57 |
+
|
| 58 |
class HNSWIndexManager:
|
| 59 |
"""
|
| 60 |
Manages a FAISS HNSW binary ANN index for the HOT tier.
|
| 61 |
+
Thread-safe singleton with disk persistence.
|
| 62 |
|
| 63 |
Automatically switches between:
|
| 64 |
- IndexBinaryFlat (N < FLAT_THRESHOLD — exact, faster for small N)
|
| 65 |
- IndexBinaryHNSW (N ≥ FLAT_THRESHOLD — approx, faster for large N)
|
|
|
|
|
|
|
|
|
|
| 66 |
"""
|
| 67 |
|
| 68 |
+
_instance: "HNSWIndexManager | None" = None
|
| 69 |
+
_singleton_lock: Lock = Lock()
|
| 70 |
+
|
| 71 |
+
def __new__(cls, *args, **kwargs) -> "HNSWIndexManager":
|
| 72 |
+
with cls._singleton_lock:
|
| 73 |
+
if cls._instance is None:
|
| 74 |
+
obj = super().__new__(cls)
|
| 75 |
+
obj._initialized = False
|
| 76 |
+
cls._instance = obj
|
| 77 |
+
return cls._instance
|
| 78 |
+
|
| 79 |
def __init__(
|
| 80 |
self,
|
| 81 |
dimension: int = 16384,
|
|
|
|
| 83 |
ef_construction: int = DEFAULT_EF_CONSTRUCTION,
|
| 84 |
ef_search: int = DEFAULT_EF_SEARCH,
|
| 85 |
):
|
| 86 |
+
if getattr(self, "_initialized", False):
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
self.dimension = dimension
|
| 90 |
self.m = m
|
| 91 |
self.ef_construction = ef_construction
|
| 92 |
self.ef_search = ef_search
|
| 93 |
|
| 94 |
+
self._write_lock = Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
self._id_map: List[Optional[str]] = []
|
| 97 |
+
self._vector_store: List[np.ndarray] = []
|
| 98 |
+
self._use_hnsw = False
|
| 99 |
+
self._stale_count = 0
|
| 100 |
self._index = None
|
| 101 |
+
|
| 102 |
+
config = get_config()
|
| 103 |
+
data_dir = Path(config.paths.data_dir if hasattr(config, 'paths') else "./data")
|
| 104 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
|
| 106 |
+
self.INDEX_PATH = data_dir / "mnemocore_hnsw.faiss"
|
| 107 |
+
self.IDMAP_PATH = data_dir / "mnemocore_hnsw_idmap.json"
|
| 108 |
+
self.VECTOR_PATH = data_dir / "mnemocore_hnsw_vectors.npy"
|
| 109 |
|
| 110 |
if FAISS_AVAILABLE:
|
| 111 |
+
if self.INDEX_PATH.exists() and self.IDMAP_PATH.exists() and self.VECTOR_PATH.exists():
|
| 112 |
+
self._load()
|
| 113 |
+
else:
|
| 114 |
+
self._build_flat_index()
|
| 115 |
+
|
| 116 |
+
self._initialized = True
|
| 117 |
|
| 118 |
# ---- Index construction -------------------------------------- #
|
| 119 |
|
| 120 |
def _build_flat_index(self) -> None:
|
| 121 |
"""Create a fresh IndexBinaryFlat (exact Hamming ANN)."""
|
| 122 |
+
self._index = faiss.IndexBinaryFlat(self.dimension)
|
|
|
|
| 123 |
self._use_hnsw = False
|
| 124 |
logger.debug(f"Built FAISS flat binary index (dim={self.dimension})")
|
| 125 |
|
| 126 |
+
def _build_hnsw_index(self) -> None:
|
| 127 |
"""
|
| 128 |
Build an HNSW binary index and optionally re-populate with existing vectors.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
"""
|
| 130 |
hnsw = faiss.IndexBinaryHNSW(self.dimension, self.m)
|
| 131 |
hnsw.hnsw.efConstruction = self.ef_construction
|
| 132 |
hnsw.hnsw.efSearch = self.ef_search
|
| 133 |
|
| 134 |
+
if self._vector_store:
|
| 135 |
+
# Compact the index to remove None entries
|
| 136 |
+
compact_ids = []
|
| 137 |
+
compact_vecs = []
|
| 138 |
+
for i, node_id in enumerate(self._id_map):
|
| 139 |
+
if node_id is not None:
|
| 140 |
+
compact_ids.append(node_id)
|
| 141 |
+
compact_vecs.append(self._vector_store[i])
|
| 142 |
+
|
| 143 |
+
if compact_vecs:
|
| 144 |
+
vecs = np.stack(compact_vecs)
|
| 145 |
+
hnsw.add(vecs)
|
| 146 |
+
|
| 147 |
+
self._id_map = compact_ids
|
| 148 |
+
self._vector_store = compact_vecs
|
| 149 |
+
self._stale_count = 0
|
| 150 |
|
| 151 |
self._index = hnsw
|
| 152 |
self._use_hnsw = True
|
|
|
|
| 157 |
|
| 158 |
def _maybe_upgrade_to_hnsw(self) -> None:
|
| 159 |
"""Upgrade to HNSW index if HOT tier has grown large enough."""
|
| 160 |
+
if not FAISS_AVAILABLE or self._use_hnsw:
|
|
|
|
|
|
|
| 161 |
return
|
| 162 |
+
active_count = len(self._id_map) - self._stale_count
|
| 163 |
+
if active_count < FLAT_THRESHOLD:
|
| 164 |
return
|
| 165 |
|
| 166 |
logger.info(
|
| 167 |
+
f"HOT tier size ({active_count}) ≥ threshold ({FLAT_THRESHOLD}) "
|
| 168 |
"— upgrading to HNSW index."
|
| 169 |
)
|
| 170 |
|
| 171 |
+
self._build_hnsw_index()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
# ---- Public API --------------------------------------------- #
|
| 174 |
|
|
|
|
| 183 |
if not FAISS_AVAILABLE or self._index is None:
|
| 184 |
return
|
| 185 |
|
| 186 |
+
vec = np.ascontiguousarray(np.expand_dims(hdv_data, axis=0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
+
with self._write_lock:
|
| 189 |
+
try:
|
|
|
|
| 190 |
self._index.add(vec)
|
| 191 |
+
self._id_map.append(node_id)
|
| 192 |
+
self._vector_store.append(hdv_data.copy())
|
| 193 |
+
except Exception as exc:
|
| 194 |
+
logger.error(f"HNSW/FAISS add failed for {node_id}: {repr(exc)}")
|
| 195 |
+
return
|
|
|
|
| 196 |
|
| 197 |
+
self._maybe_upgrade_to_hnsw()
|
| 198 |
+
self._save()
|
| 199 |
|
| 200 |
def remove(self, node_id: str) -> None:
|
| 201 |
"""
|
| 202 |
Remove a node from the index.
|
| 203 |
+
Marks node as deleted and rebuilds index lazily when the deletion rate exceeds 20%.
|
|
|
|
|
|
|
| 204 |
"""
|
| 205 |
if not FAISS_AVAILABLE or self._index is None:
|
| 206 |
return
|
| 207 |
|
| 208 |
+
with self._write_lock:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
try:
|
| 210 |
+
fid = self._id_map.index(node_id)
|
| 211 |
+
self._id_map[fid] = None
|
| 212 |
+
self._stale_count += 1
|
| 213 |
+
|
| 214 |
+
total = max(len(self._id_map), 1)
|
| 215 |
+
stale_fraction = self._stale_count / total
|
| 216 |
+
|
| 217 |
+
if stale_fraction > 0.20 and len(self._id_map) > 0:
|
| 218 |
+
logger.info(f"HNSW stale fraction {stale_fraction:.1%} — rebuilding index.")
|
| 219 |
+
if self._use_hnsw:
|
| 220 |
+
self._build_hnsw_index()
|
| 221 |
+
else:
|
| 222 |
+
self._build_flat_index()
|
| 223 |
+
if self._vector_store:
|
| 224 |
+
compact_ids = []
|
| 225 |
+
compact_vecs = []
|
| 226 |
+
for i, nid in enumerate(self._id_map):
|
| 227 |
+
if nid is not None:
|
| 228 |
+
compact_ids.append(nid)
|
| 229 |
+
compact_vecs.append(self._vector_store[i])
|
| 230 |
+
if compact_vecs:
|
| 231 |
+
vecs = np.ascontiguousarray(np.stack(compact_vecs))
|
| 232 |
+
self._index.add(vecs)
|
| 233 |
+
self._id_map = compact_ids
|
| 234 |
+
self._vector_store = compact_vecs
|
| 235 |
+
self._stale_count = 0
|
| 236 |
+
|
| 237 |
+
self._save()
|
| 238 |
+
except ValueError:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
|
| 242 |
def search(self, query_data: np.ndarray, top_k: int = 10) -> List[Tuple[str, float]]:
|
| 243 |
"""
|
| 244 |
Search for top-k nearest neighbours.
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
Returns:
|
| 247 |
List of (node_id, similarity_score) sorted by descending similarity.
|
| 248 |
similarity = 1 - normalised_hamming_distance ∈ [0, 1].
|
|
|
|
| 250 |
if not FAISS_AVAILABLE or self._index is None or not self._id_map:
|
| 251 |
return []
|
| 252 |
|
| 253 |
+
# Fetch more to account for deleted (None) entries
|
| 254 |
+
k = min(top_k + self._stale_count, len(self._id_map))
|
| 255 |
+
if k <= 0:
|
| 256 |
+
return []
|
| 257 |
+
|
| 258 |
+
index_dimension = int(getattr(self._index, "d", self.dimension) or self.dimension)
|
| 259 |
+
query_bytes = np.ascontiguousarray(query_data, dtype=np.uint8).reshape(-1)
|
| 260 |
+
expected_bytes = index_dimension // 8
|
| 261 |
+
if expected_bytes > 0 and query_bytes.size != expected_bytes:
|
| 262 |
+
logger.warning(
|
| 263 |
+
f"HNSW query dimension mismatch: index={index_dimension} bits ({expected_bytes} bytes), "
|
| 264 |
+
f"query={query_bytes.size} bytes. Adjusting query to index dimension."
|
| 265 |
+
)
|
| 266 |
+
if query_bytes.size > expected_bytes:
|
| 267 |
+
query_bytes = query_bytes[:expected_bytes]
|
| 268 |
+
else:
|
| 269 |
+
query_bytes = np.pad(query_bytes, (0, expected_bytes - query_bytes.size), mode="constant")
|
| 270 |
+
|
| 271 |
+
q = np.expand_dims(query_bytes, axis=0)
|
| 272 |
|
| 273 |
try:
|
| 274 |
distances, ids = self._index.search(q, k)
|
|
|
|
| 278 |
|
| 279 |
results: List[Tuple[str, float]] = []
|
| 280 |
for dist, idx in zip(distances[0], ids[0]):
|
| 281 |
+
if idx < 0 or idx >= len(self._id_map):
|
| 282 |
continue
|
| 283 |
+
|
| 284 |
+
node_id = self._id_map[idx]
|
| 285 |
+
if node_id is not None:
|
| 286 |
+
sim = 1.0 - float(dist) / max(index_dimension, 1)
|
| 287 |
+
sim = float(np.clip(sim, 0.0, 1.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
results.append((node_id, sim))
|
| 289 |
+
if len(results) >= top_k:
|
| 290 |
+
break
|
| 291 |
|
| 292 |
return results
|
| 293 |
|
| 294 |
+
def _save(self):
|
| 295 |
+
try:
|
| 296 |
+
faiss.write_index_binary(self._index, str(self.INDEX_PATH))
|
| 297 |
+
with open(self.IDMAP_PATH, "w") as f:
|
| 298 |
+
json.dump({
|
| 299 |
+
"id_map": self._id_map,
|
| 300 |
+
"use_hnsw": self._use_hnsw,
|
| 301 |
+
"stale_count": self._stale_count
|
| 302 |
+
}, f)
|
| 303 |
+
if self._vector_store:
|
| 304 |
+
np.save(str(self.VECTOR_PATH), np.stack(self._vector_store))
|
| 305 |
+
except Exception as e:
|
| 306 |
+
logger.error(f"Failed to save HNSW index state: {e}")
|
| 307 |
+
|
| 308 |
+
def _load(self):
|
| 309 |
+
try:
|
| 310 |
+
self._index = faiss.read_index_binary(str(self.INDEX_PATH))
|
| 311 |
+
index_dimension = int(getattr(self._index, "d", self.dimension) or self.dimension)
|
| 312 |
+
if index_dimension != self.dimension:
|
| 313 |
+
logger.warning(
|
| 314 |
+
f"HNSW index dimension mismatch on load: config={self.dimension}, index={index_dimension}. "
|
| 315 |
+
"Using index dimension."
|
| 316 |
+
)
|
| 317 |
+
self.dimension = index_dimension
|
| 318 |
+
with open(self.IDMAP_PATH, "r") as f:
|
| 319 |
+
state = json.load(f)
|
| 320 |
+
self._id_map = state.get("id_map", [])
|
| 321 |
+
self._use_hnsw = state.get("use_hnsw", False)
|
| 322 |
+
self._stale_count = state.get("stale_count", 0)
|
| 323 |
+
|
| 324 |
+
vecs = np.load(str(self.VECTOR_PATH))
|
| 325 |
+
self._vector_store = list(vecs)
|
| 326 |
+
logger.info("Loaded HNSW persistent state from disk")
|
| 327 |
+
except Exception as e:
|
| 328 |
+
logger.error(f"Failed to load HNSW index state: {e}")
|
| 329 |
+
self._build_flat_index()
|
| 330 |
|
| 331 |
@property
|
| 332 |
def size(self) -> int:
|
| 333 |
+
return len([x for x in self._id_map if x is not None])
|
| 334 |
|
| 335 |
@property
|
| 336 |
def index_type(self) -> str:
|
|
|
|
| 347 |
"ef_construction": self.ef_construction if self._use_hnsw else None,
|
| 348 |
"ef_search": self.ef_search if self._use_hnsw else None,
|
| 349 |
"faiss_available": FAISS_AVAILABLE,
|
| 350 |
+
"stale_count": self._stale_count
|
| 351 |
}
|
src/mnemocore/core/memory_model.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Memory Models
|
| 3 |
+
=============
|
| 4 |
+
Data classes mapping the Cognitive Architecture Phase 5 entities:
|
| 5 |
+
Working Memory (WM), Episodic Memory (EM), Semantic Memory (SM),
|
| 6 |
+
Procedural Memory (PM), and Meta-Memory (MM).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Literal, Optional, List
|
| 12 |
+
|
| 13 |
+
from .binary_hdv import BinaryHDV
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# --- Working Memory (WM) ---
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class WorkingMemoryItem:
|
| 20 |
+
id: str
|
| 21 |
+
agent_id: str
|
| 22 |
+
created_at: datetime
|
| 23 |
+
ttl_seconds: int
|
| 24 |
+
content: str
|
| 25 |
+
kind: Literal["thought", "observation", "goal", "plan_step", "action", "meta"]
|
| 26 |
+
importance: float
|
| 27 |
+
tags: List[str]
|
| 28 |
+
hdv: Optional[BinaryHDV] = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class WorkingMemoryState:
|
| 33 |
+
agent_id: str
|
| 34 |
+
max_items: int
|
| 35 |
+
items: List[WorkingMemoryItem] = field(default_factory=list)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# --- Episodic Memory (EM) ---
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class EpisodeEvent:
|
| 42 |
+
timestamp: datetime
|
| 43 |
+
kind: Literal["observation", "action", "thought", "reward", "error", "system"]
|
| 44 |
+
content: str
|
| 45 |
+
metadata: dict[str, Any]
|
| 46 |
+
hdv: Optional[BinaryHDV] = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class Episode:
|
| 51 |
+
id: str
|
| 52 |
+
agent_id: str
|
| 53 |
+
started_at: datetime
|
| 54 |
+
ended_at: Optional[datetime]
|
| 55 |
+
goal: Optional[str]
|
| 56 |
+
context: Optional[str]
|
| 57 |
+
events: List[EpisodeEvent]
|
| 58 |
+
outcome: Literal["success", "failure", "partial", "unknown", "in_progress"]
|
| 59 |
+
reward: Optional[float]
|
| 60 |
+
links_prev: List[str]
|
| 61 |
+
links_next: List[str]
|
| 62 |
+
ltp_strength: float
|
| 63 |
+
reliability: float
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def is_active(self) -> bool:
|
| 67 |
+
return self.ended_at is None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# --- Semantic Memory (SM) ---
|
| 71 |
+
|
| 72 |
+
@dataclass
|
| 73 |
+
class SemanticConcept:
|
| 74 |
+
id: str
|
| 75 |
+
label: str
|
| 76 |
+
description: str
|
| 77 |
+
tags: List[str]
|
| 78 |
+
prototype_hdv: BinaryHDV
|
| 79 |
+
support_episode_ids: List[str]
|
| 80 |
+
reliability: float
|
| 81 |
+
last_updated_at: datetime
|
| 82 |
+
metadata: dict[str, Any]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# --- Procedural Memory (PM) ---
|
| 86 |
+
|
| 87 |
+
@dataclass
|
| 88 |
+
class ProcedureStep:
|
| 89 |
+
order: int
|
| 90 |
+
instruction: str
|
| 91 |
+
code_snippet: Optional[str] = None
|
| 92 |
+
tool_call: Optional[dict[str, Any]] = None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@dataclass
|
| 96 |
+
class Procedure:
|
| 97 |
+
id: str
|
| 98 |
+
name: str
|
| 99 |
+
description: str
|
| 100 |
+
created_by_agent: Optional[str]
|
| 101 |
+
created_at: datetime
|
| 102 |
+
updated_at: datetime
|
| 103 |
+
steps: List[ProcedureStep]
|
| 104 |
+
trigger_pattern: str
|
| 105 |
+
success_count: int
|
| 106 |
+
failure_count: int
|
| 107 |
+
reliability: float
|
| 108 |
+
tags: List[str]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# --- Meta-Memory (MM) ---
|
| 112 |
+
|
| 113 |
+
@dataclass
|
| 114 |
+
class SelfMetric:
|
| 115 |
+
name: str
|
| 116 |
+
value: float
|
| 117 |
+
window: str # e.g. "5m", "1h", "24h"
|
| 118 |
+
updated_at: datetime
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@dataclass
|
| 122 |
+
class SelfImprovementProposal:
|
| 123 |
+
id: str
|
| 124 |
+
created_at: datetime
|
| 125 |
+
author: Literal["system", "agent", "human"]
|
| 126 |
+
title: str
|
| 127 |
+
description: str
|
| 128 |
+
rationale: str
|
| 129 |
+
expected_effect: str
|
| 130 |
+
status: Literal["pending", "accepted", "rejected", "implemented"]
|
| 131 |
+
metadata: dict[str, Any]
|
| 132 |
+
|
src/mnemocore/core/meta_memory.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Meta Memory Service
|
| 3 |
+
===================
|
| 4 |
+
Maintains a self-model of the memory substrate, gathering metrics and surfacing self-improvement proposals.
|
| 5 |
+
Plays a crucial role in enabling an AGI system to observe and upgrade its own thinking architectures over time.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Dict, List, Optional
|
| 9 |
+
import threading
|
| 10 |
+
import logging
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
from .memory_model import SelfMetric, SelfImprovementProposal
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class MetaMemoryService:
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self._metrics: List[SelfMetric] = []
|
| 21 |
+
self._proposals: Dict[str, SelfImprovementProposal] = {}
|
| 22 |
+
self._lock = threading.RLock()
|
| 23 |
+
|
| 24 |
+
def record_metric(self, name: str, value: float, window: str) -> None:
|
| 25 |
+
"""Log a new performance or algorithmic metric reading."""
|
| 26 |
+
with self._lock:
|
| 27 |
+
# We strictly bind this to metrics history for Subconscious AI trend analysis.
|
| 28 |
+
metric = SelfMetric(
|
| 29 |
+
name=name, value=value, window=window, updated_at=datetime.utcnow()
|
| 30 |
+
)
|
| 31 |
+
self._metrics.append(metric)
|
| 32 |
+
|
| 33 |
+
# Cap local metrics storage bounds
|
| 34 |
+
if len(self._metrics) > 10000:
|
| 35 |
+
self._metrics = self._metrics[-5000:]
|
| 36 |
+
|
| 37 |
+
logger.debug(f"Recorded meta-metric: {name}={value} ({window})")
|
| 38 |
+
|
| 39 |
+
def list_metrics(self, limit: int = 100, window: Optional[str] = None) -> List[SelfMetric]:
|
| 40 |
+
"""Fetch historical metric footprints."""
|
| 41 |
+
with self._lock:
|
| 42 |
+
filtered = [m for m in self._metrics if (not window) or m.window == window]
|
| 43 |
+
filtered.sort(key=lambda x: x.updated_at, reverse=True)
|
| 44 |
+
return filtered[:limit]
|
| 45 |
+
|
| 46 |
+
def create_proposal(self, proposal: SelfImprovementProposal) -> str:
|
| 47 |
+
"""Inject a formally modeled improvement prompt into the queue."""
|
| 48 |
+
with self._lock:
|
| 49 |
+
self._proposals[proposal.id] = proposal
|
| 50 |
+
logger.info(f"New self-improvement proposal created by {proposal.author}: {proposal.title}")
|
| 51 |
+
return proposal.id
|
| 52 |
+
|
| 53 |
+
def update_proposal_status(self, proposal_id: str, status: str) -> None:
|
| 54 |
+
"""Mark a proposal as accepted, rejected, or implemented by the oversight entity."""
|
| 55 |
+
with self._lock:
|
| 56 |
+
proposal = self._proposals.get(proposal_id)
|
| 57 |
+
if not proposal:
|
| 58 |
+
logger.warning(f"Could not update unknown proposal ID: {proposal_id}")
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
proposal.status = status # type: ignore
|
| 62 |
+
logger.info(f"Proposal {proposal_id} status escalated to: {status}")
|
| 63 |
+
|
| 64 |
+
def list_proposals(self, status: Optional[str] = None) -> List[SelfImprovementProposal]:
|
| 65 |
+
"""Retrieve proposals matching a given state."""
|
| 66 |
+
with self._lock:
|
| 67 |
+
if status:
|
| 68 |
+
return [p for p in self._proposals.values() if p.status == status]
|
| 69 |
+
return list(self._proposals.values())
|
| 70 |
+
|
src/mnemocore/core/node.py
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
|
|
|
|
|
| 1 |
from dataclasses import dataclass, field
|
| 2 |
from datetime import datetime, timezone
|
| 3 |
-
from typing import Dict, Any, Optional
|
| 4 |
import math
|
| 5 |
|
| 6 |
from .binary_hdv import BinaryHDV
|
| 7 |
from .config import get_config
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
@dataclass
|
| 11 |
class MemoryNode:
|
|
@@ -35,6 +40,15 @@ class MemoryNode:
|
|
| 35 |
# Phase 4.3: Episodic Chaining - links to temporally adjacent memories
|
| 36 |
previous_id: Optional[str] = None # UUID of the memory created immediately before this one
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
def access(self, update_weights: bool = True):
|
| 39 |
"""Retrieve memory (reconsolidation)"""
|
| 40 |
now = datetime.now(timezone.utc)
|
|
@@ -46,6 +60,11 @@ class MemoryNode:
|
|
| 46 |
# We recalculate based on new access count
|
| 47 |
self.calculate_ltp()
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# Legacy updates
|
| 50 |
self.epistemic_value *= 1.01
|
| 51 |
self.epistemic_value = min(self.epistemic_value, 1.0)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from datetime import datetime, timezone
|
| 5 |
+
from typing import TYPE_CHECKING, Dict, Any, Optional
|
| 6 |
import math
|
| 7 |
|
| 8 |
from .binary_hdv import BinaryHDV
|
| 9 |
from .config import get_config
|
| 10 |
|
| 11 |
+
if TYPE_CHECKING:
|
| 12 |
+
from .provenance import ProvenanceRecord
|
| 13 |
+
|
| 14 |
|
| 15 |
@dataclass
|
| 16 |
class MemoryNode:
|
|
|
|
| 40 |
# Phase 4.3: Episodic Chaining - links to temporally adjacent memories
|
| 41 |
previous_id: Optional[str] = None # UUID of the memory created immediately before this one
|
| 42 |
|
| 43 |
+
# Phase 5.0 — Agent 1: Trust & Provenance
|
| 44 |
+
provenance: Optional["ProvenanceRecord"] = field(default=None, repr=False)
|
| 45 |
+
|
| 46 |
+
# Phase 5.0 — Agent 2: Adaptive Temporal Decay
|
| 47 |
+
# Per-memory stability: S_i = S_base * (1 + k * access_count)
|
| 48 |
+
# Starts at 1.0; increases logarithmically on access.
|
| 49 |
+
stability: float = 1.0
|
| 50 |
+
review_candidate: bool = False # Set by ForgettingCurveManager when near decay threshold
|
| 51 |
+
|
| 52 |
def access(self, update_weights: bool = True):
|
| 53 |
"""Retrieve memory (reconsolidation)"""
|
| 54 |
now = datetime.now(timezone.utc)
|
|
|
|
| 60 |
# We recalculate based on new access count
|
| 61 |
self.calculate_ltp()
|
| 62 |
|
| 63 |
+
# Phase 5.0: update per-memory stability on each successful access
|
| 64 |
+
# S_i grows logarithmically so older frequently-accessed memories are more stable
|
| 65 |
+
import math as _math
|
| 66 |
+
self.stability = max(1.0, 1.0 + _math.log1p(self.access_count) * 0.5)
|
| 67 |
+
|
| 68 |
# Legacy updates
|
| 69 |
self.epistemic_value *= 1.01
|
| 70 |
self.epistemic_value = min(self.epistemic_value, 1.0)
|
src/mnemocore/core/prediction_store.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prediction Memory Store (Phase 5.0 — Agent 4)
|
| 3 |
+
==============================================
|
| 4 |
+
Stores explicitly made predictions about future events and tracks their outcomes.
|
| 5 |
+
|
| 6 |
+
A prediction has a lifecycle:
|
| 7 |
+
pending → verified (correct) OR falsified (wrong) OR expired (deadline passed)
|
| 8 |
+
|
| 9 |
+
Key behaviors:
|
| 10 |
+
- Verified predictions STRENGTHEN related strategic memories via synaptic binding
|
| 11 |
+
- Falsified predictions REDUCE confidence on related memories + generate a
|
| 12 |
+
"lesson learned" via SubconsciousAI
|
| 13 |
+
- Expired predictions are flagged for manual review
|
| 14 |
+
|
| 15 |
+
Backed by a lightweight in-memory + provenance-attached store.
|
| 16 |
+
For persistence, predictions are serialized to node.metadata["prediction"] and
|
| 17 |
+
stored as regular MemoryNodes in the HOT tier with a special tag.
|
| 18 |
+
|
| 19 |
+
Public API:
|
| 20 |
+
store = PredictionStore()
|
| 21 |
+
pred_id = store.create(content="...", confidence=0.7, deadline_days=90)
|
| 22 |
+
store.verify(pred_id, success=True, notes="EU AI Act enforced")
|
| 23 |
+
due = store.get_due() # predictions past their deadline
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import uuid
|
| 29 |
+
from dataclasses import dataclass, field
|
| 30 |
+
from datetime import datetime, timezone, timedelta
|
| 31 |
+
from typing import Any, Dict, List, Optional
|
| 32 |
+
|
| 33 |
+
from loguru import logger
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ------------------------------------------------------------------ #
|
| 37 |
+
# Prediction status constants #
|
| 38 |
+
# ------------------------------------------------------------------ #
|
| 39 |
+
|
| 40 |
+
STATUS_PENDING = "pending"
|
| 41 |
+
STATUS_VERIFIED = "verified"
|
| 42 |
+
STATUS_FALSIFIED = "falsified"
|
| 43 |
+
STATUS_EXPIRED = "expired"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ------------------------------------------------------------------ #
|
| 47 |
+
# PredictionRecord #
|
| 48 |
+
# ------------------------------------------------------------------ #
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class PredictionRecord:
|
| 52 |
+
"""A single forward-looking prediction stored in MnemoCore."""
|
| 53 |
+
|
| 54 |
+
id: str = field(default_factory=lambda: f"pred_{uuid.uuid4().hex[:16]}")
|
| 55 |
+
content: str = ""
|
| 56 |
+
predicted_at: str = field(
|
| 57 |
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
| 58 |
+
)
|
| 59 |
+
verification_deadline: Optional[str] = None # ISO datetime string
|
| 60 |
+
confidence_at_creation: float = 0.5
|
| 61 |
+
status: str = STATUS_PENDING
|
| 62 |
+
outcome: Optional[bool] = None # True=verified, False=falsified
|
| 63 |
+
verification_notes: Optional[str] = None
|
| 64 |
+
verified_at: Optional[str] = None
|
| 65 |
+
related_memory_ids: List[str] = field(default_factory=list)
|
| 66 |
+
tags: List[str] = field(default_factory=list)
|
| 67 |
+
|
| 68 |
+
def is_expired(self) -> bool:
|
| 69 |
+
"""True if the deadline has passed and status is still pending."""
|
| 70 |
+
if self.status != STATUS_PENDING or self.verification_deadline is None:
|
| 71 |
+
return False
|
| 72 |
+
deadline = datetime.fromisoformat(self.verification_deadline)
|
| 73 |
+
if deadline.tzinfo is None:
|
| 74 |
+
deadline = deadline.replace(tzinfo=timezone.utc)
|
| 75 |
+
return datetime.now(timezone.utc) > deadline
|
| 76 |
+
|
| 77 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 78 |
+
return {
|
| 79 |
+
"id": self.id,
|
| 80 |
+
"content": self.content,
|
| 81 |
+
"predicted_at": self.predicted_at,
|
| 82 |
+
"verification_deadline": self.verification_deadline,
|
| 83 |
+
"confidence_at_creation": round(self.confidence_at_creation, 4),
|
| 84 |
+
"status": self.status,
|
| 85 |
+
"outcome": self.outcome,
|
| 86 |
+
"verification_notes": self.verification_notes,
|
| 87 |
+
"verified_at": self.verified_at,
|
| 88 |
+
"related_memory_ids": self.related_memory_ids,
|
| 89 |
+
"tags": self.tags,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
@classmethod
|
| 93 |
+
def from_dict(cls, d: Dict[str, Any]) -> "PredictionRecord":
|
| 94 |
+
return cls(
|
| 95 |
+
id=d.get("id", f"pred_{uuid.uuid4().hex[:16]}"),
|
| 96 |
+
content=d.get("content", ""),
|
| 97 |
+
predicted_at=d.get("predicted_at", datetime.now(timezone.utc).isoformat()),
|
| 98 |
+
verification_deadline=d.get("verification_deadline"),
|
| 99 |
+
confidence_at_creation=d.get("confidence_at_creation", 0.5),
|
| 100 |
+
status=d.get("status", STATUS_PENDING),
|
| 101 |
+
outcome=d.get("outcome"),
|
| 102 |
+
verification_notes=d.get("verification_notes"),
|
| 103 |
+
verified_at=d.get("verified_at"),
|
| 104 |
+
related_memory_ids=d.get("related_memory_ids", []),
|
| 105 |
+
tags=d.get("tags", []),
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ------------------------------------------------------------------ #
|
| 110 |
+
# PredictionStore #
|
| 111 |
+
# ------------------------------------------------------------------ #
|
| 112 |
+
|
| 113 |
+
class PredictionStore:
|
| 114 |
+
"""
|
| 115 |
+
In-memory store for PredictionRecords with lifecycle management.
|
| 116 |
+
|
| 117 |
+
For production use, wire to an engine so verified/falsified predictions
|
| 118 |
+
can update related MemoryNode synapses and generate LLM insights.
|
| 119 |
+
"""
|
| 120 |
+
|
| 121 |
+
def __init__(self, engine=None) -> None:
|
| 122 |
+
self.engine = engine
|
| 123 |
+
self._records: Dict[str, PredictionRecord] = {}
|
| 124 |
+
|
| 125 |
+
# ---- CRUD ---------------------------------------------------- #
|
| 126 |
+
|
| 127 |
+
def create(
|
| 128 |
+
self,
|
| 129 |
+
content: str,
|
| 130 |
+
confidence: float = 0.5,
|
| 131 |
+
deadline_days: Optional[float] = None,
|
| 132 |
+
deadline: Optional[datetime] = None,
|
| 133 |
+
related_memory_ids: Optional[List[str]] = None,
|
| 134 |
+
tags: Optional[List[str]] = None,
|
| 135 |
+
) -> str:
|
| 136 |
+
"""
|
| 137 |
+
Store a new prediction.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
content: The prediction statement.
|
| 141 |
+
confidence: Confidence at creation time [0, 1].
|
| 142 |
+
deadline_days: Days from now until deadline (alternative to deadline).
|
| 143 |
+
deadline: Explicit deadline datetime (overrides deadline_days).
|
| 144 |
+
related_memory_ids: IDs of memories this prediction relates to.
|
| 145 |
+
tags: Optional classification tags.
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
The prediction ID.
|
| 149 |
+
"""
|
| 150 |
+
deadline_iso: Optional[str] = None
|
| 151 |
+
if deadline is not None:
|
| 152 |
+
if deadline.tzinfo is None:
|
| 153 |
+
deadline = deadline.replace(tzinfo=timezone.utc)
|
| 154 |
+
deadline_iso = deadline.isoformat()
|
| 155 |
+
elif deadline_days is not None:
|
| 156 |
+
deadline_iso = (
|
| 157 |
+
datetime.now(timezone.utc) + timedelta(days=deadline_days)
|
| 158 |
+
).isoformat()
|
| 159 |
+
|
| 160 |
+
rec = PredictionRecord(
|
| 161 |
+
content=content,
|
| 162 |
+
confidence_at_creation=max(0.0, min(1.0, confidence)),
|
| 163 |
+
verification_deadline=deadline_iso,
|
| 164 |
+
related_memory_ids=related_memory_ids or [],
|
| 165 |
+
tags=tags or [],
|
| 166 |
+
)
|
| 167 |
+
self._records[rec.id] = rec
|
| 168 |
+
logger.info(
|
| 169 |
+
f"Prediction created: {rec.id} | confidence={confidence:.2f} | "
|
| 170 |
+
f"deadline={deadline_iso or 'none'}"
|
| 171 |
+
)
|
| 172 |
+
return rec.id
|
| 173 |
+
|
| 174 |
+
def get(self, pred_id: str) -> Optional[PredictionRecord]:
|
| 175 |
+
return self._records.get(pred_id)
|
| 176 |
+
|
| 177 |
+
def list_all(self, status: Optional[str] = None) -> List[PredictionRecord]:
|
| 178 |
+
"""Return all predictions, optionally filtered by status."""
|
| 179 |
+
recs = list(self._records.values())
|
| 180 |
+
if status:
|
| 181 |
+
recs = [r for r in recs if r.status == status]
|
| 182 |
+
return sorted(recs, key=lambda r: r.predicted_at, reverse=True)
|
| 183 |
+
|
| 184 |
+
def get_due(self) -> List[PredictionRecord]:
|
| 185 |
+
"""Return pending predictions that have passed their deadline."""
|
| 186 |
+
return [r for r in self._records.values() if r.is_expired()]
|
| 187 |
+
|
| 188 |
+
# ---- Lifecycle ----------------------------------------------- #
|
| 189 |
+
|
| 190 |
+
async def verify(
|
| 191 |
+
self,
|
| 192 |
+
pred_id: str,
|
| 193 |
+
success: bool,
|
| 194 |
+
notes: Optional[str] = None,
|
| 195 |
+
) -> Optional[PredictionRecord]:
|
| 196 |
+
"""
|
| 197 |
+
Verify or falsify a prediction.
|
| 198 |
+
|
| 199 |
+
Side effects:
|
| 200 |
+
- Verified: strengthens related memories via synaptic binding
|
| 201 |
+
- Falsified: reduces confidence on related memories + lesson learned
|
| 202 |
+
"""
|
| 203 |
+
rec = self._records.get(pred_id)
|
| 204 |
+
if rec is None:
|
| 205 |
+
logger.warning(f"PredictionStore.verify: unknown id {pred_id!r}")
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
rec.status = STATUS_VERIFIED if success else STATUS_FALSIFIED
|
| 209 |
+
rec.outcome = success
|
| 210 |
+
rec.verification_notes = notes
|
| 211 |
+
rec.verified_at = datetime.now(timezone.utc).isoformat()
|
| 212 |
+
|
| 213 |
+
logger.info(
|
| 214 |
+
f"Prediction {pred_id} → {rec.status} | notes={notes or '—'}"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
if self.engine is not None:
|
| 218 |
+
if success:
|
| 219 |
+
await self._strengthen_related(rec)
|
| 220 |
+
else:
|
| 221 |
+
await self._weaken_related(rec)
|
| 222 |
+
await self._generate_lesson(rec)
|
| 223 |
+
|
| 224 |
+
return rec
|
| 225 |
+
|
| 226 |
+
async def expire_due(self) -> List[PredictionRecord]:
|
| 227 |
+
"""Mark overdue pending predictions as expired. Returns expired list."""
|
| 228 |
+
due = self.get_due()
|
| 229 |
+
for rec in due:
|
| 230 |
+
rec.status = STATUS_EXPIRED
|
| 231 |
+
logger.info(f"Prediction {rec.id} expired (deadline passed).")
|
| 232 |
+
return due
|
| 233 |
+
|
| 234 |
+
# ---- Engine integration -------------------------------------- #
|
| 235 |
+
|
| 236 |
+
async def _strengthen_related(self, rec: PredictionRecord) -> None:
|
| 237 |
+
"""Verified prediction → strengthen synapses on related memories."""
|
| 238 |
+
for mem_id in rec.related_memory_ids:
|
| 239 |
+
try:
|
| 240 |
+
node = await self.engine.get_memory(mem_id)
|
| 241 |
+
if node:
|
| 242 |
+
si = getattr(self.engine, "synapse_index", None)
|
| 243 |
+
if si:
|
| 244 |
+
si.add_or_strengthen(rec.id, mem_id, delta=0.15)
|
| 245 |
+
logger.debug(f"Prediction {rec.id}: strengthened memory {mem_id[:8]}")
|
| 246 |
+
except Exception as exc:
|
| 247 |
+
logger.debug(f"Prediction strengthen failed for {mem_id}: {exc}")
|
| 248 |
+
|
| 249 |
+
async def _weaken_related(self, rec: PredictionRecord) -> None:
|
| 250 |
+
"""Falsified prediction → reduce confidence on related memories."""
|
| 251 |
+
for mem_id in rec.related_memory_ids:
|
| 252 |
+
try:
|
| 253 |
+
node = await self.engine.get_memory(mem_id)
|
| 254 |
+
if node:
|
| 255 |
+
from .bayesian_ltp import get_bayesian_updater
|
| 256 |
+
updater = get_bayesian_updater()
|
| 257 |
+
updater.observe_node_retrieval(node, helpful=False, eig_signal=0.5)
|
| 258 |
+
logger.debug(f"Prediction {rec.id}: weakened memory {mem_id[:8]}")
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
logger.debug(f"Prediction weaken failed for {mem_id}: {exc}")
|
| 261 |
+
|
| 262 |
+
async def _generate_lesson(self, rec: PredictionRecord) -> None:
|
| 263 |
+
"""Ask SubconsciousAI to synthesize a 'lesson learned' for a falsified prediction."""
|
| 264 |
+
try:
|
| 265 |
+
subcon = getattr(self.engine, "subconscious_ai", None)
|
| 266 |
+
if subcon is None:
|
| 267 |
+
return
|
| 268 |
+
prompt = (
|
| 269 |
+
f"The following prediction was FALSIFIED: '{rec.content}'. "
|
| 270 |
+
f"Confidence at creation: {rec.confidence_at_creation:.2f}. "
|
| 271 |
+
f"Notes: {rec.verification_notes or 'none'}. "
|
| 272 |
+
"In 1-2 sentences, what is the key lesson learned from this failure?"
|
| 273 |
+
)
|
| 274 |
+
lesson = await subcon.generate(prompt, max_tokens=128)
|
| 275 |
+
# Store the lesson as a new memory
|
| 276 |
+
await self.engine.store(
|
| 277 |
+
lesson.strip(),
|
| 278 |
+
metadata={
|
| 279 |
+
"type": "lesson_learned",
|
| 280 |
+
"source_prediction_id": rec.id,
|
| 281 |
+
"domain": "strategic",
|
| 282 |
+
}
|
| 283 |
+
)
|
| 284 |
+
logger.info(f"Lesson learned generated for falsified prediction {rec.id}")
|
| 285 |
+
except Exception as exc:
|
| 286 |
+
logger.debug(f"Lesson generation failed: {exc}")
|
| 287 |
+
|
| 288 |
+
# ---- Serialization ------------------------------------------- #
|
| 289 |
+
|
| 290 |
+
def to_list(self) -> List[Dict[str, Any]]:
|
| 291 |
+
return [r.to_dict() for r in self._records.values()]
|
| 292 |
+
|
| 293 |
+
def __len__(self) -> int:
|
| 294 |
+
return len(self._records)
|
src/mnemocore/core/preference_store.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from loguru import logger
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from .binary_hdv import BinaryHDV, majority_bundle
|
| 6 |
+
from .config import PreferenceConfig
|
| 7 |
+
|
| 8 |
+
class PreferenceStore:
|
| 9 |
+
"""
|
| 10 |
+
Phase 12.3: Preference Learning
|
| 11 |
+
Maintains a persistent vector representing implicit user preferences
|
| 12 |
+
based on logged decisions or positive feedback.
|
| 13 |
+
"""
|
| 14 |
+
def __init__(self, config: PreferenceConfig, dimension: int):
|
| 15 |
+
self.config = config
|
| 16 |
+
self.dimension = dimension
|
| 17 |
+
# The preference vector represents the "ideal" or "preferred" region
|
| 18 |
+
self.preference_vector: Optional[BinaryHDV] = None
|
| 19 |
+
self.decision_history: List[BinaryHDV] = []
|
| 20 |
+
|
| 21 |
+
def log_decision(self, context_hdv: BinaryHDV, outcome: float) -> None:
|
| 22 |
+
"""
|
| 23 |
+
Logs a decision or feedback event.
|
| 24 |
+
`outcome`: positive value (e.g. 1.0) for good feedback, negative (-1.0) for bad feedback.
|
| 25 |
+
If outcome is positive, the preference vector shifts slightly toward `context_hdv`.
|
| 26 |
+
If outcome is negative, the preference vector shifts away (invert context_hdv).
|
| 27 |
+
"""
|
| 28 |
+
if not self.config.enabled:
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
target_hdv = context_hdv if outcome >= 0 else context_hdv.invert()
|
| 32 |
+
self.decision_history.append(target_hdv)
|
| 33 |
+
|
| 34 |
+
# Maintain history size
|
| 35 |
+
if len(self.decision_history) > self.config.history_limit:
|
| 36 |
+
self.decision_history.pop(0)
|
| 37 |
+
|
| 38 |
+
# Update preference vector via majority bundling of recent positive shifts
|
| 39 |
+
self.preference_vector = majority_bundle(self.decision_history)
|
| 40 |
+
logger.debug(f"Logged decision (outcome={outcome:.2f}). Preference vector updated.")
|
| 41 |
+
|
| 42 |
+
def bias_score(self, target_hdv: BinaryHDV, base_score: float) -> float:
|
| 43 |
+
"""
|
| 44 |
+
Biases a retrieval score using the preference vector if one exists.
|
| 45 |
+
Formula: new_score = base_score + (learning_rate * preference_similarity)
|
| 46 |
+
"""
|
| 47 |
+
if not self.config.enabled or self.preference_vector is None:
|
| 48 |
+
return base_score
|
| 49 |
+
|
| 50 |
+
pref_sim = self.preference_vector.similarity(target_hdv)
|
| 51 |
+
|
| 52 |
+
# We apply the learning_rate as the max potential boost an item can get from mapping exactly to preferences
|
| 53 |
+
return base_score + (self.config.learning_rate * pref_sim)
|
src/mnemocore/core/procedural_store.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Procedural Store Service
|
| 3 |
+
========================
|
| 4 |
+
Manages actionable skills, procedural routines, and agentic workflows.
|
| 5 |
+
Validates triggering patterns and tracks execution success rates dynamically.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Dict, List, Optional
|
| 9 |
+
import threading
|
| 10 |
+
import logging
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
from .memory_model import Procedure
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ProceduralStoreService:
|
| 19 |
+
def __init__(self):
|
| 20 |
+
# Local dictionary for Procedures mapping by ID
|
| 21 |
+
# Would typically be serialized to SQLite, JSON, or Qdrant for retrieval.
|
| 22 |
+
self._procedures: Dict[str, Procedure] = {}
|
| 23 |
+
self._lock = threading.RLock()
|
| 24 |
+
|
| 25 |
+
def store_procedure(self, proc: Procedure) -> None:
|
| 26 |
+
"""Save a new or refined procedure into memory."""
|
| 27 |
+
with self._lock:
|
| 28 |
+
proc.updated_at = datetime.utcnow()
|
| 29 |
+
self._procedures[proc.id] = proc
|
| 30 |
+
logger.info(f"Stored procedure {proc.id} ('{proc.name}')")
|
| 31 |
+
|
| 32 |
+
def get_procedure(self, proc_id: str) -> Optional[Procedure]:
|
| 33 |
+
"""Retrieve a procedure by exact ID."""
|
| 34 |
+
with self._lock:
|
| 35 |
+
return self._procedures.get(proc_id)
|
| 36 |
+
|
| 37 |
+
def find_applicable_procedures(
|
| 38 |
+
self, query: str, agent_id: Optional[str] = None, top_k: int = 5
|
| 39 |
+
) -> List[Procedure]:
|
| 40 |
+
"""
|
| 41 |
+
Find procedures whose trigger tags or trigger pattern matches the user intent.
|
| 42 |
+
Simple local text-matching for the prototype layout.
|
| 43 |
+
"""
|
| 44 |
+
with self._lock:
|
| 45 |
+
q_lower = query.lower()
|
| 46 |
+
results = []
|
| 47 |
+
for proc in self._procedures.values():
|
| 48 |
+
# Prefer procedures meant directly for this agent, or system globals
|
| 49 |
+
if proc.created_by_agent is not None and agent_id and proc.created_by_agent != agent_id:
|
| 50 |
+
continue
|
| 51 |
+
|
| 52 |
+
if proc.trigger_pattern.lower() in q_lower or any(t.lower() in q_lower for t in proc.tags):
|
| 53 |
+
results.append(proc)
|
| 54 |
+
|
| 55 |
+
# Sort by reliability and usage history to surface most competent tools
|
| 56 |
+
results.sort(key=lambda p: (p.reliability, p.success_count), reverse=True)
|
| 57 |
+
return results[:top_k]
|
| 58 |
+
|
| 59 |
+
def record_procedure_outcome(self, proc_id: str, success: bool) -> None:
|
| 60 |
+
"""Update procedure success metrics, affecting overall reliability."""
|
| 61 |
+
with self._lock:
|
| 62 |
+
proc = self._procedures.get(proc_id)
|
| 63 |
+
if not proc:
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
proc.updated_at = datetime.utcnow()
|
| 67 |
+
if success:
|
| 68 |
+
proc.success_count += 1
|
| 69 |
+
# Increase reliability slightly on success
|
| 70 |
+
proc.reliability = min(1.0, proc.reliability + 0.05)
|
| 71 |
+
else:
|
| 72 |
+
proc.failure_count += 1
|
| 73 |
+
# Decrease reliability heavily on failure
|
| 74 |
+
proc.reliability = max(0.0, proc.reliability - 0.1)
|
| 75 |
+
|
| 76 |
+
logger.debug(f"Procedure {proc_id} outcome recorded: success={success}, new rel={proc.reliability:.2f}")
|
| 77 |
+
|
src/mnemocore/core/provenance.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Provenance Tracking Module (Phase 5.0)
|
| 3 |
+
=======================================
|
| 4 |
+
W3C PROV-inspired source tracking for MnemoCore memories.
|
| 5 |
+
|
| 6 |
+
Tracks the full lifecycle of every MemoryNode:
|
| 7 |
+
- origin: where/how the memory was created
|
| 8 |
+
- lineage: ordered list of transformation events
|
| 9 |
+
- version: incremented on each significant mutation
|
| 10 |
+
|
| 11 |
+
This is the foundation for:
|
| 12 |
+
- Trust & audit trails (AI Governance)
|
| 13 |
+
- Contradiction resolution
|
| 14 |
+
- Memory-as-a-Service lineage API
|
| 15 |
+
- Source reliability scoring
|
| 16 |
+
|
| 17 |
+
Public API:
|
| 18 |
+
record = ProvenanceRecord.new(origin_type="observation", agent_id="agent-001")
|
| 19 |
+
record.add_event("consolidated", source_memories=["mem_a", "mem_b"])
|
| 20 |
+
serialized = record.to_dict()
|
| 21 |
+
restored = ProvenanceRecord.from_dict(serialized)
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
+
from datetime import datetime, timezone
|
| 28 |
+
from typing import Any, Dict, List, Optional
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ------------------------------------------------------------------ #
|
| 32 |
+
# Origin types #
|
| 33 |
+
# ------------------------------------------------------------------ #
|
| 34 |
+
|
| 35 |
+
ORIGIN_TYPES = {
|
| 36 |
+
"observation", # Direct input from agent or user
|
| 37 |
+
"inference", # Derived/reasoned by LLM or engine
|
| 38 |
+
"dream", # Produced by SubconsciousAI dream cycle
|
| 39 |
+
"consolidation", # Result of SemanticConsolidation merge
|
| 40 |
+
"external_sync", # Fetched from external source (RSS, API, etc.)
|
| 41 |
+
"user_correction", # Explicit user override
|
| 42 |
+
"prediction", # Stored as a future prediction
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ------------------------------------------------------------------ #
|
| 47 |
+
# Lineage event #
|
| 48 |
+
# ------------------------------------------------------------------ #
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class LineageEvent:
|
| 52 |
+
"""
|
| 53 |
+
A single step in a memory's transformation history.
|
| 54 |
+
|
| 55 |
+
Examples:
|
| 56 |
+
created – initial storage
|
| 57 |
+
accessed – retrieved by a query
|
| 58 |
+
consolidated – merged into or from a proto-memory cluster
|
| 59 |
+
verified – reliability confirmed externally
|
| 60 |
+
contradicted – flagged as contradicting another memory
|
| 61 |
+
updated – content or metadata modified
|
| 62 |
+
archived – moved to COLD tier
|
| 63 |
+
expired – TTL reached or evicted
|
| 64 |
+
"""
|
| 65 |
+
event: str
|
| 66 |
+
timestamp: str # ISO 8601
|
| 67 |
+
actor: Optional[str] = None # agent_id, "system", "user", etc.
|
| 68 |
+
source_memories: List[str] = field(default_factory=list) # for consolidation
|
| 69 |
+
outcome: Optional[bool] = None # for verification events
|
| 70 |
+
notes: Optional[str] = None
|
| 71 |
+
extra: Dict[str, Any] = field(default_factory=dict)
|
| 72 |
+
|
| 73 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 74 |
+
d: Dict[str, Any] = {
|
| 75 |
+
"event": self.event,
|
| 76 |
+
"timestamp": self.timestamp,
|
| 77 |
+
}
|
| 78 |
+
if self.actor is not None:
|
| 79 |
+
d["actor"] = self.actor
|
| 80 |
+
if self.source_memories:
|
| 81 |
+
d["source_memories"] = self.source_memories
|
| 82 |
+
if self.outcome is not None:
|
| 83 |
+
d["outcome"] = self.outcome
|
| 84 |
+
if self.notes:
|
| 85 |
+
d["notes"] = self.notes
|
| 86 |
+
if self.extra:
|
| 87 |
+
d["extra"] = self.extra
|
| 88 |
+
return d
|
| 89 |
+
|
| 90 |
+
@classmethod
|
| 91 |
+
def from_dict(cls, d: Dict[str, Any]) -> "LineageEvent":
|
| 92 |
+
return cls(
|
| 93 |
+
event=d["event"],
|
| 94 |
+
timestamp=d["timestamp"],
|
| 95 |
+
actor=d.get("actor"),
|
| 96 |
+
source_memories=d.get("source_memories", []),
|
| 97 |
+
outcome=d.get("outcome"),
|
| 98 |
+
notes=d.get("notes"),
|
| 99 |
+
extra=d.get("extra", {}),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ------------------------------------------------------------------ #
|
| 104 |
+
# Origin #
|
| 105 |
+
# ------------------------------------------------------------------ #
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class ProvenanceOrigin:
|
| 109 |
+
"""Where/how a memory was first created."""
|
| 110 |
+
|
| 111 |
+
type: str # One of ORIGIN_TYPES
|
| 112 |
+
agent_id: Optional[str] = None
|
| 113 |
+
session_id: Optional[str] = None
|
| 114 |
+
source_url: Optional[str] = None # For external_sync
|
| 115 |
+
timestamp: str = field(
|
| 116 |
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 120 |
+
d: Dict[str, Any] = {
|
| 121 |
+
"type": self.type,
|
| 122 |
+
"timestamp": self.timestamp,
|
| 123 |
+
}
|
| 124 |
+
if self.agent_id:
|
| 125 |
+
d["agent_id"] = self.agent_id
|
| 126 |
+
if self.session_id:
|
| 127 |
+
d["session_id"] = self.session_id
|
| 128 |
+
if self.source_url:
|
| 129 |
+
d["source_url"] = self.source_url
|
| 130 |
+
return d
|
| 131 |
+
|
| 132 |
+
@classmethod
|
| 133 |
+
def from_dict(cls, d: Dict[str, Any]) -> "ProvenanceOrigin":
|
| 134 |
+
return cls(
|
| 135 |
+
type=d.get("type", "observation"),
|
| 136 |
+
agent_id=d.get("agent_id"),
|
| 137 |
+
session_id=d.get("session_id"),
|
| 138 |
+
source_url=d.get("source_url"),
|
| 139 |
+
timestamp=d.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ------------------------------------------------------------------ #
|
| 144 |
+
# ProvenanceRecord — the full provenance object on a MemoryNode #
|
| 145 |
+
# ------------------------------------------------------------------ #
|
| 146 |
+
|
| 147 |
+
@dataclass
|
| 148 |
+
class ProvenanceRecord:
|
| 149 |
+
"""
|
| 150 |
+
Full provenance object attached to a MemoryNode.
|
| 151 |
+
|
| 152 |
+
Designed to be serialized into node.metadata["provenance"] for
|
| 153 |
+
backward compatibility with existing storage layers.
|
| 154 |
+
"""
|
| 155 |
+
|
| 156 |
+
origin: ProvenanceOrigin
|
| 157 |
+
lineage: List[LineageEvent] = field(default_factory=list)
|
| 158 |
+
version: int = 1
|
| 159 |
+
confidence_source: str = "bayesian_ltp" # How the confidence score is derived
|
| 160 |
+
|
| 161 |
+
# ---- Factory methods ------------------------------------------ #
|
| 162 |
+
|
| 163 |
+
@classmethod
|
| 164 |
+
def new(
|
| 165 |
+
cls,
|
| 166 |
+
origin_type: str = "observation",
|
| 167 |
+
agent_id: Optional[str] = None,
|
| 168 |
+
session_id: Optional[str] = None,
|
| 169 |
+
source_url: Optional[str] = None,
|
| 170 |
+
actor: Optional[str] = None,
|
| 171 |
+
) -> "ProvenanceRecord":
|
| 172 |
+
"""Create a fresh ProvenanceRecord and log the 'created' event."""
|
| 173 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 174 |
+
origin = ProvenanceOrigin(
|
| 175 |
+
type=origin_type if origin_type in ORIGIN_TYPES else "observation",
|
| 176 |
+
agent_id=agent_id,
|
| 177 |
+
session_id=session_id,
|
| 178 |
+
source_url=source_url,
|
| 179 |
+
timestamp=now,
|
| 180 |
+
)
|
| 181 |
+
record = cls(origin=origin)
|
| 182 |
+
record.add_event(
|
| 183 |
+
event="created",
|
| 184 |
+
actor=actor or agent_id or "system",
|
| 185 |
+
)
|
| 186 |
+
return record
|
| 187 |
+
|
| 188 |
+
# ---- Mutation ------------------------------------------------- #
|
| 189 |
+
|
| 190 |
+
def add_event(
|
| 191 |
+
self,
|
| 192 |
+
event: str,
|
| 193 |
+
actor: Optional[str] = None,
|
| 194 |
+
source_memories: Optional[List[str]] = None,
|
| 195 |
+
outcome: Optional[bool] = None,
|
| 196 |
+
notes: Optional[str] = None,
|
| 197 |
+
**extra: Any,
|
| 198 |
+
) -> "ProvenanceRecord":
|
| 199 |
+
"""Append a new lineage event and bump the version counter."""
|
| 200 |
+
evt = LineageEvent(
|
| 201 |
+
event=event,
|
| 202 |
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 203 |
+
actor=actor,
|
| 204 |
+
source_memories=source_memories or [],
|
| 205 |
+
outcome=outcome,
|
| 206 |
+
notes=notes,
|
| 207 |
+
extra=extra,
|
| 208 |
+
)
|
| 209 |
+
self.lineage.append(evt)
|
| 210 |
+
self.version += 1
|
| 211 |
+
return self
|
| 212 |
+
|
| 213 |
+
def mark_consolidated(
|
| 214 |
+
self,
|
| 215 |
+
source_memory_ids: List[str],
|
| 216 |
+
actor: str = "consolidation_worker",
|
| 217 |
+
) -> "ProvenanceRecord":
|
| 218 |
+
"""Convenience wrapper for consolidation events."""
|
| 219 |
+
return self.add_event(
|
| 220 |
+
event="consolidated",
|
| 221 |
+
actor=actor,
|
| 222 |
+
source_memories=source_memory_ids,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
def mark_verified(
|
| 226 |
+
self,
|
| 227 |
+
success: bool,
|
| 228 |
+
actor: str = "system",
|
| 229 |
+
notes: Optional[str] = None,
|
| 230 |
+
) -> "ProvenanceRecord":
|
| 231 |
+
"""Record a verification outcome."""
|
| 232 |
+
return self.add_event(
|
| 233 |
+
event="verified",
|
| 234 |
+
actor=actor,
|
| 235 |
+
outcome=success,
|
| 236 |
+
notes=notes,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
def mark_contradicted(
|
| 240 |
+
self,
|
| 241 |
+
contradiction_group_id: str,
|
| 242 |
+
actor: str = "contradiction_detector",
|
| 243 |
+
) -> "ProvenanceRecord":
|
| 244 |
+
"""Flag this memory as contradicted."""
|
| 245 |
+
return self.add_event(
|
| 246 |
+
event="contradicted",
|
| 247 |
+
actor=actor,
|
| 248 |
+
contradiction_group_id=contradiction_group_id,
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
# ---- Serialization -------------------------------------------- #
|
| 252 |
+
|
| 253 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 254 |
+
return {
|
| 255 |
+
"origin": self.origin.to_dict(),
|
| 256 |
+
"lineage": [e.to_dict() for e in self.lineage],
|
| 257 |
+
"version": self.version,
|
| 258 |
+
"confidence_source": self.confidence_source,
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
@classmethod
|
| 262 |
+
def from_dict(cls, d: Dict[str, Any]) -> "ProvenanceRecord":
|
| 263 |
+
return cls(
|
| 264 |
+
origin=ProvenanceOrigin.from_dict(d.get("origin", {"type": "observation"})),
|
| 265 |
+
lineage=[LineageEvent.from_dict(e) for e in d.get("lineage", [])],
|
| 266 |
+
version=d.get("version", 1),
|
| 267 |
+
confidence_source=d.get("confidence_source", "bayesian_ltp"),
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
# ---- Helpers -------------------------------------------------- #
|
| 271 |
+
|
| 272 |
+
@property
|
| 273 |
+
def created_at(self) -> str:
|
| 274 |
+
"""ISO timestamp of the creation event."""
|
| 275 |
+
for event in self.lineage:
|
| 276 |
+
if event.event == "created":
|
| 277 |
+
return event.timestamp
|
| 278 |
+
return self.origin.timestamp
|
| 279 |
+
|
| 280 |
+
@property
|
| 281 |
+
def last_event(self) -> Optional[LineageEvent]:
|
| 282 |
+
"""Most recent lineage event."""
|
| 283 |
+
return self.lineage[-1] if self.lineage else None
|
| 284 |
+
|
| 285 |
+
def is_contradicted(self) -> bool:
|
| 286 |
+
return any(e.event == "contradicted" for e in self.lineage)
|
| 287 |
+
|
| 288 |
+
def is_verified(self) -> bool:
|
| 289 |
+
return any(
|
| 290 |
+
e.event == "verified" and e.outcome is True for e in self.lineage
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
def __repr__(self) -> str:
|
| 294 |
+
return (
|
| 295 |
+
f"ProvenanceRecord(origin_type={self.origin.type!r}, "
|
| 296 |
+
f"version={self.version}, events={len(self.lineage)})"
|
| 297 |
+
)
|
src/mnemocore/core/pulse.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pulse Heartbeat Loop
|
| 3 |
+
====================
|
| 4 |
+
The central background orchestrator that binds together the AGI cognitive cycles.
|
| 5 |
+
Triggers working memory maintenance, episodic sequence linking, gap tracking, and subconscious inferences.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from enum import Enum
|
| 10 |
+
import threading
|
| 11 |
+
import asyncio
|
| 12 |
+
import logging
|
| 13 |
+
import traceback
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class PulseTick(Enum):
|
| 20 |
+
WM_MAINTENANCE = "wm_maintenance"
|
| 21 |
+
EPISODIC_CHAINING = "episodic_chaining"
|
| 22 |
+
SEMANTIC_REFRESH = "semantic_refresh"
|
| 23 |
+
GAP_DETECTION = "gap_detection"
|
| 24 |
+
INSIGHT_GENERATION = "insight_generation"
|
| 25 |
+
PROCEDURE_REFINEMENT = "procedure_refinement"
|
| 26 |
+
META_SELF_REFLECTION = "meta_self_reflection"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class PulseLoop:
|
| 30 |
+
def __init__(self, container, config):
|
| 31 |
+
"""
|
| 32 |
+
Args:
|
| 33 |
+
container: The fully built DI Container containing all memory sub-services.
|
| 34 |
+
config: Specifically the `config.pulse` section settings.
|
| 35 |
+
"""
|
| 36 |
+
self.container = container
|
| 37 |
+
self.config = config
|
| 38 |
+
self._running = False
|
| 39 |
+
self._task: Optional[asyncio.Task] = None
|
| 40 |
+
|
| 41 |
+
async def start(self) -> None:
|
| 42 |
+
"""Begin the background pulse orchestrator."""
|
| 43 |
+
if not getattr(self.config, "enabled", False):
|
| 44 |
+
logger.info("Pulse loop is disabled via configuration.")
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
self._running = True
|
| 48 |
+
interval = getattr(self.config, "interval_seconds", 30)
|
| 49 |
+
logger.info(f"Starting AGI Pulse Loop (interval={interval}s).")
|
| 50 |
+
|
| 51 |
+
while self._running:
|
| 52 |
+
start_time = datetime.utcnow()
|
| 53 |
+
try:
|
| 54 |
+
await self.tick()
|
| 55 |
+
except asyncio.CancelledError:
|
| 56 |
+
break
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.error(f"Error during Pulse tick: {e}", exc_info=True)
|
| 59 |
+
|
| 60 |
+
elapsed = (datetime.utcnow() - start_time).total_seconds()
|
| 61 |
+
sleep_time = max(0.1, interval - elapsed)
|
| 62 |
+
await asyncio.sleep(sleep_time)
|
| 63 |
+
|
| 64 |
+
def stop(self) -> None:
|
| 65 |
+
"""Gracefully interrupt and unbind the Pulse loop."""
|
| 66 |
+
self._running = False
|
| 67 |
+
if self._task and not self._task.done():
|
| 68 |
+
self._task.cancel()
|
| 69 |
+
logger.info("AGI Pulse Loop stopped.")
|
| 70 |
+
|
| 71 |
+
async def tick(self) -> None:
|
| 72 |
+
"""Execute a full iteration across the cognitive architecture planes."""
|
| 73 |
+
await self._wm_maintenance()
|
| 74 |
+
await self._episodic_chaining()
|
| 75 |
+
await self._semantic_refresh()
|
| 76 |
+
await self._gap_detection()
|
| 77 |
+
await self._insight_generation()
|
| 78 |
+
await self._procedure_refinement()
|
| 79 |
+
await self._meta_self_reflection()
|
| 80 |
+
|
| 81 |
+
async def _wm_maintenance(self) -> None:
|
| 82 |
+
"""Prune overloaded short-term buffers and cull expired items."""
|
| 83 |
+
if hasattr(self.container, "working_memory") and self.container.working_memory:
|
| 84 |
+
self.container.working_memory.prune_all()
|
| 85 |
+
logger.debug(f"Pulse: [{PulseTick.WM_MAINTENANCE.value}] Executed.")
|
| 86 |
+
|
| 87 |
+
async def _episodic_chaining(self) -> None:
|
| 88 |
+
"""Retroactively verify event streams and apply temporal links between episodic contexts."""
|
| 89 |
+
logger.debug(f"Pulse: [{PulseTick.EPISODIC_CHAINING.value}] Stubbed.")
|
| 90 |
+
|
| 91 |
+
async def _semantic_refresh(self) -> None:
|
| 92 |
+
"""Prompt Qdrant abstractions or run `semantic_consolidation` loops over episodic data."""
|
| 93 |
+
logger.debug(f"Pulse: [{PulseTick.SEMANTIC_REFRESH.value}] Stubbed.")
|
| 94 |
+
|
| 95 |
+
async def _gap_detection(self) -> None:
|
| 96 |
+
"""Unearth missing knowledge vectors (GapDetector integration)."""
|
| 97 |
+
logger.debug(f"Pulse: [{PulseTick.GAP_DETECTION.value}] Stubbed.")
|
| 98 |
+
|
| 99 |
+
async def _insight_generation(self) -> None:
|
| 100 |
+
"""Forward memory patterns to LLM for spontaneous inference generation."""
|
| 101 |
+
logger.debug(f"Pulse: [{PulseTick.INSIGHT_GENERATION.value}] Stubbed.")
|
| 102 |
+
|
| 103 |
+
async def _procedure_refinement(self) -> None:
|
| 104 |
+
"""Modify procedure reliabilities directly depending on episode occurrences."""
|
| 105 |
+
logger.debug(f"Pulse: [{PulseTick.PROCEDURE_REFINEMENT.value}] Stubbed.")
|
| 106 |
+
|
| 107 |
+
async def _meta_self_reflection(self) -> None:
|
| 108 |
+
"""Collate macro anomalies and submit SelfImprovementProposals."""
|
| 109 |
+
logger.debug(f"Pulse: [{PulseTick.META_SELF_REFLECTION.value}] Stubbed.")
|
| 110 |
+
|
src/mnemocore/core/qdrant_store.py
CHANGED
|
@@ -6,7 +6,7 @@ Provides async access to Qdrant for vector storage and similarity search.
|
|
| 6 |
Phase 4.3: Temporal Recall - supports time-based filtering and indexing.
|
| 7 |
"""
|
| 8 |
|
| 9 |
-
from typing import List, Any, Optional, Tuple
|
| 10 |
from datetime import datetime
|
| 11 |
import asyncio
|
| 12 |
|
|
@@ -100,7 +100,7 @@ class QdrantStore:
|
|
| 100 |
collection_name=self.collection_hot,
|
| 101 |
vectors_config=models.VectorParams(
|
| 102 |
size=self.dim,
|
| 103 |
-
distance=models.Distance.
|
| 104 |
on_disk=False
|
| 105 |
),
|
| 106 |
quantization_config=quantization_config,
|
|
@@ -118,7 +118,7 @@ class QdrantStore:
|
|
| 118 |
collection_name=self.collection_warm,
|
| 119 |
vectors_config=models.VectorParams(
|
| 120 |
size=self.dim,
|
| 121 |
-
distance=models.Distance.
|
| 122 |
on_disk=True
|
| 123 |
),
|
| 124 |
quantization_config=quantization_config,
|
|
@@ -169,6 +169,7 @@ class QdrantStore:
|
|
| 169 |
limit: int = 5,
|
| 170 |
score_threshold: float = 0.0,
|
| 171 |
time_range: Optional[Tuple[datetime, datetime]] = None,
|
|
|
|
| 172 |
) -> List[models.ScoredPoint]:
|
| 173 |
"""
|
| 174 |
Async semantic search.
|
|
@@ -189,30 +190,51 @@ class QdrantStore:
|
|
| 189 |
as search failures should not crash the calling code.
|
| 190 |
"""
|
| 191 |
try:
|
| 192 |
-
|
| 193 |
-
query_filter = None
|
| 194 |
if time_range:
|
| 195 |
start_ts = int(time_range[0].timestamp())
|
| 196 |
end_ts = int(time_range[1].timestamp())
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
lte=end_ts,
|
| 204 |
-
),
|
| 205 |
),
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
)
|
| 208 |
|
| 209 |
return await qdrant_breaker.call(
|
| 210 |
-
self.client.
|
| 211 |
collection_name=collection,
|
| 212 |
-
|
| 213 |
limit=limit,
|
| 214 |
score_threshold=score_threshold,
|
| 215 |
query_filter=query_filter,
|
|
|
|
| 216 |
)
|
| 217 |
except CircuitOpenError:
|
| 218 |
logger.warning(f"Qdrant search blocked for {collection}: circuit breaker open")
|
|
|
|
| 6 |
Phase 4.3: Temporal Recall - supports time-based filtering and indexing.
|
| 7 |
"""
|
| 8 |
|
| 9 |
+
from typing import List, Any, Optional, Tuple, Dict
|
| 10 |
from datetime import datetime
|
| 11 |
import asyncio
|
| 12 |
|
|
|
|
| 100 |
collection_name=self.collection_hot,
|
| 101 |
vectors_config=models.VectorParams(
|
| 102 |
size=self.dim,
|
| 103 |
+
distance=models.Distance.DOT,
|
| 104 |
on_disk=False
|
| 105 |
),
|
| 106 |
quantization_config=quantization_config,
|
|
|
|
| 118 |
collection_name=self.collection_warm,
|
| 119 |
vectors_config=models.VectorParams(
|
| 120 |
size=self.dim,
|
| 121 |
+
distance=models.Distance.DOT,
|
| 122 |
on_disk=True
|
| 123 |
),
|
| 124 |
quantization_config=quantization_config,
|
|
|
|
| 169 |
limit: int = 5,
|
| 170 |
score_threshold: float = 0.0,
|
| 171 |
time_range: Optional[Tuple[datetime, datetime]] = None,
|
| 172 |
+
metadata_filter: Optional[Dict[str, Any]] = None,
|
| 173 |
) -> List[models.ScoredPoint]:
|
| 174 |
"""
|
| 175 |
Async semantic search.
|
|
|
|
| 190 |
as search failures should not crash the calling code.
|
| 191 |
"""
|
| 192 |
try:
|
| 193 |
+
must_conditions = []
|
|
|
|
| 194 |
if time_range:
|
| 195 |
start_ts = int(time_range[0].timestamp())
|
| 196 |
end_ts = int(time_range[1].timestamp())
|
| 197 |
+
must_conditions.append(
|
| 198 |
+
models.FieldCondition(
|
| 199 |
+
key="unix_timestamp",
|
| 200 |
+
range=models.Range(
|
| 201 |
+
gte=start_ts,
|
| 202 |
+
lte=end_ts,
|
|
|
|
|
|
|
| 203 |
),
|
| 204 |
+
)
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
if metadata_filter:
|
| 208 |
+
for k, v in metadata_filter.items():
|
| 209 |
+
must_conditions.append(
|
| 210 |
+
models.FieldCondition(
|
| 211 |
+
key=k,
|
| 212 |
+
match=models.MatchValue(value=v)
|
| 213 |
+
)
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
if must_conditions:
|
| 217 |
+
query_filter = models.Filter(must=must_conditions)
|
| 218 |
+
|
| 219 |
+
# Support for Binary Quantization rescoring (BUG-04)
|
| 220 |
+
search_params = None
|
| 221 |
+
if self.binary_quantization:
|
| 222 |
+
search_params = models.SearchParams(
|
| 223 |
+
quantization=models.QuantizationSearchParams(
|
| 224 |
+
ignore=False,
|
| 225 |
+
rescore=True,
|
| 226 |
+
oversampling=2.0
|
| 227 |
+
)
|
| 228 |
)
|
| 229 |
|
| 230 |
return await qdrant_breaker.call(
|
| 231 |
+
self.client.query_points,
|
| 232 |
collection_name=collection,
|
| 233 |
+
query=query_vector,
|
| 234 |
limit=limit,
|
| 235 |
score_threshold=score_threshold,
|
| 236 |
query_filter=query_filter,
|
| 237 |
+
search_params=search_params,
|
| 238 |
)
|
| 239 |
except CircuitOpenError:
|
| 240 |
logger.warning(f"Qdrant search blocked for {collection}: circuit breaker open")
|
src/mnemocore/core/semantic_consolidation.py
CHANGED
|
@@ -37,6 +37,7 @@ from loguru import logger
|
|
| 37 |
from .binary_hdv import BinaryHDV, majority_bundle
|
| 38 |
from .config import get_config
|
| 39 |
from .node import MemoryNode
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
# ------------------------------------------------------------------ #
|
|
@@ -266,6 +267,18 @@ class SemanticConsolidationWorker:
|
|
| 266 |
medoid_node.metadata["proto_updated_at"] = datetime.now(timezone.utc).isoformat()
|
| 267 |
proto_count += 1
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
elapsed = time.monotonic() - t0
|
| 270 |
self.last_run = datetime.now(timezone.utc)
|
| 271 |
self.stats = {
|
|
|
|
| 37 |
from .binary_hdv import BinaryHDV, majority_bundle
|
| 38 |
from .config import get_config
|
| 39 |
from .node import MemoryNode
|
| 40 |
+
from .provenance import ProvenanceRecord
|
| 41 |
|
| 42 |
|
| 43 |
# ------------------------------------------------------------------ #
|
|
|
|
| 267 |
medoid_node.metadata["proto_updated_at"] = datetime.now(timezone.utc).isoformat()
|
| 268 |
proto_count += 1
|
| 269 |
|
| 270 |
+
# Phase 5.0: record consolidation in provenance lineage
|
| 271 |
+
source_ids = [n.id for n in member_nodes if n.id != medoid_node.id]
|
| 272 |
+
if medoid_node.provenance is None:
|
| 273 |
+
medoid_node.provenance = ProvenanceRecord.new(
|
| 274 |
+
origin_type="consolidation",
|
| 275 |
+
actor="consolidation_worker",
|
| 276 |
+
)
|
| 277 |
+
medoid_node.provenance.mark_consolidated(
|
| 278 |
+
source_memory_ids=source_ids,
|
| 279 |
+
actor="consolidation_worker",
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
elapsed = time.monotonic() - t0
|
| 283 |
self.last_run = datetime.now(timezone.utc)
|
| 284 |
self.stats = {
|