refactor: reduce MemoryService to the agentic surface (#485)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sairin1202
2026-07-15 22:56:34 +08:00
committed by GitHub
parent 505638905f
commit 089d551d20
234 changed files with 454 additions and 32402 deletions
-46
View File
@@ -1,46 +0,0 @@
---
name: memu
description: Give this agent persistent memory via memU. Use to memorize (save files/folders so context survives this session) when the user asks to remember or sync something, or after completing work worth keeping; and to retrieve (search memory from earlier sessions) when the user asks what is known about a person/project/topic or references context not in this conversation.
---
# memU: memorize and retrieve persistent memory
memU compiles sources into a persistent local store (`./data/memu.sqlite3` + a browsable `./data/memory/` markdown tree, relative to CWD). What one session memorizes, the next can retrieve — always run from the project root so every command hits the same store.
Use only the two commands below. Do not use the legacy `memu memorize` / `memu retrieve` commands.
Both directions need an API key: make sure `OPENAI_API_KEY` (or `MEMU_LLM_PROVIDER` + its matching key) is set, and tell the user if it is missing.
## Locate the CLI
Use the first available runner:
1. `memu` (installed via `pip install memu-py`)
2. `uvx --from memu-py memu`
3. `npx memu-cli`
## Memorize
```bash
memu memorize-workspace <folder>
```
- Incremental and safe to re-run: diffs against `<folder>/.memu_manifest.json`, so only added/modified files are processed and memory from deleted files is removed.
- Top-level directory decides the treatment: `chat/` → memory topics, `agent/` → skills, everything else → indexed workspace context (modality inferred per file from its extension).
- To memorize a single file, place (or copy) it into the workspace folder and sync — there is no separate single-file path.
Report the printed diff (added/modified/deleted) to the user; pass `--json` if you need to parse the result.
## Retrieve
```bash
memu retrieve-workspace "<query>"
```
Single-shot embedding search, no LLM calls. Returns JSON in three layers:
- `segments` — the matched slices, ranked by similarity (check `score`)
- `files` — the memory/skill documents they belong to (usually what you want)
- `resources` — matching raw sources, when summaries are not enough
If a query misses, retry with different phrasing or more specific terms. Low scores across the board usually mean nothing relevant is stored — say so rather than stretching weak matches. You can also read `./data/memory/MEMORY.md` / `SKILL.md` / `INDEX.md` directly for a browsable overview.
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: Sync dependencies
run: |
uv sync --frozen --extra document
uv sync --frozen
uv run pre-commit install
- name: Run style checks
+25 -113
View File
@@ -5,125 +5,46 @@ Operational guide for AI coding agents working in this repository.
## Mission
Ship small, verified feature and bugfix changes while preserving memU's current architecture.
Treat `docs/architecture.md` as the runtime source of truth and keep this file aligned with it.
Core invariants:
- `MemoryService` is the composition root for config, storage, LLM/VLM/embedding clients, workflows, interceptors, and memory file export.
- Public APIs should remain stable unless the requested change explicitly requires an API change.
- Major behavior runs through workflows: `memorize`, `retrieve_rag`, `retrieve_llm`, and CRUD/patch operations.
- Workflow steps must keep explicit `requires`/`produces` contracts and declared capability tags.
- `MemoryService` (`src/memu/app/service.py`) is the composition root: config, storage, and the embedding client pool. Its public surface is exactly the three `AgenticMixin` entry points — `list_all_recall_files`, `progressive_retrieve`, `commit_results`.
- memU is embedding-only. No LLM/chat call happens anywhere in the service; do not add one.
- Storage is pluggable across `inmemory`, `sqlite`, and `postgres`; repository contract changes require backend parity.
- Scope data comes from `UserConfig.model`; validate `where` filters and do not bypass scope filtering.
- LLM routing is profile-based across chat, embedding, VLM, and custom profiles.
- The exported memory workspace should stay human-readable and agent-traversable: `INDEX.md`, `MEMORY.md`, `memory/`, `resource/`, and `skill/`.
- Scope data comes from `UserConfig.model`; validate `where` filters (`_normalize_where`) and do not bypass scope filtering.
- Retrieval config lives in `ProgressiveRetrieveConfig` (`file` + `resource` layers); embedding profiles in `EmbeddingProfilesConfig`.
## First Move
## Layer Map
Before editing, classify the request by the layer it touches:
- Service/runtime wiring: `src/memu/app/service.py`
- Memorize pipeline: `src/memu/app/memorize.py`
- Retrieve pipelines: `src/memu/app/retrieve.py`
- CRUD/patch behavior: `src/memu/app/crud.py`
- Service + three entry points: `src/memu/app/service.py`, `src/memu/app/agentic.py`
- Config models/defaults: `src/memu/app/settings.py`
- Workflow engine and pipeline mutation: `src/memu/workflow/*`
- Memory file export/synthesis: `src/memu/app/memory_files.py`, `src/memu/memory_fs/*`, `src/memu/prompts/memory_fs*`
- Storage protocols/factory: `src/memu/database/interfaces.py`, `src/memu/database/factory.py`
- In-memory backend: `src/memu/database/inmemory/*`
- SQLite backend: `src/memu/database/sqlite/*`
- Postgres backend: `src/memu/database/postgres/*`
- Backends: `src/memu/database/{inmemory,sqlite,postgres}/*`
- Vector math/ranking: `src/memu/vector.py`
- LLM clients, wrappers, interceptors: `src/memu/llm/*`
- Embedding clients: `src/memu/embedding/*`
- Vision clients: `src/memu/vlm/*`
- Integrations/client surfaces: `src/memu/integrations/*`, `src/memu/client/*`
- CLI (`memu`): `src/memu/cli.py`; shared `MEMU_*` config: `src/memu/env.py`
- Host adapters (`memu-codex`, bridging pipeline): `src/memu/hosts/*`
- Tests: `tests/*`
- User-facing docs/examples: `README.md`, `readme/*`, `docs/*`, `examples/*`
Read the relevant implementation and nearby tests first. Prefer local patterns over new abstractions.
## Implementation Rules
- Keep changes narrow and localized to the affected layer.
- Preserve async behavior and avoid sync work in async paths unless existing code already does it.
- Prefer adding or modifying a workflow step over embedding new flow logic directly in `MemoryService`.
- When adding configurable behavior, update typed settings and wire it through step config or service config.
- Preserve result shapes unless the task explicitly asks for a breaking change.
- Preserve async behavior and result shapes unless the task explicitly asks for a breaking change.
- Maintain type hints and mypy compatibility.
- Keep provider-specific logic inside provider/backend modules.
- Keep storage-neutral logic outside concrete backends; vector ranking helpers belong in shared modules such as `memu.vector`.
- Do not duplicate client caching. Use the existing client pool/profile routing patterns.
- Keep provider-specific logic inside `memu.embedding.backends`; storage-neutral logic outside concrete backends.
- Do not duplicate client caching — use the existing `ClientPool` pattern.
- Do not silently swallow errors that should be visible to callers or tests.
- Avoid broad refactors unless they are required to make the requested change safe.
## Workflow Guidance
For memorize work:
- Keep modality routing in the preprocessing pipeline.
- Use VLM clients for image/video paths and chat clients for conversation/document/audio paths.
- Preserve the flow: ingest resource, preprocess, extract items, dedupe/merge, categorize, persist index, build response.
- If rich document ingestion changes, consider the optional `document` extra and MarkItDown behavior.
For retrieve work:
- Check both `retrieve_rag` and `retrieve_llm` when behavior is shared.
- Validate `where` filters against the configured user model before querying.
- Keep category, item, and resource recall responsibilities separated.
- Preserve early-stop/sufficiency behavior when modifying ranking or response build steps.
For CRUD/patch work:
- Respect repository contracts and scope fields.
- Preserve existing output shapes for list/create/update/delete operations.
- Add backend coverage when contracts change.
For memory file export:
- Keep raw resources copied verbatim into `resource/`.
- Keep category memory split under `memory/<slug>.md`.
- Keep synthesized skills under `skill/<name>/SKILL.md`.
- Keep root files as indexes/overviews, not hidden sources of untracked state.
- Treat synthesized skill output as derived from source descriptions, not from unrelated runtime state.
## Backend Parity
If a repository method, model field, filter behavior, or vector search contract changes:
1. Update the protocol/interface.
1. Update the protocol in `src/memu/database/repositories/`.
2. Update `inmemory`, `sqlite`, and `postgres` implementations where applicable.
3. Add or extend conformance tests.
4. Check migrations/bootstrap behavior for SQL backends.
5. Verify fallback behavior when pgvector or optional extras are unavailable.
## LLM, VLM, And Embedding Routing
- Chat-like work uses LLM profiles.
- Embedding work uses embedding profiles and dedicated embedding clients.
- Image/video understanding uses VLM clients derived from LLM profiles unless explicitly configured otherwise.
- Step config should select profiles with existing keys such as `llm_profile`, `chat_llm_profile`, and `embed_llm_profile`.
- Keep interceptors and usage metadata behavior consistent across capabilities.
- Do not add embeddings back into chat clients; embedding is intentionally decoupled.
## Feature Checklist
1. Locate the affected flow, backend, integration, or export path.
2. Update settings/defaults only if behavior is configurable.
3. Wire the change through `MemoryService`, pipeline registration, or step config as appropriate.
4. Implement repository/backend changes for all impacted providers.
5. Add focused tests for the happy path and edge cases.
6. Update docs/examples for user-visible behavior.
7. Add or update ADRs under `docs/adr/` for architectural changes.
## Bug Fix Checklist
1. Reproduce the bug with an existing or new failing test.
2. Fix the smallest correct layer.
3. Add a regression test that fails before and passes after.
4. Check cross-backend effects when storage behavior changes.
5. Check both retrieval modes when recall/ranking/response behavior changes.
6. Confirm no unintended public API or output-shape changes.
3. Extend `tests/test_agentic.py` (runs against inmemory + sqlite).
4. Check migrations/bootstrap behavior for SQL backends (`src/memu/database/postgres/migrations/`).
## Testing And Validation
@@ -134,32 +55,23 @@ Use `uv` for local runs.
- Run focused tests: `uv run python -m pytest tests/<target_test>.py`
- Full quality checks: `make check`
At minimum, run targeted tests for touched code. Run `make check` for broad, cross-cutting, or release-critical changes.
If a required check cannot be run, say why in the final summary.
Useful focused areas:
- Storage parity: `tests/test_backend_conformance.py`, `tests/test_inmemory.py`, `tests/test_sqlite.py`, `tests/test_postgres.py`
- Retrieval/vector behavior: `tests/test_vector.py`, `tests/test_salience.py`, retrieve-related tests
- Multimodal preprocessing: `tests/test_conversation_preprocess.py`, `tests/test_document_text.py`, `tests/test_audio_preprocess.py`, `tests/test_vlm_preprocess.py`
- Memory file export/synthesis: `tests/test_memory_files.py`, `tests/test_memory_fs_synthesis.py`
- Provider routing: `tests/test_embedding.py`, `tests/test_openrouter.py`, `tests/test_openai_max_tokens.py`, `tests/test_lazyllm.py`
- Integrations/tool memory: `tests/test_tool_memory.py`, LangGraph-related tests
- The three entry points end-to-end: `tests/test_agentic.py`
- Vector ranking: `tests/test_vector.py`
- Embedding providers/gateway: `tests/test_embedding.py`
- CLI surface: `tests/test_cli.py`
- Host adapter inject seam: `tests/test_host_instruction.py`
## Documentation Rules
- Update `README.md`, localized `readme/*`, `docs/*`, or examples when behavior visible to users changes.
- Keep architecture docs current when composition, workflow contracts, backends, or profile routing changes.
- Keep examples runnable and aligned with the public API.
- Update `README.md` or `npm/README.md` when user-visible behavior changes.
- Add or update ADRs under `docs/adr/` for architectural changes; existing ADRs are historical records — do not rewrite them.
- Do not document speculative features as implemented.
## Done Criteria
Before finishing:
- Code compiles for touched paths.
- Tests for changed behavior pass.
- Code compiles for touched paths; tests for changed behavior pass.
- New behavior has test coverage.
- User-visible or architectural behavior is documented.
- Backend parity has been considered and implemented where needed.
- No unrelated files were modified.
- Backend parity considered and implemented where needed.
- No unrelated files modified.
+64 -414
View File
@@ -16,451 +16,101 @@
<a href="https://trendshift.io/repositories/17374" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17374" alt="NevaMind-AI%2FmemU | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**[English](readme/README_en.md) | [中文](readme/README_zh.md) | [日本語](readme/README_ja.md) | [한국어](readme/README_ko.md) | [Español](readme/README_es.md) | [Français](readme/README_fr.md)**
</div>
---
> [!WARNING]
> 🚧 **Under heavy construction** — memU is undergoing a major rework. APIs, CLI commands, and docs may change without notice. Things are expected to stabilize around **July 15, 2026**.
> 🤖 **Agents**: read [`.claude/skills/memu/SKILL.md`](.claude/skills/memu/SKILL.md) and you can `memorize-workspace` and `retrieve-workspace` right away.
memU compiles conversations, documents, code, images, audio, video, URLs, and tool traces into human-readable Markdown files (`INDEX.md`, `MEMORY.md`, `SKILL.md`). Agents traverse the tree and load only what the moment needs — instead of rescanning everything or stuffing long histories into every prompt.
memU is an embedding-only memory store for AI agents. The agent does the reading and writing — memU persists what it produces and hands back compact, ranked context. No LLM call happens inside the service; the only model calls are embeddings.
The whole surface is three operations:
```python
await service.memorize_workspace(folder="./workspace")
from memu.app import MemoryService
context = await service.retrieve_workspace("What should I know about this user's launch preferences?")
service = MemoryService(database_config={"metadata_store": {"provider": "sqlite", "dsn": "sqlite:///memu.sqlite3"}})
# 1. Persist agent-prepared memory: recall files (memory/skill tracks) + resources
await service.commit_results(
recall_files=[{"name": "Profile", "track": "memory", "description": "who the user is", "content": "..."}],
resource=[{"path": "/abs/path/notes.md", "description": "meeting notes"}],
)
# 2. See what is stored, across every track
files = await service.list_all_recall_files()
# 3. Single-shot embedding retrieval over segments / files / resources
context = await service.progressive_retrieve("What should I know about this user's launch preferences?")
```
Or straight from the terminal — no code:
```bash
npx memu-cli memorize-workspace ./workspace
npx memu-cli retrieve-workspace "What should I know about this user's launch preferences?"
npx memu-cli list-files
npx memu-cli retrieve "What should I know about this user's launch preferences?"
npx memu-cli commit results.json # {"recall_files": [...], "resource": [...]}
```
That's it. Instead of one giant prompt about a person or their workspace, your agent gets three durable layers it can traverse:
## The memory model
```txt
workspace/
├── INDEX.md ← Index: a map of everything — raw sources and summaries
├── MEMORY.md ← Memory: an overview that links into memory/
├── SKILL.md ← Skill: an overview that links into skill/
├── resource/ ← the raw source files, copied verbatim
├── memory/
│ └── <topic>.md ← one memory file per topic: facts, preferences, goals, events
└── skill/
└── <name>.md ← one skill file per learned pattern, workflow, or mistake to avoid
Memory is a set of **recall files** — one Markdown document per topic (`track="memory"`) or per learned skill (`track="skill"`). Each file is sliced into embedded **segments** (memory files: one per content line; skill files: one per skill), and **resources** record the raw sources with an embedded caption.
`progressive_retrieve` embeds the query once and returns three ranked layers:
- `segments` — the matched slices, with scores
- `files` — the documents those segments belong to (usually what you want), each with its linked resource URLs
- `resources` — matching raw sources, for when summaries are not enough
There is no intention routing, sufficiency checking, or summarization — one embedding call in, ranked context out.
## Host adapter: memory for desktop coding agents
`memu-codex` runs memU as a sidecar to Codex-style agents (ADR 0008/0009): a scheduled *bridging* task mines the host's session logs into job files, the agent itself distills them into memory/skill markdown, and `commit` writes the result back through `commit_results`. The *inject* seam patches the host's global instruction file so the agent runs `memu-codex retrieve` (→ `progressive_retrieve`) before answering.
```bash
memu-codex docs install # agent-facing setup guide
memu-codex doctor # verify MEMU_* config + store + retrieval
memu-codex prepare # slice new sessions into self-evolve jobs
memu-codex commit # submit what the agent produced back into memU
```
- **Index (`INDEX.md`)** — a map of your memories: what exists, where it came from, and where to look first
- **Memory (`MEMORY.md`)** — personal facts, preferences, goals, events, and decisions extracted from source data
- **Skill (`SKILL.md`)** — **auto-extracted from agent traces and refined on every workspace sync** so the agent improves at recurring tasks
## Installation
When you sync a folder with `memorize_workspace`, the top-level directory decides the treatment: files under `chat/` become memory, files under `agent/` become skills, and everything else is indexed as workspace context.
```bash
pip install memu-py # library
npx memu-cli --help # CLI via npm launcher (engine: PyPI package memu-cli)
uvx --from memu-cli memu # CLI via uv, no install
```
Three things make it different from stuffing everything into the prompt:
Install only one of `memu-py` / `memu-cli` in a given environment — they ship the same `memu` module.
- **Fast retrieval** — walk to the right folder and rank the right files instead of scanning everything every time.
- **Higher accuracy** — scope by user, task, or session, and trace every item back to the exact conversation, document, image, or log it came from.
- **Lower cost** — retrieve compact, scoped context instead of reinjecting long histories, documents, logs, and media-derived text into every prompt.
- **Yours to inspect** — a human-readable file tree you can audit, edit, scope, and route through your own storage (`inmemory`, `sqlite`, `postgres`) and LLM providers.
## Configuration
Every CLI flag has a `MEMU_*` environment variable (also read from `~/.memu/config.env`):
---
| Setting | Env var | Default |
|---|---|---|
| Store | `MEMU_DB` | `./data/memu.sqlite3` (CLI); required for host adapters |
| Embedding provider | `MEMU_LLM_PROVIDER` | `openai` (also: `jina`, `voyage`, `openrouter`, ...) |
| API key | `MEMU_API_KEY` | the provider's env var, e.g. `OPENAI_API_KEY` |
| Embedding model | `MEMU_EMBED_MODEL` | the provider's default |
| Base URL | `MEMU_BASE_URL` | the provider's default |
## ⭐️ Star the repository
<img width="100%" src="https://github.com/NevaMind-AI/memU/blob/main/assets/star.gif" />
If you find memU useful or interesting, a GitHub Star ⭐️ would be greatly appreciated.
---
## ✨ Core Features
| Capability | Description |
|------------|-------------|
| 🗂️ **Multimodal Ingestion** | Write conversations, documents, images, video, audio, URLs, logs, and local files into memory |
| 📁 **Compiled Memory Workspace** | Persist the Index, Skill, and Memory layers — folders (categories), files (items), source artifacts, links, summaries, and embeddings |
| 🧠 **Typed Memory Extraction** | Extract profile, event, knowledge, behavior, skill, and tool memories from raw sources |
| 🛠️ **Self-Evolving Skills** | Auto-extract reusable tool patterns and workflows from agent traces, then merge and refine them on every workspace sync instead of relearning |
| 🧭 **Self-Organizing Folders** | Auto-build categories, links, summaries, and embeddings without manual tagging |
| 🤖 **Agent-Ready Retrieval** | LLM-free `retrieve_workspace()` ranks memory segments, files, and source resources directly |
| 🔄 **Incremental Workspace Sync** | `memorize_workspace()` diffs a folder against a manifest — only changed files are (re)processed, deletions cascade |
| 🧱 **Pluggable Storage** | Use in-memory, SQLite, or Postgres backends with the same repository contracts |
| 🔀 **Profile-Based LLM Routing** | Route chat, embedding, vision, and transcription work through configurable LLM profiles |
| ⌨️ **CLI** | `memu` command (pip) and `npx memu-cli` (npm) — memorize and retrieve from the terminal or CI |
---
## 🎯 Use Cases
Every use case is the same loop: drop sources into a folder, sync it with `memorize_workspace()`, then ask with `retrieve_workspace()`. The sync is incremental (only changed files are reprocessed), and the top-level directory decides the treatment — `chat/` → memory topics, `agent/` → skills, everything else → indexed context.
### 1. **Personal Memory**
*Turn chat logs into user preferences, goals, events, decisions, and relationship context.*
Storage backends: `inmemory` (tests), `sqlite` (default, brute-force cosine), `postgres` + pgvector (`pip install "memu-py[postgres]"`).
```python
# workspace/chat/*.json — conversation logs become memory topic files
await service.memorize_workspace(folder="./workspace")
context = await service.retrieve_workspace("What should I remember about this user?")
```
### 2. **Workspace Context for Coding Agents**
*Convert docs, PR notes, logs, and design decisions into reusable project memory.*
```python
# docs, notes, and logs anywhere in the folder are captioned and indexed
await service.memorize_workspace(folder="./workspace")
context = await service.retrieve_workspace("How should I structure this module?")
```
### 3. **Multimodal Knowledge Layer**
*Extract searchable facts from documents, screenshots, images, videos, and audio notes.*
```python
# modality is inferred per file: .pdf/.docx/.pptx/.xlsx/.html (via MarkItDown —
# pip install 'memu-py[document]'), .png/.jpg, .mp3/.wav, .mp4/.mov, ...
await service.memorize_workspace(folder="./workspace")
context = await service.retrieve_workspace("What matters for the next research plan?")
```
### 4. **Tool and Agent Learning**
*Turn execution traces into skills that tell future agents what worked and what to avoid.*
```python
# workspace/agent/*.txt — execution traces are distilled into skill files
await service.memorize_workspace(folder="./workspace")
context = await service.retrieve_workspace("Which tools worked for config editing?")
```
---
## 🗂️ Architecture
The compiled workspace is easiest to read as two directions:
- `memorize_workspace()` writes a folder into durable memory files, skill files, resource records, segments, links, and embeddings.
- `retrieve_workspace()` reads those layers directly, ranking segments first and rolling results up to the files and resources an agent should load.
Memory is stored in three representation layers:
| Layer | What it holds | Retrieval Role |
|-------|---------------|----------------|
| **File** (`RecallFile`) | A synthesized memory topic or skill document | The unit returned to the agent — hit segments roll up to their file |
| **Segment** | Fine slices of a file (paragraph lines, skill descriptions) | The embedded search unit — queries rank segments first |
| **Resource** | The raw source artifact with its caption | Recall original context when synthesized summaries are not enough |
`retrieve_workspace()` embeds the query once, ranks segments and resources by similarity, and returns compact context with zero chat-LLM calls.
See [docs/architecture.md](docs/architecture.md) for the runtime view of `MemoryService`, workflow pipelines, storage backends, and LLM routing, and [docs/adr/](docs/adr/README.md) for the decision records behind the layered design.
---
## 🧰 Agent Skills
The repo ships one [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills) — [`.claude/skills/memu/SKILL.md`](.claude/skills/memu/SKILL.md) — that gives Claude Code (and any skills-compatible agent) the workspace pair. The agent decides when to use each direction:
- **memorize** (`memu memorize-workspace`) — "remember this", "sync this folder into memory", finishing work worth persisting
- **retrieve** (`memu retrieve-workspace`) — "what do we know about…", starting a task with likely prior context
It works out of the box inside this repo. To use it in your own project, copy the skill folder into that project's `.claude/skills/` (or `~/.claude/skills/` to enable it everywhere):
```bash
cp -r .claude/skills/memu /path/to/your-project/.claude/skills/
```
The skill locates the CLI automatically (`memu`, `uvx --from memu-py memu`, or `npx memu-cli`) and keeps state in the project-local `./data/memu.sqlite3`, so what one session memorizes the next can retrieve. For LangGraph agents, see the [LangGraph integration](docs/langgraph_integration.md) instead.
---
## 🚀 Quick Start
### Option 1: Cloud Version
👉 **[memu.so](https://memu.so)** — Hosted API for managed ingestion, structured memory, and retrieval
For enterprise deployment: **info@nevamind.ai**
#### Cloud API (v3)
| Base URL | `https://api.memu.so` |
|----------|----------------------|
| Auth | `Authorization: Bearer <token>` |
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v3/memory/memorize` | Ingest raw data and build structured memory |
| `GET` | `/api/v3/memory/memorize/status/{task_id}` | Check processing status |
| `POST` | `/api/v3/memory/categories` | List auto-generated categories |
| `POST` | `/api/v3/memory/retrieve` | Query memory for agent context |
📚 **[Full API Documentation](https://memu.pro/docs#cloud-version)**
---
### Option 2: Self-Hosted
#### Installation
From a clone of this repository:
```bash
uv sync
# or, for the full development setup:
make install
```
To install the published package instead:
```bash
pip install memu-py # library + `memu` CLI
# or from the JS ecosystem (thin launcher over memu-py, uses uvx/pipx automatically):
npx memu-cli --help
```
> **Requirements**: Python 3.13+. The default examples use OpenAI, so set `OPENAI_API_KEY` or pass another provider through `llm_profiles`.
#### Command line
The `memu` command wraps the same service the library exposes. State persists in a local SQLite database (`./data/memu.sqlite3` by default), so memorize in one invocation and retrieve in the next:
```bash
export OPENAI_API_KEY=your_key
memu memorize-workspace ./workspace # diff-sync a folder (alias: memu sync)
memu retrieve-workspace "deploy checklist" # LLM-free embedding retrieval (alias: memu search)
memu export # rebuild the INDEX.md/MEMORY.md/SKILL.md tree
```
Every flag has a `MEMU_*` environment variable (`--provider`/`MEMU_LLM_PROVIDER`, `--model`/`MEMU_CHAT_MODEL`, `--db`/`MEMU_DB`, ...) — run `memu <command> --help` for the full list. `--db` accepts a SQLite path, a `postgres://` DSN, or `:memory:`.
**Run an in-memory smoke script:**
```bash
export OPENAI_API_KEY=your_key
cd tests
uv run python test_inmemory.py
```
**Run with PostgreSQL + pgvector:**
```bash
uv sync --extra postgres
docker run -d --name memu-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=memu \
-p 5432:5432 \
pgvector/pgvector:pg16
export OPENAI_API_KEY=your_key
export POSTGRES_DSN=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/memu
cd tests
uv run python test_postgres.py
```
---
### Custom LLM and Embedding Providers
```python
from memu import MemoryService
service = MemoryService(
llm_profiles={
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "your_key",
"chat_model": "qwen3-max",
"client_backend": "sdk"
},
"embedding": {
"base_url": "https://api.voyageai.com/v1",
"api_key": "your_key",
"embed_model": "voyage-3.5-lite"
}
},
database_config={"metadata_store": {"provider": "postgres", "dsn": "postgresql://..."}},
embedding_profiles={"default": {"provider": "jina"}},
progressive_retrieve_config={"file": {"top_k": 10, "tracks": ["memory"]}, "resource": {"enabled": False}},
)
```
---
Scoping: every record carries optional `user_id`/`agent_id` fields; pass `user=` to `commit_results` and `where=` to the read paths to partition one store across users and agents.
### OpenRouter Integration
## Design docs
```python
from memu import MemoryService
The `docs/adr/` directory records the architecture decisions, including the move to tracked workspace memorization (ADR 0006), the segment/file/resource retrieval lines (ADR 0007), and the host-adapter seams (ADR 0008/0009).
service = MemoryService(
llm_profiles={
"default": {
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
"api_key": "your_key",
"chat_model": "anthropic/claude-3.5-sonnet",
"embed_model": "openai/text-embedding-3-small",
},
},
database_config={"metadata_store": {"provider": "inmemory"}},
)
```
## License
---
## 📖 Core APIs
The primary API pair is `memorize_workspace()` / `retrieve_workspace()` — folder in, ranked context out.
### `memorize_workspace()` — Sync a Folder
<img width="100%" alt="memorize_workspace" src="assets/memorize.png" />
```python
result = await service.memorize_workspace(
folder="./workspace", # scanned recursively; modality inferred per file
user={"user_id": "123"}, # optional scope
)
# Returns the diff plus what changed:
# { "added": [...], "modified": [...], "deleted": [...],
# "resources": [...], "entries": [...], "files": [...] }
```
- Diffs the folder against a sidecar `.memu_manifest.json` — only added/modified files are processed, memory from deleted files is cascade-removed
- Routes by top-level directory: `chat/` → memory files, `agent/` → skill files, everything else → indexed workspace context
- Rebuilds the markdown memory tree (`INDEX.md` / `MEMORY.md` / `SKILL.md`) when `memory_files_config.enabled=True`
---
### `retrieve_workspace()` — Fast, LLM-Free Retrieval
<img width="100%" alt="retrieve_workspace" src="assets/retrieve.png" />
```python
result = await service.retrieve_workspace(
"deploy checklist",
where={"user_id": "123"},
)
# Returns:
# { "segments": [...], # embedded slices ranked by similarity
# "files": [...], # the memory/skill files those segments roll up to
# "resources": [...] } # workspace resources ranked by similarity
```
The query is embedded once and ranked by vector similarity — no intention routing, no query rewriting, no sufficiency checks, zero LLM calls. Use it for high-frequency lookups where latency and cost matter more than deep reasoning.
---
## 💡 Example Workflows
### Always-Learning Assistant
```bash
export OPENAI_API_KEY=your_key
uv run python examples/example_1_conversation_memory.py
```
Automatically extracts preferences, builds relationship models, and surfaces relevant context in future conversations.
### Self-Improving Agent
```bash
uv run python examples/example_2_skill_extraction.py
```
Monitors agent actions, identifies patterns in successes and failures, auto-generates skill guides from experience.
### Multimodal Context Builder
```bash
uv run python examples/example_3_multimodal_memory.py
```
Cross-references text, images, and documents automatically into a unified memory layer.
---
## 📊 Performance
memU achieves **92.09% average accuracy** on the Locomo benchmark across all reasoning tasks.
<img width="100%" alt="benchmark" src="https://github.com/user-attachments/assets/6fec4884-94e5-4058-ad5c-baac3d7e76d9" />
View detailed results: [memU-experiment](https://github.com/NevaMind-AI/memU-experiment)
---
## 🧩 Ecosystem
| Repository | Description |
|------------|-------------|
| **[memU](https://github.com/NevaMind-AI/memU)** | Personal memory as files — fast retrieval, higher accuracy, lower cost |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | Backend with real-time sync and webhook triggers |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | Visual dashboard for browsing and monitoring memory |
**Quick Links:**
- 🚀 [Try MemU Cloud](https://app.memu.so/quick-start)
- 📚 [API Documentation](https://memu.pro/docs)
- 💬 [Discord Community](https://discord.com/invite/hQZntfGsbJ)
---
## 🤝 Partners
<div align="center">
<a href="https://github.com/TEN-framework/ten-framework"><img src="https://avatars.githubusercontent.com/u/113095513?s=200&v=4" alt="Ten" height="40" style="margin: 10px;"></a>
<a href="https://openagents.org"><img src="assets/partners/openagents.png" alt="OpenAgents" height="40" style="margin: 10px;"></a>
<a href="https://github.com/milvus-io/milvus"><img src="https://miro.medium.com/v2/resize:fit:2400/1*-VEGyAgcIBD62XtZWavy8w.png" alt="Milvus" height="40" style="margin: 10px;"></a>
<a href="https://xroute.ai/"><img src="assets/partners/xroute.png" alt="xRoute" height="40" style="margin: 10px;"></a>
<a href="https://jaaz.app/"><img src="assets/partners/jazz.png" alt="Jazz" height="40" style="margin: 10px;"></a>
<a href="https://github.com/Buddie-AI/Buddie"><img src="assets/partners/buddie.png" alt="Buddie" height="40" style="margin: 10px;"></a>
<a href="https://github.com/bytebase/bytebase"><img src="assets/partners/bytebase.png" alt="Bytebase" height="40" style="margin: 10px;"></a>
<a href="https://github.com/LazyAGI/LazyLLM"><img src="assets/partners/LazyLLM.png" alt="LazyLLM" height="40" style="margin: 10px;"></a>
<a href="https://clawdchat.ai/"><img src="assets/partners/Clawdchat.png" alt="Clawdchat" height="40" style="margin: 10px;"></a>
</div>
---
## 🤝 Contributing
```bash
# Fork and clone
git clone https://github.com/YOUR_USERNAME/memU.git
cd memU
# Install dev dependencies
make install
# Run quality checks before submitting
make check
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines.
**Prerequisites:** Python 3.13+, [uv](https://github.com/astral-sh/uv), Git
---
## 📄 License
[Apache License 2.0](LICENSE.txt)
---
## 🌍 Community
- **GitHub Issues**: [Report bugs & request features](https://github.com/NevaMind-AI/memU/issues)
- **Discord**: [Join the community](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [Follow @memU_ai](https://x.com/memU_ai)
- **Contact**: info@nevamind.ai
---
<div align="center">
**Star us on GitHub** to get notified about new releases!
</div>
Apache-2.0
-68
View File
@@ -1,68 +0,0 @@
# GitHub Issue Draft: Memory Types + Tool Memory
## Title
`[2026NewYearChallenge] Specialized Memory Types with Tool Learning`
---
## Description
### What will this task implement?
This PR enhances MemU's memory type system to support specialized memory structures with type-specific metadata and introduces Tool Memory for agent self-improvement.
**Current State:** MemU has a `memory_type` field with 5 types (profile, event, knowledge, behavior, skill) and uses different LLM prompts to extract each type. However, after extraction, all memories share the same storage schema - just `summary` and `embedding`. There's no type-specific metadata, no type-aware retrieval, and no way for agents to learn from their tool usage.
**Enhancement:** Extend the memory system to support:
- Type-specific metadata fields (e.g., `when_to_use` for smarter retrieval)
- Tool Memory type for tracking tool execution history
- Tool usage statistics for agent self-improvement
- Type-aware retrieval filtering
**Key Benefits:**
- Agents can learn from their own tool usage patterns
- Smarter retrieval based on memory context
- Foundation for agents that improve over time
- Better alignment with agentic application needs
---
## Requirements
- [x] Type-specific metadata schema with `when_to_use` field
- [x] Tool Memory implementation with execution tracking
- [x] Tool statistics (success_rate, avg_time_cost, avg_score)
- [ ] Type-aware retrieval filtering
- [x] Tests for Tool Memory CRUD and statistics
- [ ] Documentation and usage examples
---
## Review Criteria
- Correctness: All tests pass, no regressions
- Quality: Clean code, follows existing patterns
- DX: Clear documentation and examples
- Impact: Enables agent self-improvement use cases
---
## Implementation Summary
### Files Modified:
1. `src/memu/database/models.py` - Added `ToolCallResult` model, extended `MemoryItem` with `when_to_use`, `metadata`, `tool_calls` fields
2. `src/memu/database/repositories/memory_item.py` - Updated interface with new fields
3. `src/memu/database/inmemory/repositories/memory_item_repo.py` - Updated implementation
4. `src/memu/database/postgres/repositories/memory_item_repo.py` - Updated implementation
5. `src/memu/database/postgres/models.py` - Added JSON columns for new fields
6. `src/memu/prompts/memory_type/__init__.py` - Added tool type
7. `src/memu/prompts/memory_type/tool.py` - New prompt for tool memory extraction
### Files Added:
1. `tests/test_tool_memory.py` - 14 unit tests for Tool Memory feature
---
## Notes
This builds on MemU's existing memory type foundation while adding the specialized structures needed for agentic applications. The Tool Memory feature is particularly valuable for agents that need to learn which tools work best in different situations.
-659
View File
@@ -1,659 +0,0 @@
# 🔥🔥🔥 MAD COMBO OPTIONS FOR MEMU HACKATHON 🔥🔥🔥
> **Goal:** Implement high-impact features that MemU is missing, sourced from competitor analysis of 7 memory repos (memoripy, memlayer, ReMe, memX, memphora-sdk, MemOS, memor).
---
## 📋 COMPLETE FEATURE GAP ANALYSIS
### FEATURES MEMU IS MISSING (Deep Scan Results)
#### FROM MEMORIPY:
-`access_counts[]` - Track how often each memory is accessed
-`timestamps[]` - Track when memory was created/last accessed
-`decay_factor` - Exponential time-based decay: `np.exp(-decay_rate * time_diff)`
-`reinforcement_factor` - Log-scaled access boost: `np.log1p(access_count)`
-`adjusted_similarity` - `similarity * decay_factor * reinforcement_factor`
- ❌ Short-term → Long-term memory promotion (when `access_count > 10`)
-`nx.Graph()` concept associations (NetworkX graph)
-`spreading_activation()` - Spread activation through concept graph
-`cluster_interactions()` - KMeans clustering for hierarchical memory
-`semantic_memory` clusters - Retrieve from semantic memory clusters
#### FROM MEMLAYER:
-`SalienceGate` - Filter what's worth saving vs noise
-`SalienceMode.LOCAL` - Local ML model for salience
-`SalienceMode.ONLINE` - OpenAI API for salience
-`SalienceMode.LIGHTWEIGHT` - Keyword-based salience (no embeddings)
-`SALIENT_PROTOTYPES` / `NON_SALIENT_PROTOTYPES` - Prototype sentences
-`is_worth_saving()` - Determine if text should be saved
-`CurationService` - Background memory decay/expiration
-`_calculate_relevance()` - Score based on age, recency, attention
- ❌ Auto-archive low-relevance memories
- ❌ Auto-delete expired memories (`expiration_timestamp`)
-`SchedulerService` - Background task scheduler
-`get_due_tasks_for_user()` - Check for pending scheduled tasks
-`ConsolidationService` - Background knowledge extraction
-`analyze_and_extract_knowledge()` - Extract facts, entities, relationships
-`NetworkXStorage` - Graph storage for entities/relationships
-`add_entity()` / `add_relationship()` - Knowledge graph operations
-`get_subgraph_context()` - Graph traversal for context
-`find_matching_nodes()` - Fuzzy entity matching
-`_find_canonical_entity()` - Entity deduplication
-`_merge_entity_nodes()` - Merge duplicate entities
-`importance_score` / `expiration_timestamp` metadata
-`track_memory_access()` - Track when memories are accessed
- ❌ Task reminders system (`add_task`, `get_pending_tasks`, `update_task_status`)
#### FROM REME:
-`UpdateMemoryFreqOp` - Increment frequency counter on recall
-`metadata["freq"]` - Frequency counter in metadata
-`UpdateMemoryUtilityOp` - Increment utility score when useful
-`metadata["utility"]` - Utility score in metadata
-`DeleteMemoryOp` - Delete based on freq/utility thresholds
-`utility/freq < threshold` pruning - Prune low-value memories
-**MEMORY TYPES:**
-`TaskMemory` - Task-related information
-`PersonalMemory` - Personal info with `target` and `reflection_subject`
-`ToolMemory` - Tool call execution history
-`ToolCallResult` - Record tool execution results with hash deduplication
-`MemoryDeduplicationOp` - Remove duplicate memories using embedding similarity
-`WorkingMemory` operations:
-`MessageCompressOp` - LLM-based compression for long conversations
-`MessageCompactOp` - Compact verbose tool messages
-`MessageOffloadOp` - Orchestrate compaction + compression
-`WorkingSummaryMode.COMPACT/COMPRESS/AUTO`
-`UpdateMemory` tool - Update/edit existing memories
-`session_memory_id` tracking - Track memories per session
- ❌ Tool memory statistics (`avg_token_cost`, `success_rate`, `avg_time_cost`, `avg_score`)
#### FROM MEMX:
-`pubsub.py` - Real-time pub/sub system
-`subscribe(key, websocket)` - WebSocket subscriptions
-`publish(key, payload)` - Broadcast updates to subscribers
-`set_value()` with timestamps - Last-write-wins with timestamps
- ❌ Redis-backed shared memory - Multi-agent shared state
-`register_schema()` / `validate_schema()` - JSON schema validation
#### FROM MEMOS (MemOS):
-**Memory Scheduler System:**
-`BaseScheduler` - Full task scheduling infrastructure
-`SchedulerDispatcher` - Parallel task dispatch
-`ScheduleTaskQueue` - Priority task queue
-`TaskStatusTracker` - Track task status in Redis
-`TaskPriorityLevel` - Priority levels for tasks
-`MemoryMonitorItem` - Monitor memory with importance scores
-`replace_working_memory()` - Replace working memory after reranking
-`update_activation_memory()` - Update activation memory periodically
-`transform_working_memories_to_monitors()` - Convert memories to monitors
-`visibility` field - Public/private memory visibility
-`confidence` score - Confidence level for memories
-`status` field (activated/archived) - Memory activation status
-`tags` field - Memory tagging system
-`entities` extraction - Extract entities from memories
#### FROM MEMPHORA-SDK:
-`store_shared()` - Store shared memory for groups
- ❌ Multi-agent crew memory - Shared memory for agent crews
- ❌ Per-agent namespaces - Isolated memory per agent
- ❌ Framework integrations (AutoGen, CrewAI, LangChain, LlamaIndex)
---
---
## 🏆 COMBO 1: "INTELLIGENT MEMORY LIFECYCLE"
**Theme:** Memory that learns, ages, and self-curates like human memory
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| Decay & Reinforcement | memoripy | 3 pts | LOW |
| Frequency & Utility Tracking | ReMe | 3 pts | LOW |
| Auto-Pruning Low-Value Memories | ReMe | 3 pts | LOW |
**Total: 9 pts | LOW-MEDIUM effort**
### Why it's MAD:
```
Memory accessed often → gets STRONGER (reinforcement)
Memory ignored → gets WEAKER (decay)
Memory with low utility/freq ratio → gets DELETED automatically
Result: Self-healing, self-optimizing memory that mimics human forgetting!
```
### The Pitch:
> "MemU now has HUMAN-LIKE memory - it remembers what matters and forgets what doesn't!"
### Technical Implementation:
```python
# Decay formula (from memoripy)
decay_factor = np.exp(-decay_rate * time_diff)
reinforcement_factor = np.log1p(access_count)
adjusted_similarity = similarity * decay_factor * reinforcement_factor
# Pruning logic (from ReMe)
if freq >= freq_threshold:
if utility / freq < utility_threshold:
delete_memory(memory_id)
```
---
## 🏆 COMBO 2: "SMART MEMORY GATE"
**Theme:** Don't save garbage, only save gold
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| Salience Filtering | memlayer | 3-5 pts | MEDIUM |
| Decay & Reinforcement | memoripy | 3 pts | LOW |
| Background Curation Service | memlayer | 3 pts | MEDIUM |
**Total: 9-11 pts | MEDIUM effort**
### Why it's MAD:
```
INPUT: "Hello!" → BLOCKED (not salient)
INPUT: "My name is John, I work at Google" → SAVED (salient)
BACKGROUND: Old unused memories → AUTO-ARCHIVED
RETRIEVAL: Frequently accessed → BOOSTED
Result: Clean, high-quality memory that doesn't bloat!
```
### The Pitch:
> "MemU now has a BOUNCER - only important memories get in, garbage stays out!"
### Technical Implementation:
```python
# Salience Gate (from memlayer)
class SalienceGate:
SALIENT_PROTOTYPES = ["My name is...", "I work at...", "The deadline is..."]
NON_SALIENT_PROTOTYPES = ["Hello", "Thanks", "Okay", "Got it"]
def is_worth_saving(self, text: str) -> bool:
# Compare similarity to salient vs non-salient prototypes
salient_score = max_similarity(text, SALIENT_PROTOTYPES)
non_salient_score = max_similarity(text, NON_SALIENT_PROTOTYPES)
return salient_score > (non_salient_score + threshold)
```
---
## 🏆 COMBO 3: "KNOWLEDGE BRAIN"
**Theme:** Memory that understands relationships
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| Knowledge Graph | memlayer | 5 pts | HIGH |
| Entity Extraction | memlayer | 3 pts | MEDIUM |
| Graph Traversal Retrieval | memlayer | 3 pts | MEDIUM |
**Total: 11 pts | HIGH effort**
### Why it's MAD:
```
INPUT: "John works at Google. Sarah is John's wife."
GRAPH:
John --[works_at]--> Google
John --[married_to]--> Sarah
QUERY: "Who is related to Google?"
RESULT: John (works there), Sarah (married to John who works there)
Result: Memory that REASONS about relationships!
```
### The Pitch:
> "MemU now has a BRAIN - it understands how things connect!"
### Technical Implementation:
```python
# Knowledge Graph (from memlayer)
import networkx as nx
class KnowledgeGraph:
def __init__(self):
self.graph = nx.Graph()
def add_entity(self, name: str, node_type: str):
self.graph.add_node(name, type=node_type)
def add_relationship(self, subject: str, predicate: str, obj: str):
self.graph.add_edge(subject, obj, relation=predicate)
def get_subgraph_context(self, entity: str, depth: int = 2):
# Traverse graph for related entities
return nx.ego_graph(self.graph, entity, radius=depth)
```
---
## 🏆 COMBO 4: "MEMORY EVOLUTION" ⭐ TOP PICK
**Theme:** Memory that evolves and improves itself
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| Salience Gate (LIGHTWEIGHT mode) | memlayer | 3 pts | LOW |
| Decay & Reinforcement | memoripy | 3 pts | LOW |
| Frequency & Utility | ReMe | 3 pts | LOW |
| Auto-Pruning | ReMe | 3 pts | LOW |
**Total: 12 pts | LOW-MEDIUM effort**
### Why it's MAD:
```
STAGE 1: Salience Gate filters noise at INPUT
STAGE 2: Decay/Reinforcement adjusts scores at RETRIEVAL
STAGE 3: Frequency/Utility tracks VALUE over time
STAGE 4: Auto-Pruning DELETES low-value memories
Result: FULL LIFECYCLE MANAGEMENT - from birth to death!
```
### The Pitch:
> "MemU memories now have a LIFECYCLE - they're born, they grow, they age, they die!"
### Memory Lifecycle Diagram:
```
┌─────────────────────────────────────────────────────────────────┐
│ MEMORY LIFECYCLE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ INPUT ──► [SALIENCE GATE] ──► SAVE or REJECT │
│ │ │
│ ▼ │
│ ┌─────────┐ │
│ │ MEMORY │ ◄── access_count, last_accessed │
│ │ ITEM │ ◄── freq, utility, salience_score │
│ └────┬────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ ▼ ▼ │
│ [RETRIEVAL] [BACKGROUND] │
│ │ │ │
│ decay_factor auto_prune() │
│ reinforcement if utility/freq < threshold │
│ │ │ │
│ ▼ ▼ │
│ BOOSTED SCORE DELETE MEMORY │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 📊 COMPARISON TABLE
| Combo | Points | Effort | WOW Factor | Complexity | Recommendation |
|-------|--------|--------|------------|------------|----------------|
| 1. Intelligent Lifecycle | 9 | LOW | ⭐⭐⭐⭐ | LOW | ✅ SAFE BET |
| 2. Smart Gate | 9-11 | MEDIUM | ⭐⭐⭐⭐ | MEDIUM | ✅ GOOD |
| 3. Knowledge Brain | 11 | HIGH | ⭐⭐⭐⭐⭐ | HIGH | ⚠️ RISKY |
| 4. Memory Evolution | 12 | LOW-MED | ⭐⭐⭐⭐⭐ | MEDIUM | 🏆 **BEST COMBO** |
---
## 🎯 RECOMMENDATION: COMBO 4 "MEMORY EVOLUTION"
### Why This Combo Wins:
1. **12 points** - highest point potential
2. **LOW-MEDIUM effort** - achievable in hackathon timeframe
3. **4 features that SYNERGIZE** - each builds on the other
4. **UNIQUE story** - "memory lifecycle" is a compelling narrative
5. **Easy to demo** - show memory being filtered, decaying, getting pruned
### Implementation Order:
```
Step 1: Add fields to MemoryItem model
- access_count: int = 0
- last_accessed: datetime
- freq: int = 0
- utility: int = 0
- salience_score: float = 0.0
Step 2: Implement lightweight salience gate (keyword-based, no ML)
- SALIENT_KEYWORDS list
- NON_SALIENT_KEYWORDS list
- is_worth_saving() function
Step 3: Implement decay-aware retrieval
- Modify cosine_topk() to apply decay formula
- Update access_count and last_accessed on retrieval
Step 4: Implement frequency/utility tracking
- Increment freq on every retrieval
- Increment utility when memory contributes to response
Step 5: Implement auto-pruning
- Background check for low utility/freq ratio
- Delete memories below threshold
```
### Files to Modify:
```
memU/src/memu/database/models.py # Add new fields
memU/src/memu/database/inmemory/vector.py # Decay-aware retrieval
memU/src/memu/app/memorize.py # Salience gate
memU/src/memu/app/retrieve.py # Frequency/utility tracking
memU/src/memu/app/service.py # Auto-pruning service
```
---
## 📚 Reference Implementations
### From memoripy (Decay & Reinforcement):
- File: `prospects/memoripy/memoripy/memory_store.py`
- Key functions: `retrieve()`, `classify_memory()`
### From memlayer (Salience Gate):
- File: `prospects/memlayer/memlayer/ml_gate.py`
- Key class: `SalienceGate`
### From ReMe (Frequency & Utility):
- Files:
- `prospects/ReMe/reme_ai/vector_store/update_memory_freq_op.py`
- `prospects/ReMe/reme_ai/vector_store/update_memory_utility_op.py`
- `prospects/ReMe/reme_ai/vector_store/delete_memory_op.py`
---
## 🚀 Ready to Implement?
Choose your combo and let's build! 🔥
## 🏆 UPDATED MAD COMBOS (After Deep Scan)
---
## 🏆 COMBO 1: "INTELLIGENT MEMORY LIFECYCLE"
**Theme:** Memory that learns, ages, and self-curates like human memory
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| Decay & Reinforcement | memoripy | 3 pts | LOW |
| Frequency & Utility Tracking | ReMe | 3 pts | LOW |
| Auto-Pruning Low-Value Memories | ReMe | 3 pts | LOW |
**Total: 9 pts | LOW-MEDIUM effort**
### Why it's MAD:
```
Memory accessed often → gets STRONGER (reinforcement)
Memory ignored → gets WEAKER (decay)
Memory with low utility/freq ratio → gets DELETED automatically
Result: Self-healing, self-optimizing memory that mimics human forgetting!
```
### The Pitch:
> "MemU now has HUMAN-LIKE memory - it remembers what matters and forgets what doesn't!"
---
## 🏆 COMBO 2: "SMART MEMORY GATE"
**Theme:** Don't save garbage, only save gold
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| Salience Filtering | memlayer | 3-5 pts | MEDIUM |
| Decay & Reinforcement | memoripy | 3 pts | LOW |
| Background Curation Service | memlayer | 3 pts | MEDIUM |
**Total: 9-11 pts | MEDIUM effort**
### Why it's MAD:
```
INPUT: "Hello!" → BLOCKED (not salient)
INPUT: "My name is John, I work at Google" → SAVED (salient)
BACKGROUND: Old unused memories → AUTO-ARCHIVED
RETRIEVAL: Frequently accessed → BOOSTED
Result: Clean, high-quality memory that doesn't bloat!
```
### The Pitch:
> "MemU now has a BOUNCER - only important memories get in, garbage stays out!"
---
## 🏆 COMBO 3: "KNOWLEDGE BRAIN"
**Theme:** Memory that understands relationships
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| Knowledge Graph (NetworkX) | memlayer | 5 pts | HIGH |
| Entity Extraction & Deduplication | memlayer | 3 pts | MEDIUM |
| Graph Traversal Retrieval | memlayer | 3 pts | MEDIUM |
**Total: 11 pts | HIGH effort**
### Why it's MAD:
```
INPUT: "John works at Google. Sarah is John's wife."
GRAPH:
John --[works_at]--> Google
John --[married_to]--> Sarah
QUERY: "Who is related to Google?"
RESULT: John (works there), Sarah (married to John who works there)
Result: Memory that REASONS about relationships!
```
### The Pitch:
> "MemU now has a BRAIN - it understands how things connect!"
---
## 🏆 COMBO 4: "MEMORY EVOLUTION" ⭐ TOP PICK
**Theme:** Memory that evolves and improves itself
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| Salience Gate (LIGHTWEIGHT mode) | memlayer | 3 pts | LOW |
| Decay & Reinforcement | memoripy | 3 pts | LOW |
| Frequency & Utility | ReMe | 3 pts | LOW |
| Auto-Pruning | ReMe | 3 pts | LOW |
**Total: 12 pts | LOW-MEDIUM effort**
### Why it's MAD:
```
STAGE 1: Salience Gate filters noise at INPUT
STAGE 2: Decay/Reinforcement adjusts scores at RETRIEVAL
STAGE 3: Frequency/Utility tracks VALUE over time
STAGE 4: Auto-Pruning DELETES low-value memories
Result: FULL LIFECYCLE MANAGEMENT - from birth to death!
```
### The Pitch:
> "MemU memories now have a LIFECYCLE - they're born, they grow, they age, they die!"
---
## 🏆 COMBO 5: "MEMORY TYPES" (NEW!)
**Theme:** Different memory types for different purposes
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| TaskMemory type | ReMe | 3 pts | MEDIUM |
| PersonalMemory type | ReMe | 3 pts | MEDIUM |
| ToolMemory type | ReMe | 5 pts | HIGH |
| Memory Deduplication | ReMe | 3 pts | MEDIUM |
**Total: 14 pts | MEDIUM-HIGH effort**
### Why it's MAD:
```
TaskMemory: "Complete the report by Friday"
- when_to_use: "When user asks about deadlines"
- content: "Report due Friday"
PersonalMemory: "User prefers dark mode"
- target: "user_preferences"
- reflection_subject: "ui_settings"
ToolMemory: "file_reader tool usage history"
- tool_call_results: [...]
- statistics: {avg_token_cost, success_rate, avg_score}
Result: Specialized memory for specialized tasks!
```
### The Pitch:
> "MemU now has SPECIALIZED MEMORY - task memory, personal memory, tool memory!"
---
## 🏆 COMBO 6: "WORKING MEMORY COMPRESSION" (NEW!)
**Theme:** Handle long conversations without losing context
| Component | Source | Points | Effort |
|-----------|--------|--------|--------|
| MessageCompressOp | ReMe | 3 pts | MEDIUM |
| MessageCompactOp | ReMe | 3 pts | MEDIUM |
| MessageOffloadOp | ReMe | 3 pts | MEDIUM |
**Total: 9 pts | MEDIUM effort**
### Why it's MAD:
```
LONG CONVERSATION (50k tokens) → COMPRESS → STATE SNAPSHOT (5k tokens)
Modes:
- COMPACT: Store full content externally, keep short previews
- COMPRESS: LLM-based compression to generate dense summaries
- AUTO: Compact first, then compress if needed
Result: Handle infinite conversations without context overflow!
```
### The Pitch:
> "MemU now handles INFINITE conversations - compress, compact, never forget!"
---
## 📊 UPDATED COMPARISON TABLE
| Combo | Points | Effort | WOW Factor | Complexity | Recommendation |
|-------|--------|--------|------------|------------|----------------|
| 1. Intelligent Lifecycle | 9 | LOW | ⭐⭐⭐⭐ | LOW | ✅ SAFE BET |
| 2. Smart Gate | 9-11 | MEDIUM | ⭐⭐⭐⭐ | MEDIUM | ✅ GOOD |
| 3. Knowledge Brain | 11 | HIGH | ⭐⭐⭐⭐⭐ | HIGH | ⚠️ RISKY |
| 4. Memory Evolution | 12 | LOW-MED | ⭐⭐⭐⭐⭐ | MEDIUM | 🏆 **BEST COMBO** |
| 5. Memory Types | 14 | MED-HIGH | ⭐⭐⭐⭐⭐ | HIGH | 🔥 HIGH POINTS |
| 6. Working Memory | 9 | MEDIUM | ⭐⭐⭐⭐ | MEDIUM | ✅ GOOD |
---
## 🎯 FINAL RECOMMENDATION
### For MAX POINTS with REASONABLE EFFORT: **COMBO 4 "MEMORY EVOLUTION"**
**Why?**
1. **12 points** - highest point potential for effort
2. **LOW-MEDIUM effort** - achievable in hackathon timeframe
3. **4 features that SYNERGIZE** - each builds on the other
4. **UNIQUE story** - "memory lifecycle" is a compelling narrative
5. **Easy to demo** - show memory being filtered, decaying, getting pruned
### Implementation Order:
```
Step 1: Add fields to MemoryItem model
- access_count: int = 0
- last_accessed: datetime
- freq: int = 0
- utility: int = 0
- salience_score: float = 0.0
Step 2: Implement lightweight salience gate (keyword-based, no ML)
- SALIENT_KEYWORDS list
- NON_SALIENT_KEYWORDS list
- is_worth_saving() function
Step 3: Implement decay-aware retrieval
- Modify cosine_topk() to apply decay formula
- Update access_count and last_accessed on retrieval
Step 4: Implement frequency/utility tracking
- Increment freq on every retrieval
- Increment utility when memory contributes to response
Step 5: Implement auto-pruning
- Background check for low utility/freq ratio
- Delete memories below threshold
```
### Files to Modify:
```
memU/src/memu/database/models.py # Add new fields
memU/src/memu/database/inmemory/vector.py # Decay-aware retrieval
memU/src/memu/app/memorize.py # Salience gate
memU/src/memu/app/retrieve.py # Frequency/utility tracking
memU/src/memu/app/service.py # Auto-pruning service
```
---
## 📚 Reference Implementations
### From memoripy (Decay & Reinforcement):
- File: `prospects/memoripy/memoripy/memory_store.py`
- Key functions: `retrieve()`, `classify_memory()`
### From memlayer (Salience Gate + Knowledge Graph):
- File: `prospects/memlayer/memlayer/ml_gate.py` - SalienceGate
- File: `prospects/memlayer/memlayer/storage/networkx.py` - Knowledge Graph
- File: `prospects/memlayer/memlayer/services.py` - CurationService
### From ReMe (Frequency & Utility + Memory Types):
- File: `prospects/ReMe/reme_ai/vector_store/update_memory_freq_op.py`
- File: `prospects/ReMe/reme_ai/vector_store/update_memory_utility_op.py`
- File: `prospects/ReMe/reme_ai/vector_store/delete_memory_op.py`
- File: `prospects/ReMe/reme_ai/schema/memory.py` - Memory types
- File: `prospects/ReMe/reme_ai/summary/task/memory_deduplication_op.py`
- File: `prospects/ReMe/reme_ai/summary/working/` - Working memory ops
### From MemOS (Scheduler):
- File: `prospects/MemOS/src/memos/mem_scheduler/base_scheduler.py`
---
## 🚀 Ready to Implement?
Choose your combo and let's build! 🔥
-392
View File
@@ -1,392 +0,0 @@
# memU Architecture
## Purpose and scope
This document describes the self-hosted `memu` Python package architecture as
implemented in this repository, reflecting the `RecallFile`/`RecallEntry` data
model and the workspace memorize/retrieve paths introduced by ADR 0006.
The repository also describes a hosted Cloud product in `README.md`, but this
document focuses on the local `MemoryService` runtime and its code paths.
## System overview
memU implements structured agent memory with four persistent record types:
- `Resource`: raw source artifacts (conversation/document/image/video/audio)
- `RecallEntry`: extracted atomic memories with embeddings (`memory_type` + `summary`)
- `RecallFile`: grouped topic files — a memory category **or** a skill, distinguished
by a `track` column (`"memory" | "skill"`)
- `RecallFileEntry`: entry-to-file relation edges
At runtime, `MemoryService` orchestrates ingestion, retrieval, manual CRUD, and an
optional markdown export over these records.
> **Vocabulary note.** Earlier revisions named these `MemoryItem` /
> `MemoryCategory` / `CategoryItem`. ADR 0006 renamed them to `RecallEntry` /
> `RecallFile` / `RecallFileEntry` (and renamed `RecallFile.summary` → `content`),
> a pure-rename foundation on top of which the skill track was added. A few
> back-compat dict views on the `Database` protocol (`items`, `categories`,
> `relations`) retain the older names.
```mermaid
flowchart TD
A["Resource or Query"] --> B["MemoryService (composition root)"]
B --> C["Workflow Pipelines"]
C --> D["Capability Clients (LLM / VLM / Embedding)"]
C --> E["Database Repositories"]
E --> F["Resources"]
E --> G["RecallEntries"]
E --> H["RecallFiles (track: memory | skill)"]
E --> I["RecallFileEntries"]
B --> J["Memory File-System Export (INDEX/MEMORY/SKILL .md)"]
```
## Core runtime components
### `MemoryService` as composition root
`src/memu/app/service.py` constructs and owns:
- typed configs (`LLMProfilesConfig`, `DatabaseConfig`, `MemorizeConfig`,
`RetrieveConfig`, `RetrieveWorkspaceConfig`, `MemoryFilesConfig`, `UserConfig`,
`EmbeddingProfilesConfig`)
- storage backend (`build_database(...)`)
- resource filesystem fetcher (`LocalFS`)
- per-capability client pools (one `ClientPool` each for chat/LLM, VLM and
embedding; see `src/memu/app/client_pool.py`)
- workflow and LLM interceptor registries
- workflow runner (`local` by default, pluggable)
- named workflow pipelines via `PipelineManager`
- the markdown memory file-system builder (`MemoryFilesBuilder`,
`src/memu/app/memory_files.py`), which owns the exporter/synthesizer/lock
The three client pools (`ClientPool[Config, Client]`) replace hand-rolled
per-capability caches: each lazily builds and caches a client per profile name
through the matching gateway (`build_llm_client` / `build_vlm_client` /
`build_embedding_client`), so adding a new capability is one more pool rather
than another copy of the get/cache/build dance.
Public APIs are assembled by mixins:
- `MemorizeMixin`: `memorize(...)`, `memorize_workspace(...)`
- `RetrieveMixin`: `retrieve(...)`, `retrieve_workspace(...)`
- `CRUDMixin`: list/clear/create/update/delete memory operations
### Workflow engine
All major operations execute as workflows (`WorkflowStep`) with:
- explicit required/produced state keys (`requires` / `produces`)
- declared capability tags (`llm`, `vector`, `db`, `io`, `vision`)
- per-step config (for profile selection)
`PipelineManager` validates step dependencies at registration/mutation time and
supports runtime pipeline revisioning (`config_step`, `insert_before/after`,
`replace_step`, `remove_step`).
`WorkflowRunner` is a protocol; the default `LocalWorkflowRunner` executes
sequentially with `run_steps(...)`. Workflow state is a plain `dict[str, Any]`,
so step contracts are validated by key names rather than static types.
Registered pipelines (see `MemoryService._register_pipelines`):
| Pipeline | Entry point |
| ------------------------ | ---------------------------- |
| `memorize` | `memorize(...)` |
| `memorize_workspace` | `memorize_workspace(...)` |
| `retrieve_rag` | `retrieve(...)` (method=rag) |
| `retrieve_llm` | `retrieve(...)` (method=llm) |
| `retrieve_workspace` | `retrieve_workspace(...)` |
| `patch_create` / `patch_update` / `patch_delete` | `CRUDMixin` create/update/delete |
| `crud_list_recall_entries` / `crud_list_recall_files` / `crud_clear_memory` | `CRUDMixin` list/clear |
### Interception and observability hooks
Two interceptor systems exist:
- workflow step interceptors: before/after/on_error around each step
- LLM call interceptors: before/after/on_error around `chat/summarize/vision/embed/transcribe`
LLM wrappers also extract best-effort usage metadata from raw provider responses.
## Ingestion architecture (`memorize`)
`memorize(...)` executes the `memorize` pipeline:
1. `ingest_resource`: fetch local/remote resource into `blob_config.resources_dir`
via `LocalFS`. For the `document` modality, rich formats (PDF, Word,
PowerPoint, Excel, HTML, EPub) are converted to Markdown text via
[MarkItDown](https://github.com/microsoft/markitdown) (`memu.blob.document_text`),
while plain text (`.txt`/`.md`/...) is read directly. MarkItDown is an optional
extra: `pip install 'memu-py[document]'`.
2. `preprocess_multimodal`: modality-specific preprocessing for
conversation/document/audio (text-oriented path) and image/video
(vision-oriented path)
3. `extract_entries`: per-memory-type LLM extraction into structured `RecallEntry`
records (`memory_type` ∈ profile/event/knowledge/behavior/skill/tool by default)
4. `dedupe_merge`: placeholder stage (currently pass-through)
5. `categorize_entries`: persist resource + recall entries + entry-file relations
and embeddings (memory track)
6. `persist_index`: update recall-file summaries; optionally persist entry references
7. `build_response`: return resource(s), entries, files, relations
Recall-file (category) bootstrap is lazy and scoped: files are initialized when
needed with embeddings, and mapped by normalized name. New-category creation can
be gated behind `allow_new_categories`.
### Workspace ingestion (`memorize_workspace`)
`memorize_workspace(...)` syncs a folder and, per changed file, runs the
**`memorize_workspace`** pipeline: the standard `memorize` steps plus a final
`generate_skills` step (ADR 0006). That step:
- runs per file, after the memory persist step, taking `preprocessed_resources`
as input (no data dependency on the memory categorize/persist outputs);
- reads existing `track="skill"` `RecallFile`s from the DB as the "existing skills"
context, so file *N* sees skills created by files *1..N-1*;
- emits `(name, description, body)` skills and persists each **directly** as a
`RecallFile(track="skill", content=body)`, embedding `name + description` and
bypassing the `RecallEntry` plane entirely;
- is gated behind `memory_files_config.synthesize`.
Single-file `memorize(...)` is intentionally left untouched and does **not** run
skill generation or drive the exporter.
## Retrieval architecture (`retrieve`)
`retrieve(...)` chooses one of two pipelines from `retrieve_config.method`:
- `retrieve_rag` (embedding-driven ranking)
- `retrieve_llm` (LLM-driven ranking)
Both share the same staged pattern (step ids):
1. `route_intention` — route intention + optional query rewrite
2. `route_file` — recall-file (category) recall
3. `sufficiency_after_file` — optional early stop
4. `recall_entries` — entry recall
5. `sufficiency_after_entries` — optional early stop
6. `recall_resources` — raw resource recall
7. `build_context` — response build
Key behavior:
- `where` filters are validated against `user_model` fields before querying
- file readers scope to `track="memory"` so skills stay out of memory RAG scoring
- RAG path uses vector similarity (and optional salience ranking for entries)
- LLM path ranks IDs from formatted file/entry/resource context
- each stage can stop early if sufficiency check decides context is enough
### Workspace retrieval (`retrieve_workspace`)
`retrieve_workspace(...)` is the LLM-free, embedding-only counterpart of the
routing-heavy `retrieve` (ADR 0006). Driven by `RetrieveWorkspaceConfig`
(`method="rag"` only), it embeds the query once and ranks the file/entry/resource
layers by vector similarity, with **no** intention routing, query rewrite,
sufficiency checks, or summarization. Steps: `recall_files``recall_entries`
`recall_resources``build_response`. The file layer scopes by track via
`RetrieveFileConfig.tracks`, so skill-track files can be retrieved (or excluded)
independently of the memory track — the read-side mirror of the ingestion track
isolation.
## Data and storage architecture
### Repository contracts
Storage is abstracted through a `Database` protocol (`src/memu/database/interfaces.py`)
with four repositories:
- `ResourceRepo` (incl. `vector_search_resources`)
- `RecallEntryRepo` (incl. `vector_search_items`, similarity/salience)
- `RecallFileRepo`
- `RecallFileEntryRepo`
Vector ranking over **stored** embeddings is a repository responsibility, which
keeps the retrieval layer from reaching into any concrete backend. The pure
cosine/salience math lives in the storage-neutral `memu.vector` module (not under
any backend), so the app layer and every backend depend on it instead of on each
other. (File recall still ranks freshly re-embedded summaries at query time in
the retrieval layer, since that is query-time policy rather than search over
stored vectors.)
### Backends
`build_database(...)` selects backend by `database_config.metadata_store.provider`:
- `inmemory`: in-process dict/list state
- `sqlite`: SQLModel persistence, embeddings stored as JSON text, brute-force cosine search
- `postgres`: SQLModel persistence with pgvector support (when enabled), local fallback ranking when needed
`DatabaseConfig` auto-derives `vector_index`: `pgvector` for postgres, `bruteforce`
otherwise. For Postgres, startup runs migration bootstrap and attempts
`CREATE EXTENSION IF NOT EXISTS vector` in `ddl_mode="create"`.
### Track column and scope model
`RecallFile.track` (`"memory" | "skill"`, default `"memory"`) splits the two
tracks within one table. `get_or_create_category` keys on `(name, track, *scope)`,
and the Postgres unique-with-scope index is `(name, track, *scope)`, so a skill
and a memory category may share a name without colliding. Every `RecallFile`
reader scopes by track through the `where` filter rather than ad-hoc
post-filtering.
`UserConfig.model` is merged into record/table models so scope fields (for example
`user_id`) become first-class columns/attributes across resources, entries, files,
and relations. This is why `where` filters and `user_data` writes are consistently
available across APIs.
## LLM/provider architecture
LLM access is profile-based (`llm_profiles`):
- `default` profile for chat-like tasks
- `embedding` profile for embedding tasks (auto-derived from default if not set)
Per-step profile routing happens through step config (`chat_llm_profile`,
`embed_llm_profile`, or `llm_profile`).
Client backends (selected per profile via `client_backend`):
- `sdk`: official OpenAI SDK wrapper
- `anthropic`: official Anthropic/Claude SDK wrapper
- `httpx`: provider-adapted HTTP backend (OpenAI, Claude, Grok, DeepSeek, Kimi,
MiniMax, Doubao, OpenRouter — see `memu.llm.backends`)
- `lazyllm_backend`: LazyLLM adapter
### VLM (vision-language) architecture
Image/video understanding uses a dedicated `memu.vlm` package, a sibling of
`memu.llm` mirroring its layout (`backends/`, `*_client.py`, `gateway.py`),
scoped to the multimodal `vision` capability. Only providers whose first-party
API offers native image understanding are included (`memu.vlm.backends`:
OpenAI, Claude, Grok, Kimi, MiniMax, Doubao, OpenRouter); text-only providers
such as DeepSeek are intentionally excluded.
VLM profiles are **derived** from the LLM profiles (`vlm_config_from_llm`): each
VLM client reuses the matching LLM profile's provider, credentials, and
`client_backend`, swapping only the model for that provider's latest VLM
(`memu.vlm.VLM_PROVIDER_DEFAULTS`), falling back to the LLM chat model when the
provider has no known VLM. This makes vision work with zero extra configuration.
During `preprocess_multimodal`, `MemoryService` routes by modality: `image` and
`video` use the VLM client (`_get_vlm_client`, profile from
`memorize_config.vlm_profile`), while `conversation`/`document`/`audio` use the
chat LLM client. VLM clients are cached per profile and wrapped by the same
`LLMClientWrapper`, so interceptors and usage metadata behave identically.
### Embedding (vectorization) architecture
Vectorization uses a dedicated `memu.embedding` package, a sibling of `memu.llm`
and `memu.vlm` mirroring their layout (`backends/`, `*_client.py`/`openai_sdk.py`,
`gateway.py`, `defaults.py`), scoped to the `embed` capability used by vector
search. `memu.embedding.backends` covers OpenAI, Jina, Voyage, Doubao and
OpenRouter; the HTTP client falls back to an OpenAI-compatible backend for any
other provider.
Embedding is **fully decoupled** from the text/chat clients: `OpenAIClient`,
`HTTPLLMClient` and `AnthropicClient` no longer expose `embed()`. All
vectorization — query embedding, file/entry embedding, RAG ranking — flows
through `_get_step_embedding_client` / `_get_embedding_client`, which build
dedicated `memu.embedding` clients. (`LazyLLMClient` remains multi-capability, as
it is the embedding transport for the `lazyllm_backend`.)
Embedding profiles (`EmbeddingConfig`) are **derived** from the LLM profiles
(`embedding_config_from_llm`) by default, reusing each profile's provider,
credentials and transport. Prefer passing an explicit `embedding_profiles` to
`MemoryService` to point vectorization at a dedicated embedding provider (e.g.
`jina`/`voyage`) independently of the chat provider. Embedding clients are built
via `build_embedding_client` (`client_backend`: `sdk`/`httpx`/`lazyllm_backend`;
`anthropic` raises, as Claude has no embeddings API), cached per profile, and
wrapped by the same `LLMClientWrapper`. `_get_step_embedding_client` resolves the
profile from step config (`embed_llm_profile`, default `embedding`).
## Integration surfaces
- `memu.integrations.langgraph`: LangChain/LangGraph tool adapter
(`save_memory`, `search_memory`)
There is no built-in HTTP server or CLI in this repository; usage is programmatic
via `MemoryService` (the hosted Cloud API is a separate product).
## Memory file system export (`memu.memory_fs`)
`MemoryService.export_memory_files(...)` renders the structured store into a
browsable markdown tree. Each root index sits beside a sibling payload directory:
```txt
<output_dir>/
├── INDEX.md ← index of the raw files under resource/
├── MEMORY.md ← overview + index of memory/
├── SKILL.md ← overview + index of skill/
├── resource/
│ └── <file_name> ← one copied raw source file (verbatim bytes)
├── memory/
│ └── <slug>.md ← one memory-track RecallFile (description + content)
├── skill/
│ └── <slug>.md ← one skill-track RecallFile (description + content)
└── .memufs_manifest.json ← per-artifact content hashes (diff detection)
```
- `resource/` holds the raw source files copied verbatim out of the blob store
(`Resource.local_path`); `INDEX.md` indexes them (name, modality, description,
link).
- `memory/<slug>.md` is one file per `track="memory"` `RecallFile`; `MEMORY.md`
links to each one.
- `skill/<slug>.md` is one file per `track="skill"` `RecallFile`, rendered by the
**same** track-agnostic renderer as `memory/` (ADR 0006). Skills are now
first-class rows, not an export-time synthesis artifact — the old
`skill/<name>/SKILL.md` folder layout is gone.
### Deterministic vs. synthesized
`INDEX.md`, the `resource/` copies, and the per-file `memory/<slug>.md` /
`skill/<slug>.md` documents are **always deterministic** — recomputed from the
current store, no LLM. Rendered content avoids volatile values so an unchanged
store re-exports as a no-op.
The two root overviews are deterministic link indexes by default. When
`memory_files_config.synthesize=True`, `MemoryFilesBuilder` synthesizes their
bodies with the `synthesis_llm_profile` LLM:
- `MEMORY.md` is synthesized from the per-source multimodal descriptions
- `SKILL.md` is synthesized from the skill-track files' bodies (the mirror of
`MEMORY.md`)
### Initialize vs. incremental update
`MemoryFilesBuilder.build(database, where, changed=...)` decides between two paths:
- **Initialization** (no prior tree on disk, or `changed is None`): rebuild from
the full scoped store. `export_memory_files(user=...)` always takes this path.
- **Incremental update** (a tree already exists and a changed set is supplied):
read existing overview bodies back off disk and merge only the changed sources'
descriptions into them.
`memorize_workspace(...)` drives this builder after each folder sync — passing the
just-created resources as the changed set, or forcing a full rebuild when files
were modified/deleted. That refresh is best-effort: an export failure is logged
and never fails the sync, since the structured memory is already persisted. The
exporter is read-only against the database, disabled by default
(`memory_files_config.enabled`), and serialized through a per-service lock.
## Current constraints and tradeoffs
- workflow state is dict-based, so step contracts are validated by key names
rather than static types
- SQLite/inmemory vector search is brute-force (portable but less scalable)
- file/entry summary and extraction quality are prompt/LLM dependent
- the `dedupe_merge` stage is a placeholder (currently pass-through)
- **skill provenance/deletion is a known, deferred gap** (ADR 0006): skill
`RecallFile`s bypass the `RecallEntry` plane and have no link back to their
source resources, so they do not invalidate on source change/delete. The skill
track is currently append/merge-only.
## Related ADRs
- `docs/adr/0001-workflow-pipeline-architecture.md`
- `docs/adr/0002-pluggable-storage-and-vector-strategy.md`
- `docs/adr/0003-user-scope-in-data-model.md`
- `docs/adr/0004-workspace-memorize-and-memory-file-system.md`
- `docs/adr/0005-dedicated-embedding-package.md`
- `docs/adr/0006-from-memory-item-category-to-tracked-workspace-memorization.md`
-53
View File
@@ -1,53 +0,0 @@
# Grok (xAI) Integration
MemU supports **Grok**, the AI model from xAI, as a first-class LLM provider.
## Prerequisites
1. **xAI Account:** You need an active account with [xAI](https://x.ai/).
2. **API Key:** Obtain an API key from the [xAI Console](https://console.x.ai/).
## Configuration
To enable Grok, you need to set the `XAI_API_KEY` environment variable.
### Environment Variable
```bash
export XAI_API_KEY="your-xai-api-key-here"
```
PowerShell:
```powershell
$env:XAI_API_KEY="your-xai-api-key-here"
```
## Usage
To use Grok as your LLM provider, switch the `provider` setting to `grok`. This can be done in your configuration file or when initializing the application.
### Python Example
```python
from memu.app.settings import LLMConfig
# Configure MemU to use Grok
config = LLMConfig(
provider="grok",
# The default API key env var is XAI_API_KEY
# The default model is grok-2-latest
)
print(f"Using provider: {config.provider}")
print(f"Base URL: {config.base_url}")
print(f"Chat Model: {config.chat_model}")
```
## Models Supported
We currently support the following Grok models:
* **grok-2-latest** (Default)
The integration automatically sets the base URL to `https://api.x.ai/v1`.
-97
View File
@@ -1,97 +0,0 @@
# MemU LangGraph Integration
The MemU LangGraph Integration provides a seamless adapter to expose MemU's powerful memory capabilities (`memorize` and `retrieve`) as standard [LangChain](https://python.langchain.com/) / [LangGraph](https://langchain-ai.github.io/langgraph/) tools. This allows your agents to persist information and recall it across sessions using MemU as the long-term memory backend.
## Overview
This integration wraps the `MemoryService` and exposes two key tools:
- **`save_memory`**: Persists text, conversation snippets, or facts associated with a user.
- **`search_memory`**: Retrieves relevant memories based on semantic search queries.
These tools are fully typed and compatible with LangGraph's `prebuilt.ToolNode` and LangChain's agents.
## Installation
To use this integration, you need to install the optional dependencies:
```bash
uv add langgraph langchain-core
```
## Quick Start
Here is a complete example of how to initialize the MemU memory service and bind it to a LangGraph agent.
```python
import asyncio
import os
from memu.app.service import MemoryService
from memu.integrations.langgraph import MemULangGraphTools
# Ensure you have your configuration set (e.g., env vars for DB connection)
# os.environ["MEMU_DATABASE_URL"] = "..."
async def main():
# 1. Initialize MemoryService
memory_service = MemoryService()
# If your service requires async init (check your specific implementation):
# await memory_service.initialize()
# 2. Instantiate MemULangGraphTools
memu_tools = MemULangGraphTools(memory_service)
# Get the list of tools (BaseTool compatible)
tools = memu_tools.tools()
# 3. Example Usage: Manually invoking a tool
# In a real app, you would pass 'tools' to your LangGraph agent or StateGraph.
# Save a memory
save_tool = memu_tools.save_memory_tool()
print("Saving memory...")
result = await save_tool.ainvoke({
"content": "The user prefers dark mode.",
"user_id": "user_123",
"metadata": {"category": "preferences"}
})
print(f"Save Result: {result}")
# Search for a memory
search_tool = memu_tools.search_memory_tool()
print("\nSearching memory...")
search_result = await search_tool.ainvoke({
"query": "What are the user's preferences?",
"user_id": "user_123"
})
print(f"Search Result:\n{search_result}")
if __name__ == "__main__":
asyncio.run(main())
```
## API Reference
### `MemULangGraphTools`
The main adapter class.
```python
class MemULangGraphTools(memory_service: MemoryService)
```
#### `save_memory_tool() -> StructuredTool`
Returns a tool named `save_memory`.
- **Inputs**: `content` (str), `user_id` (str), `metadata` (dict, optional).
- **Description**: Save a piece of information, conversation snippet, or memory for a user.
#### `search_memory_tool() -> StructuredTool`
Returns a tool named `search_memory`.
- **Inputs**: `query` (str), `user_id` (str), `limit` (int, default=5), `metadata_filter` (dict, optional), `min_relevance_score` (float, default=0.0).
- **Description**: Search for relevant memories or information for a user based on a query.
## Troubleshooting
### Import Errors
If you see an `ImportError` regarding `langchain_core` or `langgraph`:
1. Ensure you have installed the extras: `uv add langgraph langchain-core` (or `pip install langgraph langchain-core`).
2. Verify your virtual environment is active.
-70
View File
@@ -1,70 +0,0 @@
# Grok (xAI) Provider
memU includes first-class support for [Grok](https://grok.x.ai/), allowing you to leverage xAI's powerful language models directly within your application.
## Prerequisites
To use this provider, you must have an active xAI account.
1. Navigate to the [xAI Console](https://console.x.ai/).
2. Sign up or log in.
3. Create a new **API Key** in the API Keys section.
## Configuration
The integration is designed to work out-of-the-box with minimal configuration.
### Environment Variables
Set the following environment variable in your `.env` file or system environment:
```bash
GROK_API_KEY=xai-YOUR_API_KEY_HERE
```
### Defaults
When you select the `grok` provider, memU automatically configures the following defaults:
* **Base URL**: `https://api.x.ai/v1`
* **Model**: `grok-2-latest`
## Usage Example
You can enable the Grok provider by setting the `provider` field to `"grok"` in your application configuration.
### Using Python Configuration
```python
import os
from memu.app.settings import LLMConfig
from memu.app.service import MemoryService
# Configure the LLM provider to use Grok
llm_config = LLMConfig(
provider="grok",
api_key=os.environ.get("GROK_API_KEY", ""),
)
# Initialize the service — pass config via llm_profiles, not llm_config
service = MemoryService(llm_profiles={"default": llm_config})
print(f"Service initialized with model: {llm_config.chat_model}")
# Output: Service initialized with model: grok-2-latest
```
## Troubleshooting
### Connection Issues
If you are unable to connect to the xAI API:
1. Verify that your `GROK_API_KEY` is set correctly and has not expired.
2. Ensure that the `base_url` is resolving to `https://api.x.ai/v1`. If you have manual overrides in your settings, they might be conflicting with the default.
### Model Availability
If you receive a `404` or "Model not found" error, xAI may have updated their model names. You can override the model manually in the config if needed:
```python
config = LLMConfig(
provider="grok",
chat_model="grok-beta" # Example override
)
```
-410
View File
@@ -1,410 +0,0 @@
# Deploying MemU on Sealos DevBox
This guide demonstrates how to build and deploy a **Personal AI Assistant with Long-Term Memory** using MemU on [Sealos DevBox](https://sealos.io/products/devbox).
## Overview
MemU enables AI agents to maintain persistent, structured memory across conversations. Combined with Sealos DevBox's 1-click cloud development environment, you can quickly build and deploy memory-enabled AI applications.
**What we'll build:**
- A FastAPI-based AI assistant that remembers user preferences and past conversations
- Persistent memory storage using MemU's in-memory or PostgreSQL backend
- Simple REST API for chat interactions
- One-click deployment to production
**Time to complete:** ~15 minutes
## Prerequisites
- [Sealos account](https://sealos.io) (free tier available)
- OpenAI API key (or compatible provider like Nebius, Groq)
## Step 1: Create a DevBox Environment
1. Log in to [Sealos Dashboard](https://cloud.sealos.io)
2. Navigate to **DevBox** module
3. Click **Create New Project**
4. Select **Python 3.11+** template
5. Configure resources (recommended: 2 vCPU, 4GB RAM)
6. Click **Create** - your environment will be ready in ~60 seconds
## Step 2: Connect Your IDE
1. In the DevBox project list, click the **VS Code** or **Cursor** button
2. Your local IDE will open with a secure SSH connection to the cloud environment
3. All code runs in the cloud, keeping your local machine free
## Step 3: Set Up the Project
Open the terminal in your connected IDE and run:
```bash
# Clone or create project directory
mkdir memu-assistant && cd memu-assistant
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install memu fastapi uvicorn python-dotenv
```
## Step 4: Create the Application
Create the following files in your project:
### `.env`
```env
# LLM Provider Configuration
OPENAI_API_KEY=your_api_key_here
OPENAI_BASE_URL=https://api.openai.com/v1
# Or use Nebius (OpenAI-compatible)
# OPENAI_API_KEY=your_nebius_key
# OPENAI_BASE_URL=https://api.tokenfactory.nebius.com/v1/
# Model Configuration
CHAT_MODEL=gpt-4o-mini
EMBED_MODEL=text-embedding-3-small
# Server Configuration
HOST=0.0.0.0
PORT=8000
```
### `main.py`
```python
"""
Personal AI Assistant with Long-Term Memory
Powered by MemU + FastAPI on Sealos DevBox
"""
import os
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
load_dotenv()
# MemU imports
from memu.app import MemoryService
# Global memory service
memory_service: MemoryService | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize MemU on startup."""
global memory_service
llm_profiles = {
"default": {
"provider": "openai",
"base_url": os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
"api_key": os.getenv("OPENAI_API_KEY"),
"chat_model": os.getenv("CHAT_MODEL", "gpt-4o-mini"),
"client_backend": "sdk",
},
"embedding": {
"provider": "openai",
"base_url": os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
"api_key": os.getenv("OPENAI_API_KEY"),
"embed_model": os.getenv("EMBED_MODEL", "text-embedding-3-small"),
"client_backend": "sdk",
},
}
memory_service = MemoryService(llm_profiles=llm_profiles)
print("✓ MemU Memory Service initialized")
yield
print("Shutting down...")
app = FastAPI(
title="MemU Assistant",
description="AI Assistant with Long-Term Memory",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class ChatRequest(BaseModel):
message: str
user_id: str = "default"
class ChatResponse(BaseModel):
response: str
memories_used: int
memories_stored: int
class MemorizeRequest(BaseModel):
content: str
user_id: str = "default"
@app.get("/")
async def root():
return {
"service": "MemU Assistant",
"status": "running",
"endpoints": ["/chat", "/memorize", "/recall", "/health"],
}
@app.get("/health")
async def health():
return {"status": "healthy", "memory_service": memory_service is not None}
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""
Chat with the AI assistant. The assistant will:
1. Retrieve relevant memories from past conversations
2. Generate a response using those memories as context
3. Store new information from the conversation
"""
if not memory_service:
raise HTTPException(status_code=503, detail="Memory service not initialized")
# Step 1: Retrieve relevant memories
retrieve_result = await memory_service.retrieve(
queries=[{"role": "user", "content": request.message}]
)
memories = retrieve_result.get("items", [])
memories_context = ""
if memories:
memories_context = "\n\nRelevant memories from past conversations:\n"
for mem in memories[:5]: # Limit to top 5 memories
if isinstance(mem, dict):
memories_context += f"- {mem.get('summary', str(mem))}\n"
# Step 2: Generate response (simplified - in production, use full LLM call)
# For demo, we'll create a simple response acknowledging the memories
response_text = f"I received your message: '{request.message}'"
if memories:
response_text += f"\n\nI found {len(memories)} relevant memories that might help."
# Step 3: Store the conversation as a new memory
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(f"User ({request.user_id}): {request.message}")
temp_file = f.name
try:
memorize_result = await memory_service.memorize(
resource_url=temp_file,
modality="text",
)
memories_stored = len(memorize_result.get("items", []))
finally:
os.unlink(temp_file)
return ChatResponse(
response=response_text,
memories_used=len(memories),
memories_stored=memories_stored,
)
@app.post("/memorize")
async def memorize(request: MemorizeRequest):
"""Store information in long-term memory."""
if not memory_service:
raise HTTPException(status_code=503, detail="Memory service not initialized")
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(request.content)
temp_file = f.name
try:
result = await memory_service.memorize(
resource_url=temp_file,
modality="text",
)
return {
"status": "stored",
"items_created": len(result.get("items", [])),
"categories": len(result.get("categories", [])),
}
finally:
os.unlink(temp_file)
@app.get("/recall")
async def recall(query: str, limit: int = 5):
"""Recall memories related to a query."""
if not memory_service:
raise HTTPException(status_code=503, detail="Memory service not initialized")
result = await memory_service.retrieve(
queries=[{"role": "user", "content": query}]
)
items = result.get("items", [])[:limit]
return {
"query": query,
"memories_found": len(items),
"memories": [
{"summary": item.get("summary", str(item)) if isinstance(item, dict) else str(item)}
for item in items
],
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", 8000)),
reload=True,
)
```
### `requirements.txt`
```
memu>=0.1.0
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
python-dotenv>=1.0.0
```
### `entrypoint.sh`
```bash
#!/bin/bash
source venv/bin/activate
uvicorn main:app --host 0.0.0.0 --port 8000
```
## Step 5: Test Locally in DevBox
```bash
# Run the application
python main.py
```
Use the DevBox preview feature to access your running application, or test with curl:
```bash
# Health check
curl http://localhost:8000/health
# Store a memory
curl -X POST http://localhost:8000/memorize \
-H "Content-Type: application/json" \
-d '{"content": "User prefers dark mode and uses Python for AI development"}'
# Chat with memory
curl -X POST http://localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{"message": "What programming language do I use?"}'
# Recall memories
curl "http://localhost:8000/recall?query=programming%20preferences"
```
## Step 6: Deploy to Production
1. In the Sealos Dashboard, go to your DevBox project
2. Click **Create Release** to package your application
3. Click **Deploy** next to your release
4. Configure environment variables (OPENAI_API_KEY, etc.)
5. Click **Deploy** - your app will be live in minutes!
Your application will receive a public URL like: `https://your-app.cloud.sealos.io`
## Using with PostgreSQL (Optional)
For production deployments with persistent storage:
1. In Sealos Dashboard, go to **Database** module
2. Create a PostgreSQL instance
3. Update your `.env` with the connection string:
```env
DATABASE_URL=postgresql://user:password@host:5432/memu
```
4. Update `main.py` to use PostgreSQL backend (see MemU documentation)
## API Reference
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Service info |
| `/health` | GET | Health check |
| `/chat` | POST | Chat with memory-aware AI |
| `/memorize` | POST | Store information in memory |
| `/recall` | GET | Query stored memories |
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Sealos DevBox │
│ ┌─────────────────────────────────────────────────┐ │
│ │ FastAPI Application │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ │
│ │ │ /chat │ │/memorize│ │ /recall │ │ │
│ │ └────┬────┘ └────┬────┘ └────────┬────────┘ │ │
│ │ │ │ │ │ │
│ │ └────────────┼────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────▼───────┐ │ │
│ │ │ MemU Service │ │ │
│ │ │ (Memory Mgmt)│ │ │
│ │ └───────┬───────┘ │ │
│ │ │ │ │
│ │ ┌────────────┼────────────┐ │ │
│ │ │ │ │ │ │
│ │ ┌────▼────┐ ┌────▼────┐ ┌───▼────┐ │ │
│ │ │ Vector │ │ LLM │ │Postgres│ │ │
│ │ │ Store │ │ API │ │(opt.) │ │ │
│ │ └─────────┘ └─────────┘ └────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Benefits of This Setup
- **Zero Infrastructure Management**: Sealos handles Kubernetes complexity
- **Instant Environment**: Ready-to-code in 60 seconds
- **Persistent Memory**: MemU maintains context across sessions
- **Scalable**: Easily scale resources as needed
- **Cost-Effective**: Pay only for what you use
## Next Steps
- Add authentication for multi-user support
- Integrate with Slack, Discord, or other platforms
- Use PostgreSQL for production-grade persistence
- Add conversation history UI
## Resources
- [MemU Documentation](https://github.com/NevaMind-AI/memU)
- [Sealos DevBox Guide](https://sealos.io/blog/how-to-setup-devbox)
- [FastAPI Documentation](https://fastapi.tiangolo.com)
---
*This guide was created for the MemU PR Hackathon - 2026 New Year Challenge (Issue #228)*
-54
View File
@@ -1,54 +0,0 @@
# 🛡️ Context-Aware Support Agent (Sealos Edition)
## Overview
This use case demonstrates how **MemU** enables a support agent to remember user history across sessions, deployed on a **Sealos Devbox** environment.
Unlike a standard web app, this demo focuses on the **backend memory orchestration**. It runs as a **CLI (Command Line Interface)** tool to transparently show the internal memory logs, retrieval process, and state persistence without the abstraction layer of a UI.
## 🚀 Quick Start
### Prerequisites
- Sealos Devbox Environment
- Python 3.13+
- MemU Library (installed via `make install`)
### How to Run the Demo
Since this is a backend demonstration, you will run the agent directly in the terminal to observe the memory cycle.
```bash
uv run python examples/sealos_support_agent.py
```
## 📸 Live Demo Output (Proof of Concept)
Below is the actual output captured from the Sealos terminal. This serves as verification of the "Demonstration Quality" requirement.
```plaintext
🚀 Starting Sealos Support Agent Demo (Offline Mode)
📝 --- Phase 1: Ingesting Conversation History ---
👤 Captain: "I'm getting a 502 Bad Gateway error on port 3000."
🤖 Agent: (Memorizing this interaction...)
✅ Memory stored! extracted 2 items.
- [profile] Captain reported a 502 Bad Gateway error on port 3000.
🔍 --- Phase 2: Retrieval on New Interaction ---
👤 Captain: "Hello"
🤖 Agent: (Searching memory for context...)
💡 Retrieved Context:
Found Memory: Captain reported a 502 Bad Gateway error on port 3000.
💬 --- Phase 3: Agent Response ---
🤖 Agent: "Welcome back, Captain. I see you had a 502 error on port 3000 recently. Is that resolved?"
✨ Demo Completed Successfully
```
## 💡 Code Highlights & Justification
- **CLI vs Web**: We chose a CLI implementation to provide clear visibility into the memory ingestion and retrieval logs, which are often hidden in web implementations.
- **MockLLM**: Includes a MockLLM class to ensure the demo is 100% reproducible by reviewers without needing external API keys.
- **Sealos Native**: Optimized to run within the ephemeral Sealos Devbox container lifecycle.
+16 -16
View File
@@ -143,14 +143,14 @@ postgres_db = build_postgres_database(config=postgres_config, user_model=UserSco
for res_id, resource in sqlite_db.resources.items():
postgres_db.resource_repo.create_resource(
url=resource.url,
modality=resource.modality,
local_path=resource.local_path,
caption=resource.caption,
embedding=resource.embedding,
user_data={"user_id": getattr(resource, "user_id", None)},
track=resource.track,
)
# Similar for categories, items, and relations...
# Similar for recall files, segments, and relations...
```
## Performance Considerations
@@ -172,7 +172,6 @@ from memu.app import MemoryService
async def main():
# Initialize with SQLite
service = MemoryService(
llm_profiles={"default": {"api_key": "your-api-key"}},
database_config={
"metadata_store": {
"provider": "sqlite",
@@ -181,24 +180,25 @@ async def main():
},
)
# Memorize a conversation
result = await service.memorize(
resource_url="conversation.json",
modality="conversation",
# Commit prepared memory
await service.commit_results(
recall_files=[
{
"name": "Preferences",
"track": "memory",
"description": "what alice likes",
"content": "# Preferences\n- prefers dark roast coffee",
}
],
user={"user_id": "alice"},
)
print(f"Created {len(result['categories'])} categories")
# Retrieve relevant memories
memories = await service.retrieve(
queries=[
{"role": "user", "content": {"text": "What are my preferences?"}}
],
where={"user_id": "alice"},
context = await service.progressive_retrieve(
"What are my preferences?", where={"user_id": "alice"}
)
for item in memories.get("items", []):
print(f"- {item['summary']}")
for segment in context["segments"]:
print(f"- {segment['text']} ({segment['score']:.2f})")
asyncio.run(main())
```
-181
View File
@@ -1,181 +0,0 @@
# Quickstart: Adding Long-Term Memory to Python Agents
Welcome to MemU! This guide will help you add robust long-term memory capabilities to your Python agents in just a few minutes. Without MemU, LLMs are limited by their context window. MemU solves this by providing an intelligent, persistent memory layer.
## Prerequisites
Before we begin, ensure you have the following:
- **Python 3.13+**: MemU takes advantage of modern Python features.
- **OpenAI API Key**: This quickstart uses OpenAI's models (`gpt-4o-mini`). You will need a valid API key.
## Step-by-Step Guide
### 1. Installation
Install MemU using `pip` or `uv`:
```bash
pip install memu
# OR
uv add memu
```
### 2. Configuration
MemU requires an LLM backend to function. By default, it looks for the `OPENAI_API_KEY` environment variable.
**Linux / macOS / Git Bash:**
```bash
export OPENAI_API_KEY=sk-proj-your-api-key
```
**Windows (PowerShell):**
```powershell
$env:OPENAI_API_KEY="sk-proj-your-api-key"
```
### 3. The Robust Starter Script
Below is a complete, production-ready script that demonstrates the full lifecycle of a memory-enabled agent: **Initialization**, **Injection** (adding memory), and **Retrieval** (searching memory).
Create a file named `getting_started.py` and paste the following code:
```python
"""
Getting Started with MemU: A Robust Example.
This script demonstrates the core lifecycle of MemU:
1. **Initialization**: Setting up the client with secure API key handling.
2. **Memory Injection**: Adding a specific memory with metadata.
3. **Retrieval**: Searching for that memory using natural language.
4. **Error Handling**: Catching common configuration issues.
Usage:
export OPENAI_API_KEY=your_api_key_here
python getting_started.py
"""
import asyncio
import logging
import os
import sys
from memu.app import MemoryService
# Configure logging to show info but suppress noisy libraries
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
async def main() -> None:
"""Run the MemU lifecycle demonstration."""
print(">>> MemU Getting Started Example")
print("-" * 30)
# 1. API Key Handling
# MemU relies on an LLM backend (defaulting to OpenAI).
# We ensure the API key is present before proceeding.
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
print("[!] Error: OPENAI_API_KEY environment variable is not set.")
print("Please export it: export OPENAI_API_KEY=sk-...")
return
try:
# 2. Initialization
# We initialize the MemoryService with:
# - llm_profiles: Configuration for the LLM (model, api_key).
# - memorize_config: Pre-defining a memory category ensures we can organize memories efficiently.
print(f"[*] Initializing MemoryService with model: gpt-4o-mini...")
service = MemoryService(
llm_profiles={
"default": {
"api_key": api_key,
"chat_model": "gpt-4o-mini",
},
},
memorize_config={
"memory_categories": [
{
"name": "User Facts",
"description": "General and specific facts known about the user preference and identity.",
}
]
},
)
print("[OK] Service initialized successfully.\n")
# 3. Memory Injection
# We manually inject a memory into the system.
# This is useful for bootstrapping a user profile or adding explicit knowledge.
print("[*] Injecting memory...")
memory_content = "The user is a senior Python architect who loves clean code and type hints."
# We use 'create_memory_item' to insert a single memory record.
# memory_type='profile' indicates this is an attribute of the user.
result = await service.create_memory_item(
memory_type="profile",
memory_content=memory_content,
memory_categories=["User Facts"],
)
print(f"[OK] Memory created! ID: {result.get('memory_item', {}).get('id')}\n")
# 4. Retrieval
# Now we query the system naturally to see if it recalls the information.
query_text = "What kind of code does the user like?"
print(f"[*] Querying: '{query_text}'")
search_results = await service.retrieve(
queries=[{"role": "user", "content": query_text}]
)
# 5. Display Results
items = search_results.get("items", [])
if items:
print(f"[OK] Found {len(items)} relevant memory item(s):")
for idx, item in enumerate(items, 1):
print(f" {idx}. {item.get('summary')} (Type: {item.get('memory_type')})")
else:
print("[!] No relevant memories found.")
except Exception as e:
print(f"\n[!] An error occurred during execution: {e}")
logging.exception("Detailed traceback:")
finally:
print("\n[=] Example execution finished.")
if __name__ == "__main__":
asyncio.run(main())
```
### Understanding the Code
1. **Initialization**: We configure `MemoryService` with specific `llm_profiles`. This tells MemU which model to use. We also define a `memorize_config` with a "User Facts" category. Categories help the LLM organize and retrieve information more effectively.
2. **Memory Injection**: `create_memory_item` is used to explicitly add a piece of knowledge. We tag it with `memory_type="profile"` to semantically indicate this is a user attribute.
3. **Retrieval**: We use `retrieve` with a natural language query. MemU's internal workflow ("RAG" or "LLM" based) will determine the best way to find relevant memories.
## Troubleshooting
### `[!] Error: OPENAI_API_KEY environment variable is not set.`
This is the most common issue. It means the script cannot find your API key which is required to communicate with OpenAI.
**Solution:**
Ensure you have exported the key in your **current terminal session**.
- **Windows PowerShell**: `$env:OPENAI_API_KEY="sk-..."`
- **Linux/Mac**: `export OPENAI_API_KEY=sk-...`
Also, verify that you didn't accidentally include spaces around the `=` sign in bash.
## Next Steps
Now that you have the basics running, consider exploring:
- **Core Concepts**: Learn about `MemoryService`, `MemoryItem`, and `MemoryCategory`.
- **Advanced Configuration**: Switch to local LLMs or use different vector stores.
- **Integrations**: Connect MemU to your existing agent framework.
## Community Resources
This tutorial was created as part of the MemU 2026 Challenge. For a summary of the architectural analysis, see the author's [LinkedIn Post](https://www.linkedin.com/posts/david-a-mamani-c_github-nevamind-aimemu-memory-infrastructure-activity-7418493617482207232-_MtG?utm_source=share&utm_medium=member_desktop&rcm=ACoAAFdc0CIB__DJovR2t1BOxxJ6tgEeOqVEgx4).
-117
View File
@@ -1,117 +0,0 @@
"""
Example 1: Multiple Conversations -> Memory Category File
This example demonstrates how to process multiple conversation files
and generate a memory category JSON file.
Usage:
export OPENAI_API_KEY=your_api_key
python examples/example_1_conversation_memory.py
"""
import asyncio
import os
import sys
from memu.app import MemoryService
# Add src to sys.path
src_path = os.path.abspath("src")
sys.path.insert(0, src_path)
async def generate_memory_md(categories, output_dir):
"""Generate concise markdown files for each memory category."""
os.makedirs(output_dir, exist_ok=True)
generated_files = []
for cat in categories:
name = cat.get("name", "unknown")
summary = cat.get("summary", "")
filename = f"{name}.md"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
# Title
# Content - concise version
if summary:
cleaned_summary = summary.replace("<content>", "").replace("</content>", "").strip()
f.write(f"{cleaned_summary}\n")
else:
f.write("*No content available*\n")
generated_files.append(filename)
return generated_files
async def main():
"""
Process multiple conversation files and generate memory categories.
This example:
1. Initializes MemoryService with OpenAI API
2. Processes conversation JSON files
3. Extracts memory categories from conversations
4. Outputs the categories to files
"""
print("Example 1: Conversation Memory Processing")
print("-" * 50)
# Get OpenAI API key from environment
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
msg = "Please set OPENAI_API_KEY environment variable"
raise ValueError(msg)
# Initialize service with OpenAI using llm_profiles
# The "default" profile is required and used as the primary LLM configuration
service = MemoryService(
llm_profiles={
"default": {
"api_key": api_key,
"chat_model": "gpt-4o-mini",
},
},
)
# Conversation files to process
conversation_files = [
"examples/resources/conversations/conv1.json",
"examples/resources/conversations/conv2.json",
"examples/resources/conversations/conv3.json",
]
# Process each conversation
print("\nProcessing conversations...")
total_items = 0
categories = []
for conv_file in conversation_files:
if not os.path.exists(conv_file):
continue
try:
result = await service.memorize(resource_url=conv_file, modality="conversation")
total_items += len(result.get("items", []))
# Categories are returned in the result and updated after each memorize call
categories = result.get("categories", [])
except Exception as e:
print(f"Error: {e}")
# Write to output files
output_dir = "examples/output/conversation_example"
os.makedirs(output_dir, exist_ok=True)
# 1. Generate individual Markdown files for each category
await generate_memory_md(categories, output_dir)
print(f"\n✓ Processed {len(conversation_files)} files, extracted {total_items} items")
print(f"✓ Generated {len(categories)} categories")
print(f"✓ Output: {output_dir}/")
if __name__ == "__main__":
asyncio.run(main())
-274
View File
@@ -1,274 +0,0 @@
"""
Example 2: Workflow & Agent Logs -> Skill Extraction
This example demonstrates how to extract skills from workflow descriptions
and agent runtime logs, then output them to a Markdown file.
Usage:
export OPENAI_API_KEY=your_api_key
python examples/example_2_skill_extraction.py
"""
import asyncio
import os
import sys
from openai import AsyncOpenAI
from memu.app import MemoryService
# Add src to sys.path
src_path = os.path.abspath("src")
sys.path.insert(0, src_path)
async def generate_skill_md(
all_skills, service, output_file, attempt_number, total_attempts, categories=None, is_final=False
):
"""
Use LLM to generate a concise task execution guide (skill.md).
This creates a production-ready guide incorporating lessons learned from deployment attempts.
"""
os.makedirs(os.path.dirname(output_file), exist_ok=True)
# Prepare context for LLM
skills_text = "\n\n".join([f"### From {skill_data['source']}\n{skill_data['skill']}" for skill_data in all_skills])
# Get category summaries if available
categories_text = ""
if categories:
categories_with_content = [cat for cat in categories if cat.get("summary") and cat.get("summary").strip()]
if categories_with_content:
categories_text = "\n\n".join([
f"**{cat.get('name', 'unknown')}**:\n{cat.get('summary', '')}" for cat in categories_with_content
])
# Construct prompt for LLM
prompt = f"""Generate a concise production-ready task execution guide.
**Context**:
- Task: Production Microservice Deployment with Blue-Green Strategy
- Progress: {attempt_number}/{total_attempts} attempts
- Status: {"Complete" if is_final else f"v0.{attempt_number}"}
**Skills Learned**:
{skills_text}
{f"**Categories**:\n{categories_text}" if categories_text else ""}
**Required Structure**:
1. **Frontmatter** (YAML):
- name: production-microservice-deployment
- description: Brief description
- version: {"1.0.0" if is_final else f"0.{attempt_number}.0"}
- status: {"Production-Ready" if is_final else "Evolving"}
2. **Introduction**: What this guide does and when to use it
3. **Deployment Context**: Strategy, environment, goals
4. **Pre-Deployment Checklist**:
- Actionable checks from lessons learned
- Group by category (Database, Monitoring, etc.)
- Mark critical items
5. **Deployment Procedure**:
- Step-by-step instructions with commands
- Include monitoring points
6. **Rollback Procedure**:
- When to rollback (thresholds)
- Exact commands
- Expected recovery time
7. **Common Pitfalls & Solutions**:
- Failures/issues encountered
- Root cause, symptoms, solution
8. **Best Practices**:
- What works well
- Expected timelines
9. **Key Takeaways**: 3-5 most important lessons
**Style**:
- Use markdown with clear hierarchy
- Be specific and concise
- Technical and production-grade tone
- Focus on PRACTICAL steps
**CRITICAL**:
- ONLY use information from provided skills/lessons
- DO NOT make assumptions or add generic advice
- Extract ACTUAL experiences from the logs
Generate the complete markdown document now:"""
client = AsyncOpenAI(api_key=service.llm_config.api_key)
response = await client.chat.completions.create(
model=service.llm_config.chat_model,
messages=[
{
"role": "system",
"content": "You are an expert technical writer creating concise, production-grade deployment guides from real experiences.",
},
{"role": "user", "content": prompt},
],
temperature=0.7,
max_tokens=3000,
)
generated_content = response.choices[0].message.content
# Write to file
with open(output_file, "w", encoding="utf-8") as f:
f.write(generated_content)
return True
async def main():
"""
Extract skills from agent logs using incremental memory updates.
This example demonstrates INCREMENTAL LEARNING:
1. Process files ONE BY ONE
2. Each file UPDATES existing memory
3. Category summaries EVOLVE with each new file
4. Final output shows accumulated knowledge
"""
print("Example 2: Incremental Skill Extraction")
print("-" * 50)
# Get OpenAI API key from environment
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
msg = "Please set OPENAI_API_KEY environment variable"
raise ValueError(msg)
# Custom config for skill extraction
skill_prompt = """
You are analyzing an agent execution log. Extract the key actions taken, their outcomes, and lessons learned.
For each significant action or phase:
1. **Action/Phase**: What was being attempted?
2. **Status**: SUCCESS ✅ or FAILURE ❌
3. **What Happened**: What was executed
4. **Outcome**: What worked/failed, metrics
5. **Root Cause** (for failures): Why did it fail?
6. **Lesson**: What did we learn?
7. **Action Items**: Concrete steps for next time
**IMPORTANT**:
- Focus on ACTIONS and outcomes
- Be specific: include actual metrics, errors, timing
- ONLY extract information explicitly stated
- DO NOT infer or assume information
Extract ALL significant actions from the text:
Text: {resource}
"""
# Define custom categories
skill_categories = [
{"name": "deployment_execution", "description": "Deployment actions, traffic shifting, environment management"},
{
"name": "pre_deployment_validation",
"description": "Capacity validation, configuration checks, readiness verification",
},
{
"name": "incident_response_rollback",
"description": "Incident response, error detection, rollback procedures",
},
{
"name": "performance_monitoring",
"description": "Metrics monitoring, performance analysis, bottleneck detection",
},
{"name": "database_management", "description": "Database capacity planning, optimization, schema changes"},
{"name": "testing_verification", "description": "Testing, smoke tests, load tests, verification"},
{"name": "infrastructure_setup", "description": "Kubernetes, containers, networking configuration"},
{"name": "lessons_learned", "description": "Key reflections, root cause analyses, action items"},
]
memorize_config = {
"memory_types": ["skill"],
"memory_type_prompts": {"skill": skill_prompt},
"memory_categories": skill_categories,
}
# Initialize service with OpenAI using llm_profiles
# The "default" profile is required and used as the primary LLM configuration
service = MemoryService(
llm_profiles={
"default": {
"api_key": api_key,
"chat_model": "gpt-4o-mini",
},
},
memorize_config=memorize_config,
)
# Resources to process
resources = [
("examples/resources/logs/log1.txt", "document"),
("examples/resources/logs/log2.txt", "document"),
("examples/resources/logs/log3.txt", "document"),
]
# Process each resource sequentially
print("\nProcessing files...")
all_skills = []
categories = []
for idx, (resource_file, modality) in enumerate(resources, 1):
if not os.path.exists(resource_file):
continue
try:
result = await service.memorize(resource_url=resource_file, modality=modality)
# Extract skill items
for item in result.get("items", []):
if item.get("memory_type") == "skill":
all_skills.append({"skill": item.get("summary", ""), "source": os.path.basename(resource_file)})
# Categories are returned in the result and updated after each memorize call
categories = result.get("categories", [])
# Generate intermediate skill.md
await generate_skill_md(
all_skills=all_skills,
service=service,
output_file=f"examples/output/skill_example/log_{idx}.md",
attempt_number=idx,
total_attempts=len(resources),
categories=categories,
)
except Exception as e:
print(f"Error: {e}")
# Generate final comprehensive skill.md
await generate_skill_md(
all_skills=all_skills,
service=service,
output_file="examples/output/skill_example/skill.md",
attempt_number=len(resources),
total_attempts=len(resources),
categories=categories,
is_final=True,
)
print(f"\n✓ Processed {len(resources)} files, extracted {len(all_skills)} skills")
print(f"✓ Generated {len(categories)} categories")
print("✓ Output: examples/output/skill_example/")
if __name__ == "__main__":
asyncio.run(main())
-137
View File
@@ -1,137 +0,0 @@
"""
Example 3: Multimodal Processing -> Memory Category File
This example demonstrates how to process multiple modalities (images, documents)
and generate a unified memory category JSON file.
Usage:
export OPENAI_API_KEY=your_api_key
python examples/example_3_multimodal_memory.py
"""
import asyncio
import os
import sys
from memu.app import MemoryService
# Add src to sys.path
src_path = os.path.abspath("src")
sys.path.insert(0, src_path)
async def generate_memory_md(categories, output_dir):
"""Generate concise markdown files for each memory category."""
os.makedirs(output_dir, exist_ok=True)
generated_files = []
for cat in categories:
name = cat.get("name", "unknown")
description = cat.get("description", "")
summary = cat.get("summary", "")
filename = f"{name}.md"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
# Title
formatted_name = name.replace("_", " ").title()
f.write(f"# {formatted_name}\n\n")
if description:
f.write(f"*{description}*\n\n")
# Content - full version
if summary:
cleaned_summary = summary.replace("<content>", "").replace("</content>", "").strip()
f.write(f"{cleaned_summary}\n")
else:
f.write("*No content available*\n")
generated_files.append(filename)
return generated_files
async def main():
"""
Process multiple modalities (images and documents) to generate memory categories.
This example:
1. Initializes MemoryService with OpenAI API
2. Processes documents and images
3. Extracts unified memory categories across modalities
4. Outputs the categories to files
"""
print("Example 3: Multimodal Memory Processing")
print("-" * 50)
# Get OpenAI API key from environment
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
msg = "Please set OPENAI_API_KEY environment variable"
raise ValueError(msg)
# Define custom categories for multimodal content
multimodal_categories = [
{"name": "technical_documentation", "description": "Technical documentation, guides, and tutorials"},
{
"name": "architecture_concepts",
"description": "System architecture, design patterns, and structural concepts",
},
{"name": "best_practices", "description": "Best practices, recommendations, and guidelines"},
{"name": "code_examples", "description": "Code snippets, examples, and implementation details"},
{"name": "visual_diagrams", "description": "Visual concepts, diagrams, charts, and illustrations from images"},
]
# Initialize service with OpenAI using llm_profiles
# The "default" profile is required and used as the primary LLM configuration
service = MemoryService(
llm_profiles={
"default": {
"api_key": api_key,
"chat_model": "gpt-4o-mini",
},
},
memorize_config={"memory_categories": multimodal_categories},
)
# Resources to process (file_path, modality)
resources = [
("examples/resources/docs/doc1.txt", "document"),
("examples/resources/docs/doc2.txt", "document"),
("examples/resources/images/image1.png", "image"),
]
# Process each resource
print("\nProcessing resources...")
total_items = 0
categories = []
for resource_file, modality in resources:
if not os.path.exists(resource_file):
continue
try:
result = await service.memorize(resource_url=resource_file, modality=modality)
total_items += len(result.get("items", []))
# Categories are returned in the result and updated after each memorize call
categories = result.get("categories", [])
except Exception as e:
print(f"Error: {e}")
# Write to output files
output_dir = "examples/output/multimodal_example"
os.makedirs(output_dir, exist_ok=True)
# 1. Generate individual Markdown files for each category
await generate_memory_md(categories, output_dir)
print(f"\n✓ Processed {len(resources)} files, extracted {total_items} items")
print(f"✓ Generated {len(categories)} categories")
print(f"✓ Output: {output_dir}/")
if __name__ == "__main__":
asyncio.run(main())
-112
View File
@@ -1,112 +0,0 @@
"""
Example 4: Multiple Conversations -> Memory Category File (Using OpenRouter)
This example demonstrates how to process multiple conversation files
and generate memory categories using OpenRouter as the LLM backend.
Usage:
export OPENROUTER_API_KEY=your_api_key
python examples/example_4_openrouter_memory.py
"""
import asyncio
import os
import sys
from memu.app import MemoryService
src_path = os.path.abspath("src")
sys.path.insert(0, src_path)
async def generate_memory_md(categories, output_dir):
"""Generate concise markdown files for each memory category."""
os.makedirs(output_dir, exist_ok=True)
generated_files = []
for cat in categories:
name = cat.get("name", "unknown")
summary = cat.get("summary", "")
filename = f"{name}.md"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
if summary:
cleaned_summary = summary.replace("<content>", "").replace("</content>", "").strip()
f.write(f"{cleaned_summary}\n")
else:
f.write("*No content available*\n")
generated_files.append(filename)
return generated_files
async def main():
"""
Process multiple conversation files and generate memory categories using OpenRouter.
This example:
1. Initializes MemoryService with OpenRouter API
2. Processes conversation JSON files
3. Extracts memory categories from conversations
4. Outputs the categories to files
"""
print("Example 4: Conversation Memory Processing (OpenRouter)")
print("-" * 50)
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
msg = "Please set OPENROUTER_API_KEY environment variable"
raise ValueError(msg)
# Initialize service with OpenRouter
service = MemoryService(
llm_profiles={
"default": {
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
"api_key": api_key,
"chat_model": "anthropic/claude-3.5-sonnet", # you can use any model from openrouter.ai
"embed_model": "openai/text-embedding-3-small", # you can use any model from openrouter.ai
},
},
)
conversation_files = [
"examples/resources/conversations/conv1.json",
"examples/resources/conversations/conv2.json",
"examples/resources/conversations/conv3.json",
]
print("\nProcessing conversations...")
total_items = 0
categories = []
for conv_file in conversation_files:
if not os.path.exists(conv_file):
print(f"Skipped: {conv_file} not found")
continue
try:
print(f"Processing: {conv_file}")
result = await service.memorize(resource_url=conv_file, modality="conversation")
total_items += len(result.get("items", []))
categories = result.get("categories", [])
except Exception as e:
print(f"Error processing {conv_file}: {e}")
output_dir = "examples/output/openrouter_example"
os.makedirs(output_dir, exist_ok=True)
await generate_memory_md(categories, output_dir)
print(f"\nProcessed {len(conversation_files)} files, extracted {total_items} items")
print(f"Generated {len(categories)} categories")
print(f"Output: {output_dir}/")
if __name__ == "__main__":
asyncio.run(main())
-250
View File
@@ -1,250 +0,0 @@
"""
Unified Example: LazyLLM Integration Demo
=========================================
This example merges functionalities from:
1. Example 1: Conversation Memory Processing
2. Example 2: Skill Extraction
3. Example 3: Multimodal Processing
It demonstrates how to use the LazyLLM backend for:
- Processing conversation history
- Extracting technical skills from logs
- Handling multimodal content (images + text)
- defaut source and model are from qwen
Usage:
export MEMU_QWEN_API_KEY=your_api_key
python examples/example_5_with_lazyllm_client.py
"""
import asyncio
import os
import sys
from pathlib import Path
# Add src to sys.path FIRST before importing memu
project_root = Path(__file__).parent.parent
src_path = str(project_root / "src")
if src_path not in sys.path:
sys.path.insert(0, src_path)
from memu.app import MemoryService
# ==========================================
# PART 1: Conversation Memory Processing
# ==========================================
async def run_conversation_memory_demo(service):
print("\n" + "=" * 60)
print("PART 1: Conversation Memory Processing")
print("=" * 60)
conversation_files = [
"examples/resources/conversations/conv1.json",
"examples/resources/conversations/conv2.json",
"examples/resources/conversations/conv3.json",
]
total_items = 0
categories = []
for conv_file in conversation_files:
if not os.path.exists(conv_file):
print(f"⚠ File not found: {conv_file}")
continue
try:
print(f" Processing: {conv_file}")
result = await service.memorize(resource_url=conv_file, modality="conversation")
total_items += len(result.get("items", []))
categories = result.get("categories", [])
print(f" ✓ Extracted {len(result.get('items', []))} items")
except Exception as e:
print(f" ✗ Error processing {conv_file}: {e}")
# Output generation
output_dir = "examples/output/lazyllm_example/conversation"
os.makedirs(output_dir, exist_ok=True)
await generate_markdown_output(categories, output_dir)
print(f"✓ Conversation processing complete. Output: {output_dir}")
# ==========================================
# PART 2: Skill Extraction
# ==========================================
async def run_skill_extraction_demo(service):
print("\n" + "=" * 60)
print("PART 2: Skill Extraction from Logs")
print("=" * 60)
# Configure prompt for skill extraction
skill_prompt = """
You are analyzing an agent execution log. Extract the key actions taken, their outcomes, and lessons learned.
Output MUST be valid XML wrapped in <skills> tags.
Format:
<skills>
<memory>
<content>
[Action] Description...
[Lesson] Key lesson...
</content>
<categories>
<category>Category Name</category>
</categories>
</memory>
</skills>
Text: {resource}
"""
# Update service config for skill extraction
service.memorize_config.memory_types = ["skill"]
service.memorize_config.memory_type_prompts = {"skill": skill_prompt}
logs = ["examples/resources/logs/log1.txt", "examples/resources/logs/log2.txt", "examples/resources/logs/log3.txt"]
all_skills = []
for log_file in logs:
if not os.path.exists(log_file):
continue
print(f" Processing log: {log_file}")
try:
result = await service.memorize(resource_url=log_file, modality="document")
for item in result.get("items", []):
if item.get("memory_type") == "skill":
all_skills.append(item.get("summary", ""))
print(f" ✓ Extracted {len(result.get('items', []))} skills")
except Exception as e:
print(f" ✗ Error: {e}")
# Generate summary guide
if all_skills:
output_file = "examples/output/lazyllm_example/skills/skill_guide.md"
await generate_skill_guide(all_skills, service, output_file)
print(f"✓ Skill guide generated: {output_file}")
# ==========================================
# PART 3: Multimodal Memory
# ==========================================
async def run_multimodal_demo(service):
print("\n" + "=" * 60)
print("PART 3: Multimodal Memory Processing")
print("=" * 60)
# Configure for knowledge extraction
xml_prompt = """
Analyze content and extract key information.
Output MUST be valid XML wrapped in <knowledge> tags.
Format:
<knowledge>
<memory>
<content>Extracted content...</content>
<categories><category>category_name</category></categories>
</memory>
</knowledge>
Content: {resource}
"""
service.memorize_config.memory_types = ["knowledge"]
service.memorize_config.memory_type_prompts = {"knowledge": xml_prompt}
resources = [
("examples/resources/docs/doc1.txt", "document"),
("examples/resources/images/image1.png", "image"),
]
categories = []
for res_file, modality in resources:
if not os.path.exists(res_file):
continue
print(f" Processing {modality}: {res_file}")
try:
result = await service.memorize(resource_url=res_file, modality=modality)
categories = result.get("categories", [])
print(f" ✓ Extracted {len(result.get('items', []))} items")
except Exception as e:
print(f" ✗ Error: {e}")
output_dir = "examples/output/lazyllm_example/multimodal"
os.makedirs(output_dir, exist_ok=True)
await generate_markdown_output(categories, output_dir)
print(f"✓ Multimodal processing complete. Output: {output_dir}")
# ==========================================
# Helpers
# ==========================================
async def generate_markdown_output(categories, output_dir):
for cat in categories:
name = cat.get("name", "unknown")
summary = cat.get("summary", "")
if not summary:
continue
with open(os.path.join(output_dir, f"{name}.md"), "w", encoding="utf-8") as f:
f.write(f"# {name.replace('_', ' ').title()}\n\n")
cleaned = summary.replace("<content>", "").replace("</content>", "").strip()
f.write(cleaned)
async def generate_skill_guide(skills, service, output_file):
os.makedirs(os.path.dirname(output_file), exist_ok=True)
skills_text = "\n\n".join(skills)
prompt = f"Summarize these skills into a guide:\n\n{skills_text}"
# Use LazyLLM via service
summary = await service.llm_client.chat(text=prompt)
with open(output_file, "w", encoding="utf-8") as f:
f.write(summary)
# ==========================================
# Main Entry
# ==========================================
async def main():
print("Unified LazyLLM Example")
print("=" * 60)
# 1. Initialize Shared Service
service = MemoryService(
llm_profiles={
"default": {
"client_backend": "lazyllm_backend",
"chat_model": "qwen3-max",
"embed_model": "text-embedding-v3",
"lazyllm_source": {
"source": "qwen",
"llm_source": "qwen",
"vlm_source": "qwen",
"embed_source": "qwen",
"stt_source": "qwen",
"vlm_model": "qwen-vl-plus",
"stt_model": "qwen-audio-turbo",
},
},
}
)
# 2. Run Demos
await run_conversation_memory_demo(service)
# await run_skill_extraction_demo(service)
# await run_multimodal_demo(service)
if __name__ == "__main__":
asyncio.run(main())
-140
View File
@@ -1,140 +0,0 @@
"""
Example 6: Multimodal Preprocessing -> Extracted Text + Caption
This example exercises memU's preprocessing stage for every supported modality
(conversation, document, image, audio, video) and prints what each preprocessor
extracts. It is a quick way to verify that multimodal extraction works end to end
against a real provider:
- conversation / document : chat LLM (text understanding)
- image / video : VLM vision (video uses a sampled mid-frame)
- audio : speech-to-text transcription + chat LLM cleanup
Requirements:
- An OpenAI API key with access to a chat model, a vision model, and the
transcription model (``gpt-4o-mini-transcribe``).
- ``ffmpeg``/``ffprobe`` on PATH for the video frame extraction.
- Optional document extras for PDF/Office input: ``pip install 'memu-py[document]'``.
Usage:
export OPENAI_API_KEY=your_api_key
# Optional model overrides (defaults to the library defaults):
# export MEMU_CHAT_MODEL=gpt-4o-mini
# export MEMU_VLM_MODEL=gpt-4o-mini
python examples/example_6_preprocess_modalities.py
"""
from __future__ import annotations
import asyncio
import os
import sys
from memu.app import MemoryService
# Allow running from a source checkout without installing the package.
sys.path.insert(0, os.path.abspath("src"))
RESOURCES = "examples/resources"
# (modality, file). Vision modalities are routed to the VLM client automatically.
CASES: list[tuple[str, str]] = [
("conversation", f"{RESOURCES}/conversations/conv1.json"),
("document", f"{RESOURCES}/docs/doc1.txt"),
("document", f"{RESOURCES}/docs/doc_sample.pdf"),
("image", f"{RESOURCES}/images/image1.png"),
("audio", f"{RESOURCES}/audio/audio_intro.mp3"),
("video", f"{RESOURCES}/video/video_test.mp4"),
]
VISION_MODALITIES = {"image", "video"}
def build_service() -> MemoryService:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
msg = "Please set OPENAI_API_KEY environment variable"
raise ValueError(msg)
default_profile: dict[str, str] = {"api_key": api_key}
chat_model = os.getenv("MEMU_CHAT_MODEL")
if chat_model:
default_profile["chat_model"] = chat_model
service = MemoryService(llm_profiles={"default": default_profile})
# VLM profiles are derived from the LLM profile; allow an explicit override of
# the vision model (handy when a default vision model is unavailable).
vlm_model = os.getenv("MEMU_VLM_MODEL")
if vlm_model:
for cfg in service.vlm_profiles.values():
cfg.vlm_model = vlm_model
return service
def _preview(value: str | None, limit: int = 500) -> str:
if not value:
return "<empty>"
value = value.strip()
return value if len(value) <= limit else value[:limit] + f"... [+{len(value) - limit} chars]"
async def preprocess_one(service: MemoryService, modality: str, path: str) -> bool:
"""Run the preprocessing stage for a single resource and print the result."""
print("\n" + "=" * 72)
print(f"MODALITY: {modality} ({os.path.basename(path)})")
print("-" * 72)
if not os.path.exists(path):
print(f" SKIP: missing sample file {path}")
return False
# Ingest (copies the file into the resources dir and extracts inline text for
# text modalities), then run the modality-specific preprocessor. Vision
# modalities use the VLM client; everything else uses the chat LLM client.
local_path, raw_text = await service.fs.fetch(path, modality)
if modality in VISION_MODALITIES:
client = service._get_vlm_client(service.memorize_config.vlm_profile)
else:
client = service._get_llm_client("default")
segments = await service._preprocess_resource_url(
local_path=local_path,
text=raw_text,
modality=modality,
llm_client=client,
)
extracted = False
for i, seg in enumerate(segments):
print(f" [segment {i}] caption: {_preview(seg.get('caption'), 200)}")
print(f" [segment {i}] text : {_preview(seg.get('text'))}")
if seg.get("text"):
extracted = True
return extracted
async def main() -> None:
print("Example 6: Multimodal Preprocessing")
print("-" * 50)
service = build_service()
results: list[tuple[str, str, bool]] = []
for modality, path in CASES:
try:
ok = await preprocess_one(service, modality, path)
except Exception as e:
print(f" ERROR: {e}")
ok = False
results.append((modality, os.path.basename(path), ok))
print("\n" + "#" * 72)
print("SUMMARY")
print("#" * 72)
for modality, name, ok in results:
print(f" [{'OK ' if ok else 'FAIL'}] {modality:<12} {name}")
if __name__ == "__main__":
asyncio.run(main())
-107
View File
@@ -1,107 +0,0 @@
"""
Getting Started with MemU: A Robust Example.
This script demonstrates the core lifecycle of MemU:
1. **Initialization**: Setting up the client with secure API key handling.
2. **Memory Injection**: Adding a specific memory with metadata.
3. **Retrieval**: Searching for that memory using natural language.
4. **Error Handling**: Catching common configuration issues.
Usage:
export OPENAI_API_KEY=your_api_key_here
python examples/getting_started_robust.py
"""
import asyncio
import logging
import os
import sys
# Ensure src is in the path for local usage if custom installing
sys.path.insert(0, os.path.abspath("src"))
from memu.app import MemoryService
# Configure logging to show info but suppress noisy libraries
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
async def main() -> None:
"""Run the MemU lifecycle demonstration."""
print(">>> MemU Getting Started Example")
print("-" * 30)
# 1. API Key Handling
# MemU relies on an LLM backend (defaulting to OpenAI).
# We ensure the API key is present before proceeding.
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
print("[!] Error: OPENAI_API_KEY environment variable is not set.")
print("Please export it: export OPENAI_API_KEY=sk-...")
return
try:
# 2. Initialization
# We initialize the MemoryService with:
# - llm_profiles: Configuration for the LLM (model, api_key).
# - memorize_config: Pre-defining a memory category ensures we can organize memories efficiently.
print("[*] Initializing MemoryService with model: gpt-4o-mini...")
service = MemoryService(
llm_profiles={
"default": {
"api_key": api_key,
"chat_model": "gpt-4o-mini",
},
},
memorize_config={
"memory_categories": [
{
"name": "User Facts",
"description": "General and specific facts known about the user preference and identity.",
}
]
},
)
print("[OK] Service initialized successfully.\n")
# 3. Memory Injection
# We manually inject a memory into the system.
# This is useful for bootstrapping a user profile or adding explicit knowledge.
print("[*] Injecting memory...")
memory_content = "The user is a senior Python architect who loves clean code and type hints."
# We use 'create_memory_item' to insert a single memory record.
# memory_type='profile' indicates this is an attribute of the user.
result = await service.create_memory_item(
memory_type="profile",
memory_content=memory_content,
memory_categories=["User Facts"],
)
print(f"[OK] Memory created! ID: {result.get('memory_item', {}).get('id')}\n")
# 4. Retrieval
# Now we query the system naturally to see if it recalls the information.
query_text = "What kind of code does the user like?"
print(f"[*] Querying: '{query_text}'")
search_results = await service.retrieve(queries=[{"role": "user", "content": query_text}])
# 5. Display Results
items = search_results.get("items", [])
if items:
print(f"[OK] Found {len(items)} relevant memory item(s):")
for idx, item in enumerate(items, 1):
print(f" {idx}. {item.get('summary')} (Type: {item.get('memory_type')})")
else:
print("[!] No relevant memories found.")
except Exception as e:
print(f"\n[!] An error occurred during execution: {e}")
logging.exception("Detailed traceback:")
finally:
print("\n[=] Example execution finished.")
if __name__ == "__main__":
asyncio.run(main())
-76
View File
@@ -1,76 +0,0 @@
"""Demo script for MemU LangGraph Integration."""
import asyncio
import logging
import os
import sys
# Try imports and fail proactively if missing
try:
import langgraph # noqa: F401
from langchain_core.tools import BaseTool
from memu.app.service import MemoryService
from memu.integrations.langgraph import MemULangGraphTools
except ImportError:
print("Missing dependencies. Please run: uv sync --extra langgraph")
sys.exit(1)
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("langgraph_demo")
async def initialize_infrastructure() -> MemULangGraphTools:
"""Initialize the MemoryService and the LangGraph adapter."""
# Ensure OPENAI_API_KEY is present
if not os.environ.get("OPENAI_API_KEY"):
logger.warning("OPENAI_API_KEY not found in environment variables.")
# In a real scenario, you might load config from file or env
service = MemoryService()
return MemULangGraphTools(service)
async def process_conversation(tools: list[BaseTool], user_id: str) -> None:
"""Simulate a conversation where memory is saved."""
save_tool = next(t for t in tools if t.name == "save_memory")
logger.info("--- Simulating Save Memory ---")
inputs = {
"content": "The user prefers dark mode and likes Python programming.",
"user_id": user_id,
"metadata": {"source": "demo_script"},
}
# Invoke the tool (async execution)
result = await save_tool.ainvoke(inputs)
logger.info("Save Result: %s", result)
async def process_retrieval(tools: list[BaseTool], user_id: str) -> None:
"""Simulate retrieving memory."""
search_tool = next(t for t in tools if t.name == "search_memory")
logger.info("--- Simulating Search Memory ---")
inputs = {"query": "What are the user's preferences?", "user_id": user_id, "limit": 3}
result = await search_tool.ainvoke(inputs)
logger.info("Search Result:\n%s", result)
async def main() -> None:
"""Main entry point."""
logger.info("Starting LangGraph Demo...")
adapter = await initialize_infrastructure()
tools = adapter.tools()
user_id = "demo_user_123"
await process_conversation(tools, user_id)
await process_retrieval(tools, user_id)
logger.info("Demo completed.")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,9 +0,0 @@
# activities
## Open Source Contributions
- The user enjoys contributing to open source projects, specifically a Python CLI tool used for automating deployment tasks.
## Running
- The user usually goes for a run every morning and is interested in running routes near downtown San Francisco.
## Dining Plans
- The user plans to try several vegetarian restaurants in San Francisco for their partner, who is vegetarian.
## Gym
- The user goes to the gym 3-4 times a week, usually after work around 7 PM.
@@ -1,5 +0,0 @@
# experiences
## User Experiences
- The user has been programming for about 5 years.
- The user is leading a big product launch next month, which is causing work-related stress that is affecting their sleep schedule.
- The user usually goes to bed around 11 PM but finds themselves awake thinking about work projects.
@@ -1,17 +0,0 @@
# goals
## Learning Objectives
- The user is interested in learning more about system design and scalability patterns.
- The user is learning about event-driven architecture and message queues, specifically using Apache Kafka for event streaming.
## Product Development Goals
- The user wants to build their own SaaS product related to developer tools or automation.
- The user is currently in the research phase for their product idea, reading about OpenAPI specifications and exploring tools like Swagger and Postman.
- The user plans to start prototyping their SaaS product idea in the next few months.
## Tool Development Ideas
- The user is considering building a tool for API testing and documentation that automatically generates test cases based on API specifications.
- The user is researching tools for developing the API tool.
## Work-Life Balance Goals
- The user plans to talk to their team about response time expectations for emails, as they believe they are putting pressure on themselves.
- The user expresses a desire to establish better work-life boundaries and has been considering ways to limit work-related activities during the evening.
@@ -1,13 +0,0 @@
# habits
## Eating Habits
- The user is trying to eat less meat.
## Exercise Routine
- The user usually goes for a run every morning.
- The user exercises regularly, going to the gym 3-4 times a week.
## Sleep Habits
- The user has been having trouble sleeping due to work stress.
- The user usually tries to go to bed around 11 PM but struggles to fall asleep due to stress from work projects.
- The user checks their phone before bed, which may impact their sleep quality.
- The user used to read before bed but stopped because they were always checking work emails.
## Caffeine Consumption
- The user usually drinks coffee throughout the day to stay alert, typically having their last coffee around 3-4 PM.
@@ -1,29 +0,0 @@
# knowledge
## Reading Interests
- The user has been reading about OpenAPI specifications and exploring tools like Swagger and Postman.
## Technology Stack
- Alex's technology stack includes Django and FastAPI for Python services, Kubernetes for orchestration, Redis for caching, and Apache Kafka for event streaming.
## Interests
- Alex has an interest in system design and scalability patterns and is learning about event-driven architecture and message queues.
- For food in San Francisco, check out the Ferry Building Marketplace for gourmet options and local produce.
- Golden Gate Park, Lands End, and Muir Woods are recommended for nature activities in San Francisco.
- A self-guided tour of tech headquarters in Silicon Valley and the Computer History Museum are good tech-related activities in San Francisco.
- The de Young Museum and SFMOMA are suitable for photography and museum interests in San Francisco.
## Monitoring and Observability
- For monitoring and observability, Alex works with Prometheus and Grafana.
## Sleep and Wellness
- Work stress can negatively affect sleep quality, especially when one is in a leadership role.
- Using a phone before bed can disrupt sleep due to blue light, which suppresses melatonin production.
- Regular exercise can improve sleep, but working out close to bedtime may be stimulating.
- Caffeine consumption, particularly in the afternoon, can adversely affect sleep quality.
- Establishing a wind-down routine, such as reading or gentle stretching before bed, can help improve sleep.
- Keeping a phone out of the bedroom can create a boundary between work and sleep, improving sleep quality.
- Setting specific 'no work' hours can help transition from work mode to rest mode, reducing stress.
## Activities and Recommendations
- The best time to visit Muir Woods is early in the morning around 8 AM to avoid crowds and enjoy peacefulness.
- Parking reservations at Muir Woods are required and should be booked in advance.
- Recommended vegetarian restaurants in San Francisco include Greens Restaurant, Nourish Cafe, Shizen, Gracias Madre, and Ike's Love & Sandwiches.
- The Embarcadero is a great flat waterfront path for morning runs in San Francisco.
- Crissy Field offers stunning views of the Golden Gate Bridge and is another good running spot.
- Running to Coit Tower from North Beach provides a more challenging route with hills.
- San Francisco weather can be changeable, particularly across different neighborhoods, so packing layers is advisable.
@@ -1 +0,0 @@
*No content available*
@@ -1,3 +0,0 @@
# personal_info
## Basic Information
- The user is named Alex and works as a software engineer at TechCorp.
@@ -1,6 +0,0 @@
# preferences
## Interests
- The user loves food and nature.
- The user enjoys exploring tech companies due to their background in software development.
- The user is interested in vegetarian options as their partner is vegetarian and the user is trying to eat less meat.
- The user likes reading and used to read before bed.
@@ -1,3 +0,0 @@
# relationships
## partner
- The user's partner enjoys photography and museums.
@@ -1,12 +0,0 @@
# work_life
## Professional Background
- The user is a software engineer at TechCorp.
- Alex is a software engineer with approximately 5 years of programming experience.
- The user works in software development.
## Current Responsibilities
- The user primarily works on backend systems using Python and Go for an e-commerce platform.
- At TechCorp, Alex's team is building a distributed microservices architecture for their e-commerce platform.
- The user uses Django and FastAPI for Python services and is migrating to Go for better performance.
- The user works with Kubernetes for orchestration and Redis for caching.
- The user is responsible for monitoring and observability, using Prometheus and Grafana.
- The user is leading a big product launch next month.
-66
View File
@@ -1,66 +0,0 @@
memorize_config = {
"memory_types": [
"record",
],
"memory_type_prompts": {
"record": {
"objective": {
"ordinal": 10,
"prompt": "# Task Objective\nYou will be given a conversation between a user and an coding agent. Your goal is to extract detailed records for what are planed to do, and what have been done.",
},
"workflow": {
"ordinal": 20,
"prompt": "# Workflow\nRead through the conversation and extract records. You should expecially focus on:\n- What the user ask the agent to do\n- What plan does the agent suggest\n- What the agent has done",
},
"rules": {
"ordinal": -1,
"prompt": None,
},
"examples": {
"ordinal": 60,
"prompt": "# Example\n## Output\n<item>\n <memory>\n <content>The user ask the agent to generate a code example for fastapi</content>\n <categories>\n <category>todo</category>\n </categories>\n </memory>\n <memory>\n <content>The agent suggest to use the code example from the document</content>\n <categories>\n <category>todo</category>\n </categories>\n </memory>\n <memory>\n <content>The agent ask the user to specify the response type</content>\n <categories>\n <category>todo</category>\n </categories>\n </memory>\n</item>",
},
}
},
"memory_categories": [
{
"name": "todo",
"description": "This file traces the latest status of the task. All records should be included in this file.",
"target_length": None,
"custom_prompt": {
"objective": {
"ordinal": 10,
"prompt": "# Task Objective\nYou are a specialist in task management. You should update the markdown file to reflect the latest status of the task.",
},
"workflow": {
"ordinal": 20,
"prompt": "# Workflow\nRead through the existing markdown file and the new records. Then update the markdown file to reflect:\n- What existing tasks are completed\n- What new tasks are added\n- What tasks are still in progress",
},
"rules": {
"ordinal": 30,
"prompt": "# Rules\nFor each action-like record, explictly mark it as [Done] or [Todo].",
},
"examples": {
"ordinal": 50,
"prompt": "# Example\n## Output\n```markdown\n# Task\n## Task Objective\nThe user ask the agent to generate a code example for fastapi\n## Breakdown\n- [Done] The agent suggest to use the code example from the document\n- [Todo] The agent ask the user to specify the response type\n```",
},
},
}
],
}
retrieve_config = {
"method": "rag",
"route_intention": False,
"sufficiency_check": False,
"category": {
"enabled": False,
},
"item": {
"enabled": True,
"top_k": 10,
},
"resource": {
"enabled": False,
},
}
-31
View File
@@ -1,31 +0,0 @@
import os
from memu.app import MemoryService
from ..config import memorize_config, retrieve_config
USER_ID = "claude_user"
SHARED_MEMORY_SERVICE = None
def get_memory_service() -> MemoryService:
global SHARED_MEMORY_SERVICE
if SHARED_MEMORY_SERVICE is not None:
return SHARED_MEMORY_SERVICE
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
msg = "Please set OPENAI_API_KEY environment variable"
raise ValueError(msg)
SHARED_MEMORY_SERVICE = MemoryService(
llm_profiles={
"default": {
"api_key": api_key,
"chat_model": "gpt-4o-mini",
},
},
memorize_config=memorize_config,
retrieve_config=retrieve_config,
)
return SHARED_MEMORY_SERVICE
@@ -1,38 +0,0 @@
import json
from collections.abc import Awaitable
from pathlib import Path
from typing import Any
import pendulum
from .common import get_memory_service
USER_ID = "claude_user"
def dump_conversation_resource(
conversation_messages: list[dict[str, Any]],
) -> str:
resource_data = {
"content": [
{
"role": message.get("role", "system"),
"content": {"text": message.get("content", "")},
"created_at": message.get("timestamp", pendulum.now().isoformat()),
}
for message in conversation_messages
]
}
time_string = pendulum.now().format("YYYYMMDD_HHmmss")
resource_url = Path(__file__).parent / "data" / f"conv_{time_string}.json"
resource_url.parent.mkdir(parents=True, exist_ok=True)
with open(resource_url, "w") as f:
json.dump(resource_data, f, indent=4, ensure_ascii=False)
return resource_url.as_posix()
def memorize(conversation_messages: list[dict[str, Any]]) -> Awaitable[dict[str, Any]]:
memory_service = get_memory_service()
resource_url = dump_conversation_resource(conversation_messages)
return memory_service.memorize(resource_url=resource_url, modality="conversation", user={"user_id": USER_ID})
-42
View File
@@ -1,42 +0,0 @@
from typing import Any
from claude_agent_sdk import create_sdk_mcp_server, tool
from .common import get_memory_service
USER_ID = "claude_user"
@tool("memu_memory", "Retrieve memory based on a query", {"query": str})
async def get_memory(args: dict[str, Any]) -> dict[str, Any]:
"""Retrieve memory from the memory API based on the provided query."""
query = {"role": "user", "content": args["query"]}
memory_service = get_memory_service()
result = await memory_service.retrieve(query, where={"user_id": USER_ID})
return {"content": [{"type": "text", "text": str(result)}]}
async def _get_todos() -> str:
memory_service = get_memory_service()
result = await memory_service.list_memory_categories(where={"user_id": USER_ID})
categories = result["categories"]
todos = ""
for category in categories:
if category["name"] == "todo":
todos = category["summary"]
return todos
@tool("memu_todos", "Retrieve todos for the user", {})
async def get_todos() -> dict[str, Any]:
"""Retrieve todos from the memory API."""
todos = await _get_todos()
return {"content": [{"type": "text", "text": str(todos)}]}
memu_server = create_sdk_mcp_server(name="memu", version="1.0.0", tools=[get_memory, get_todos])
@@ -1,31 +0,0 @@
from typing import Any
import aiohttp
from ..config import memorize_config
BASE_URL = "https://api.memu.so"
API_KEY = "your memu api key"
USER_ID = "claude_user"
AGENT_ID = "claude_agent"
async def memorize(conversation_messages: list[dict[str, Any]]) -> str | None:
payload = {
"conversation": conversation_messages,
"user_id": USER_ID,
"agent_id": AGENT_ID,
"override_config": memorize_config,
}
async with (
aiohttp.ClientSession() as session,
session.post(
f"{BASE_URL}/api/v3/memory/memorize",
headers={"Authorization": f"Bearer {API_KEY}"},
json=payload,
) as response,
):
result = await response.json()
task_id = result["task_id"]
return task_id
@@ -1,52 +0,0 @@
from typing import Any
import aiohttp
from claude_agent_sdk import create_sdk_mcp_server, tool
BASE_URL = "https://api.memu.so"
API_KEY = "your memu api key"
USER_ID = "claude_user"
AGENT_ID = "claude_agent"
@tool("memu_memory", "Retrieve memory based on a query", {"query": str})
async def get_memory(args: dict[str, Any]) -> dict[str, Any]:
"""Retrieve memory from the memory API based on the provided query."""
query = args["query"]
url = f"{BASE_URL}/api/v3/memory/retrieve"
headers = {"Authorization": f"Bearer {API_KEY}"}
data = {"user_id": USER_ID, "agent_id": AGENT_ID, "query": query}
async with aiohttp.ClientSession() as session, session.post(url, headers=headers, json=data) as response:
result = await response.json()
return {"content": [{"type": "text", "text": str(result)}]}
async def _get_todos() -> str:
url = f"{BASE_URL}/api/v3/memory/categories"
headers = {"Authorization": f"Bearer {API_KEY}"}
data = {
"user_id": USER_ID,
"agent_id": AGENT_ID,
}
async with aiohttp.ClientSession() as session, session.post(url, headers=headers, json=data) as response:
result = await response.json()
categories = result["categories"]
todos = ""
for category in categories:
if category["name"] == "todo":
todos = category["summary"]
return todos
@tool("memu_todos", "Retrieve todos for the user", {})
async def get_todos() -> dict[str, Any]:
"""Retrieve todos from the memory API."""
todos = await _get_todos()
return {"content": [{"type": "text", "text": str(todos)}]}
# Create the MCP server with the tool
memu_server = create_sdk_mcp_server(name="memu", version="1.0.0", tools=[get_memory, get_todos])
-198
View File
@@ -1,198 +0,0 @@
import asyncio
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
TextBlock,
)
from memory.local.memorize import memorize
from memory.local.tools import _get_todos, memu_server
# Set your Anthropic API key here if it's not set in the environment variables
# os.environ["ANTHROPIC_API_KEY"] = ""
N_MESSAGES_MEMORIZE = 2
RUNNING_MEMORIZATION: asyncio.Task | None = None
async def trigger_memorize(messages: list[dict[str, any]]) -> bool:
"""Create a background task to memorize conversation messages.
Returns True if the task was successfully created and registered.
"""
global RUNNING_MEMORIZATION
try:
memorize_awaitable = memorize(messages)
RUNNING_MEMORIZATION = asyncio.create_task(memorize_awaitable)
except Exception as e:
print(f"\n[Memory] Memorization initialization failed: {e!r}")
return False
else:
print("\n[Memory] Memorization task submitted.")
return True
async def get_next_input(iteration: int) -> tuple[str | None, bool]:
"""
Get the next input for the conversation.
Returns:
tuple of (input_text, should_break)
- input_text: The user input or todo-based input, None if should continue
- should_break: True if the loop should break
"""
if iteration == 0:
return await get_user_input()
todos = await _get_todos()
print(f">>> Todos:\n{todos}\n")
print("-" * 40)
if todos and "[todo]" in todos.lower():
return f"Please continue with the following todos:\n{todos}", False
return await get_user_input()
async def get_user_input() -> tuple[str | None, bool]:
"""
Get input from the user.
Returns:
tuple of (input_text, should_break)
"""
try:
user_input = input("\nYou: ").strip()
except EOFError:
return None, True
if not user_input:
return None, False
if user_input.lower() in ("quit", "exit"):
return None, True
return user_input, False
async def process_response(client: ClaudeSDKClient) -> list[str]:
"""Process the assistant response and return collected text parts."""
assistant_text_parts: list[str] = []
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f"Claude: {block.text}")
assistant_text_parts.append(block.text)
elif isinstance(message, ResultMessage):
print(f"Result: {message.result}")
return assistant_text_parts
async def check_and_memorize(conversation_messages: list[dict[str, any]]) -> None:
"""Check if memorization threshold is reached and trigger if needed.
Skips triggering if a previous memorization task is still running.
"""
global RUNNING_MEMORIZATION
if len(conversation_messages) < N_MESSAGES_MEMORIZE:
return
# Check if there's a running memorization task
if RUNNING_MEMORIZATION is not None:
if not RUNNING_MEMORIZATION.done():
print("\n[Info] Have running memorization, skipping...")
return
# Previous task completed, check for exceptions
try:
RUNNING_MEMORIZATION.result()
except Exception as e:
print(f"\n[Memory] Memorization failed: {e!r}")
RUNNING_MEMORIZATION = None
print(f"\n[Info] Reached {N_MESSAGES_MEMORIZE} messages, triggering memorization...")
success = await trigger_memorize(conversation_messages.copy())
if success:
conversation_messages.clear()
async def run_conversation_loop(client: ClaudeSDKClient) -> list[dict[str, any]]:
"""Run the main conversation loop."""
conversation_messages: list[dict[str, any]] = []
iteration = 0
while True:
user_input, should_break = await get_next_input(iteration)
if should_break:
break
if user_input is None:
continue
conversation_messages.append({"role": "user", "content": user_input})
await client.query(user_input)
assistant_text_parts = await process_response(client)
if assistant_text_parts:
conversation_messages.append({
"role": "assistant",
"content": "\n".join(assistant_text_parts),
})
await check_and_memorize(conversation_messages)
iteration += 1
return conversation_messages
async def main():
options = ClaudeAgentOptions(
mcp_servers={"memu": memu_server},
allowed_tools=[
# "mcp__memu__memu_memory",
"mcp__memu__memu_todos",
],
)
print("Claude Autorun")
print("Type 'quit' or 'exit' to end the session.")
print("-" * 40)
async with ClaudeSDKClient(options=options) as client:
remaining_messages = await run_conversation_loop(client)
# Wait for any running memorization task to complete
global RUNNING_MEMORIZATION
if RUNNING_MEMORIZATION is not None and not RUNNING_MEMORIZATION.done():
print("\n[Info] Waiting for running memorization task to complete...")
try:
await RUNNING_MEMORIZATION
print("\n[Memory] Running memorization completed successfully.")
except Exception as e:
print(f"\n[Memory] Running memorization failed: {e!r}")
RUNNING_MEMORIZATION = None
# Memorize remaining messages and wait for completion
if remaining_messages:
print("\n[Info] Session ended, memorizing remaining messages...")
success = await trigger_memorize(remaining_messages.copy())
if success and RUNNING_MEMORIZATION is not None:
print("\n[Info] Waiting for final memorization to complete...")
try:
await RUNNING_MEMORIZATION
print("\n[Memory] Final memorization completed successfully.")
except Exception as e:
print(f"\n[Memory] Final memorization failed: {e!r}")
print("\nDone")
if __name__ == "__main__":
asyncio.run(main())
Binary file not shown.
@@ -1,54 +0,0 @@
[
{
"role": "user",
"content": "Hi! I'm Alex. I'm a software engineer working at TechCorp. I've been programming for about 5 years now."
},
{
"role": "assistant",
"content": "Nice to meet you, Alex! It's great to have you here. What kind of software development do you primarily work on at TechCorp?"
},
{
"role": "user",
"content": "I mainly work on backend systems using Python and Go. We're building a distributed microservices architecture for our e-commerce platform. I'm particularly interested in learning more about system design and scalability patterns."
},
{
"role": "assistant",
"content": "That sounds like an exciting project! Distributed systems and microservices certainly present interesting challenges. Are you working with any specific frameworks or technologies for the microservices?"
},
{
"role": "user",
"content": "Yes, we use Django and FastAPI for Python services, and we're migrating some critical services to Go for better performance. We use Kubernetes for orchestration and Redis for caching. I've been learning about event-driven architecture and message queues lately."
},
{
"role": "assistant",
"content": "Excellent technology stack! Event-driven architecture is a great pattern for microservices. Are you using any specific message broker like RabbitMQ or Kafka?"
},
{
"role": "user",
"content": "We're using Apache Kafka for our event streaming. I'm also responsible for monitoring and observability, so I work with Prometheus and Grafana. Outside of work, I like to contribute to open source projects. I recently started contributing to a Python CLI tool."
},
{
"role": "assistant",
"content": "That's wonderful! Open source contributions are a great way to learn and give back to the community. What made you interested in contributing to that particular CLI tool?"
},
{
"role": "user",
"content": "I actually use it in my daily workflow for automating deployment tasks. I found a few bugs and thought I could help improve it. I also want to eventually build my own SaaS product, maybe something related to developer tools or automation."
},
{
"role": "assistant",
"content": "That's a great goal! Having a SaaS product idea is exciting. Do you have any specific problem you're looking to solve with your developer tools idea?"
},
{
"role": "user",
"content": "I'm thinking about building a tool for API testing and documentation. Something that can automatically generate test cases based on API specifications and maintain up-to-date documentation. I notice this is a pain point in my current team."
},
{
"role": "assistant",
"content": "That's a valuable problem to solve! Many teams struggle with keeping API documentation synchronized with their codebase. Have you done any market research or prototyping yet?"
},
{
"role": "user",
"content": "Not yet, I'm still in the research phase. I've been reading about OpenAPI specifications and exploring tools like Swagger and Postman. I think there's room for a more automated and AI-powered solution. I'm planning to start prototyping in the next few months."
}
]
@@ -1,67 +0,0 @@
[
{
"role": "user",
"created_at": "2025-02-01T09:00:00Z",
"content": "I need help planning a weekend trip to San Francisco. Can you suggest some activities?"
},
{
"role": "assistant",
"created_at": "2025-02-01T09:00:08Z",
"content": "I'd be happy to help you plan your San Francisco trip! What are your main interests? Are you into tech, food, nature, art, or something else?"
},
{
"role": "user",
"created_at": "2025-02-01T09:00:25Z",
"content": "I love food and nature! I'm also interested in visiting some tech companies since I work in software development. My partner enjoys photography and museums."
},
{
"role": "assistant",
"created_at": "2025-02-01T09:00:40Z",
"content": "Perfect! For food, you should definitely check out the Ferry Building Marketplace for gourmet options and local produce. For nature, I'd recommend Golden Gate Park, hiking at Lands End, or visiting Muir Woods for the redwoods. For tech, you could do a self-guided tour of tech headquarters in Silicon Valley or visit the Computer History Museum. For your partner, there's the de Young Museum in Golden Gate Park and SFMOMA for modern art."
},
{
"role": "user",
"created_at": "2025-02-01T09:01:20Z",
"content": "Those sound great! I'm particularly excited about Muir Woods. I've never seen redwoods before. What's the best time to visit?"
},
{
"role": "assistant",
"created_at": "2025-02-01T09:01:35Z",
"content": "Muir Woods is magical! I'd recommend going early in the morning, ideally right when it opens around 8 AM. This way you'll avoid the crowds and experience the peaceful atmosphere of the forest. The morning light filtering through the redwoods is also spectacular for photography. Make sure to book parking reservations in advance as they're required."
},
{
"role": "user",
"created_at": "2025-02-01T09:02:10Z",
"content": "Good to know about the parking! Are there any good vegetarian restaurants you'd recommend? My partner is vegetarian and I'm trying to eat less meat."
},
{
"role": "assistant",
"created_at": "2025-02-01T09:02:25Z",
"content": "San Francisco has amazing vegetarian options! I'd recommend Greens Restaurant for upscale vegetarian with a view, Nourish Cafe for healthy bowls, and Shizen for vegan sushi. If you're in the Mission District, try Gracias Madre for organic Mexican food. For something casual, Ike's Love & Sandwiches has great veggie options."
},
{
"role": "user",
"created_at": "2025-02-01T09:03:05Z",
"content": "Perfect! I'll add these to my list. One more thing - I usually go for a run every morning. Are there good running routes near downtown?"
},
{
"role": "assistant",
"created_at": "2025-02-01T09:03:20Z",
"content": "Absolutely! The Embarcadero is perfect for morning runs - it's a flat waterfront path with beautiful bay views. You can run from the Ferry Building towards Fisherman's Wharf, which is about 3 miles one way. Alternatively, Crissy Field offers stunning views of the Golden Gate Bridge. If you want something more challenging with hills, try running up to Coit Tower from North Beach."
},
{
"role": "user",
"created_at": "2025-02-01T09:03:55Z",
"content": "The Embarcadero sounds perfect for my morning routine. Thanks for all the suggestions! I'm getting really excited about this trip now."
},
{
"role": "assistant",
"created_at": "2025-02-01T09:04:05Z",
"content": "You're welcome! It sounds like you have a great mix of activities planned. Don't forget to bring layers - San Francisco weather can be quite changeable, especially if you're visiting different neighborhoods. Have a wonderful trip!"
},
{
"role": "user",
"created_at": "2025-02-01T09:04:30Z",
"content": "Will do! I always forget how different the weather can be in different parts of the city. I'll pack accordingly."
}
]
@@ -1,67 +0,0 @@
[
{
"role": "user",
"created_at": "2025-03-10T22:00:00Z",
"content": "I've been having trouble sleeping lately. I think work stress might be affecting my sleep schedule."
},
{
"role": "assistant",
"created_at": "2025-03-10T22:00:12Z",
"content": "I'm sorry to hear you're having trouble sleeping. Work stress can definitely impact sleep quality. Can you tell me more about your current sleep routine and what's been going on at work?"
},
{
"role": "user",
"created_at": "2025-03-10T22:00:45Z",
"content": "I usually try to go to bed around 11 PM, but I find myself lying awake thinking about work projects. I'm leading a big product launch next month and there's a lot of pressure. I also tend to check my phone before bed."
},
{
"role": "assistant",
"created_at": "2025-03-10T22:01:05Z",
"content": "It's common for work pressure to interfere with sleep, especially when you're in a leadership role. The phone usage before bed could also be contributing to the problem - the blue light can suppress melatonin production. Have you tried any relaxation techniques or sleep hygiene practices?"
},
{
"role": "user",
"created_at": "2025-03-10T22:01:40Z",
"content": "I tried meditation once but didn't stick with it. I do exercise regularly - I go to the gym 3-4 times a week, usually after work around 7 PM. I also drink coffee throughout the day to stay alert."
},
{
"role": "assistant",
"created_at": "2025-03-10T22:01:55Z",
"content": "Exercise is great for sleep, but working out too close to bedtime can actually be stimulating. The caffeine consumption throughout the day could also be affecting your sleep, especially if you're having coffee in the afternoon. Could you try moving your workouts to the morning and cutting off caffeine after 2 PM?"
},
{
"role": "user",
"created_at": "2025-03-10T22:02:25Z",
"content": "That makes sense. I usually have my last coffee around 3-4 PM. Maybe I should switch to decaf in the afternoon. Morning workouts might be tough though - I'm not really a morning person."
},
{
"role": "assistant",
"created_at": "2025-03-10T22:02:40Z",
"content": "Starting with the caffeine cutoff is a good first step! As for morning workouts, you don't have to make a drastic change immediately. Even a 15-minute walk in the morning can help regulate your circadian rhythm. For the evening, you might want to establish a wind-down routine - maybe some light reading, gentle stretching, or breathing exercises about an hour before bed."
},
{
"role": "user",
"created_at": "2025-03-10T22:03:20Z",
"content": "I like reading, actually. I used to read before bed but stopped because I was always checking work emails. Maybe I should keep my phone out of the bedroom entirely."
},
{
"role": "assistant",
"created_at": "2025-03-10T22:03:35Z",
"content": "That's an excellent idea! Keeping your phone out of the bedroom can make a big difference. You could get a regular alarm clock if you use your phone for that. Creating a clear boundary between work and sleep time is important, especially with the product launch stress."
},
{
"role": "user",
"created_at": "2025-03-10T22:04:10Z",
"content": "You're right. I think I need to be better about work-life boundaries in general. I often answer emails late at night, which probably doesn't help my stress levels."
},
{
"role": "assistant",
"created_at": "2025-03-10T22:04:25Z",
"content": "Those late-night emails can definitely keep your mind in 'work mode' when it should be winding down. Have you considered setting specific 'no work' hours, maybe from 8 PM onwards? That way you give your brain time to transition from work mode to rest mode."
},
{
"role": "user",
"created_at": "2025-03-10T22:04:55Z",
"content": "That sounds reasonable. I'll talk to my team about response time expectations. I don't think anyone expects instant replies at night anyway - I'm probably putting that pressure on myself."
}
]
-330
View File
@@ -1,330 +0,0 @@
MemU: Agentic Memory Framework - Technical Documentation
Introduction
============
MemU is a sophisticated agentic memory framework designed to provide AI agents and applications with human-like memory capabilities. Unlike traditional RAG (Retrieval-Augmented Generation) systems that simply store and retrieve information, MemU organizes, categorizes, and maintains memories in a structured, semantically meaningful way.
Core Concepts
=============
1. Memory Organization
MemU organizes information into several layers:
- Memory Items: Individual pieces of information extracted from inputs
- Memory Categories: Semantic groupings of related memories
- Memory Types: Classifications of memory content (profile, event, knowledge, behavior)
2. Multi-Modal Support
The framework supports various input modalities:
- Text documents (PDF, TXT, DOC)
- Conversations (JSON chat logs)
- Images (PNG, JPG, with vision model integration)
- Audio (transcription and processing)
- Video (frame extraction and analysis)
3. Intelligent Processing Pipeline
Each input goes through several processing stages:
a. Preprocessing: Content extraction and normalization
b. Summarization: Key information extraction
c. Embedding: Vector representation generation
d. Classification: Memory type identification
e. Categorization: Semantic category assignment
f. Storage: Persistent storage with metadata
Architecture Components
=======================
1. MemoryService (Core Service Layer)
The main entry point for all memory operations:
- memorize(): Process and store new information
- retrieve(): Query and fetch relevant memories
- update(): Modify existing memories
- delete(): Remove memories
Configuration options:
- LLM provider settings (OpenAI, Azure, custom)
- Embedding model selection
- Memory type definitions
- Category templates
- Retrieval methods (RAG, LLM-based)
2. Storage Layer
Multiple storage backends supported:
- SQLite (default, local development)
- PostgreSQL (production deployments)
- In-memory (testing and temporary storage)
Data persistence:
- Memory items with metadata
- Category definitions and summaries
- Vector embeddings for similarity search
- Resource references and URLs
3. Vector Search Engine
Semantic search capabilities powered by:
- Dense embeddings (OpenAI text-embedding-3-small/large)
- Similarity metrics (cosine similarity, dot product)
- Efficient indexing for fast retrieval
- Hybrid search combining semantic and keyword matching
4. LLM Integration Layer
Flexible LLM backend support:
- OpenAI SDK client (primary)
- HTTP-based client (custom endpoints)
- Configurable model selection
- Prompt template system
- Streaming response support
Memory Types
============
1. Profile Memory
Stores persistent information about entities:
- Personal attributes (name, age, occupation)
- Preferences and interests
- Relationships and connections
- Identity and characteristics
Example: "Alex is a software engineer at TechCorp, specializing in backend development"
2. Event Memory
Records discrete occurrences and activities:
- Temporal events with timestamps
- Actions and experiences
- Milestones and achievements
- Incidents and interactions
Example: "Completed the deployment pipeline implementation on November 15, 2024"
3. Knowledge Memory
Captures factual information and learnings:
- Facts and concepts
- Skills and capabilities
- Domain expertise
- Technical knowledge
Example: "Proficient in Python, Go, Kubernetes, and microservices architecture"
4. Behavior Memory
Tracks patterns and tendencies:
- Habits and routines
- Decision patterns
- Behavioral preferences
- Interaction styles
Example: "Prefers morning workouts, typically exercises 3-4 times per week"
Category Management
===================
Dynamic Categorization:
MemU automatically assigns memories to semantic categories based on content similarity. Categories are created and maintained dynamically as new memories are added.
Default Categories:
- personal_info: Personal details and identity
- preferences: Likes, dislikes, and choices
- relationships: Connections with others
- activities: Hobbies and interests
- goals: Aspirations and objectives
- experiences: Past events and learnings
- knowledge: Facts and information
- opinions: Views and perspectives
- habits: Routines and patterns
- work_life: Professional information
Custom Categories:
Users can define custom categories with:
- Name and description
- Embedding vector for semantic matching
- Assignment threshold for automatic categorization
- Summary generation for category overview
Category Summaries:
MemU maintains auto-generated summaries for each category that:
- Provide overview of category contents
- Get updated as new memories are added
- Help with high-level information retrieval
- Support category-level search
Retrieval Strategies
====================
1. RAG-Based Retrieval (Default)
Vector similarity search approach:
- Query embedding generation
- Similarity calculation with stored memories
- Top-K selection per category
- Ranking by relevance score
- Context window assembly
Advantages:
- Fast and efficient
- Deterministic results
- Lower LLM costs
- Good for factual recall
2. LLM-Based Retrieval
AI-powered search and ranking:
- Query understanding and expansion
- Semantic relevance judgment
- Context-aware ranking
- Multi-hop reasoning support
- Natural language result explanation
Advantages:
- Better semantic understanding
- Handles complex queries
- Context-aware results
- Flexible interpretation
Retrieval Pipeline:
1. Pre-retrieval decision (should we retrieve?)
2. Query rewriting (optimize for search)
3. Category ranking (which categories are relevant?)
4. Item retrieval (fetch top-K items)
5. Item ranking (rerank by relevance)
6. Resource retrieval (fetch original sources)
7. Result assembly (format for output)
Best Practices
==============
1. Memory Quality
- Provide detailed, contextual inputs
- Include timestamps for events
- Maintain consistent terminology
- Regular memory consolidation
- Remove outdated information
2. Configuration Optimization
- Tune embedding models for your domain
- Adjust category assignment thresholds
- Customize memory type prompts
- Set appropriate top-K values
- Configure LLM parameters
3. Performance Optimization
- Batch memory operations when possible
- Use appropriate storage backend
- Index frequently queried fields
- Cache embeddings when reusing
- Monitor memory growth
4. Privacy and Security
- Implement access controls
- Encrypt sensitive memories
- Regular data audits
- Compliance with data regulations
- User consent for memory storage
Use Cases
=========
1. Personal AI Assistants
- Remember user preferences and context
- Maintain conversation history
- Learn from interactions
- Personalize responses
2. Customer Support Systems
- Track customer history and issues
- Remember preferences and complaints
- Build customer profiles
- Improve service quality
3. Educational Applications
- Track learning progress
- Remember concepts learned
- Adapt to learning style
- Provide personalized content
4. Knowledge Management
- Organize organizational knowledge
- Track project information
- Build expertise databases
- Enable knowledge discovery
5. Agent Workflows
- Maintain task context
- Remember tool usage patterns
- Learn from execution history
- Optimize decision making
API Reference
=============
Basic Usage Example:
```python
from memu.app import MemoryService
# Initialize service
service = MemoryService(
llm_config={
"api_key": "your-api-key",
"chat_model": "gpt-4o-mini"
}
)
# Store a memory
result = await service.memorize(
resource_url="conversation.json",
modality="conversation"
)
# Retrieve memories
memories = await service.retrieve(
query="What programming languages does Alex know?",
top_k=5
)
# Access categories
categories = service.store.categories
```
Advanced Configuration:
```python
# Custom memory types
memorize_config = {
"memory_types": ["profile", "knowledge", "custom"],
"memory_type_prompts": {
"custom": "Extract specific information: {resource}"
},
"memory_categories": [
{"name": "technical_skills", "description": "Programming and technical abilities"},
{"name": "soft_skills", "description": "Communication and interpersonal skills"}
]
}
service = MemoryService(
llm_config=llm_config,
memorize_config=memorize_config
)
```
Roadmap
=======
Upcoming Features:
- Long-term memory consolidation
- Federated memory systems
- Memory importance scoring
- Automatic memory pruning
- Cross-user memory sharing
- Memory versioning and history
- Enhanced temporal reasoning
- Graph-based memory relationships
- Memory export and import
- Advanced privacy controls
Contributing
============
MemU is open source and welcomes contributions:
- Bug reports and feature requests
- Documentation improvements
- Code contributions
- Example applications
- Performance optimizations
For more information, visit: https://github.com/mem-labs/memU
-433
View File
@@ -1,433 +0,0 @@
Building Production-Ready AI Agents: A Comprehensive Guide
Executive Summary
=================
This document provides a comprehensive guide to building, deploying, and maintaining production-ready AI agents. It covers architecture patterns, best practices, common pitfalls, and real-world implementation strategies based on experiences from deploying agents at scale.
What is an AI Agent?
====================
An AI agent is an autonomous system that can:
- Perceive its environment through sensors or data inputs
- Make decisions based on goals and constraints
- Take actions using available tools and APIs
- Learn and adapt from experience
- Operate with minimal human intervention
Key characteristics that differentiate agents from simple LLM applications:
1. Goal-directed behavior (not just reactive)
2. Multi-step reasoning and planning
3. Tool use and external API integration
4. Memory and state management
5. Error handling and recovery
6. Continuous operation
Agent Architecture Patterns
============================
1. ReAct (Reasoning + Acting) Pattern
The most common agent architecture:
- Thought: Reason about the current situation
- Action: Choose and execute a tool/action
- Observation: Observe the results
- Repeat until goal is achieved
Advantages:
- Interpretable decision-making process
- Easy to debug with visible reasoning
- Works well with modern LLMs
Challenges:
- Can be verbose and slow
- May get stuck in reasoning loops
- Token costs accumulate quickly
2. Plan-and-Execute Pattern
Separates planning from execution:
- Generate high-level plan upfront
- Execute steps sequentially
- Replan if execution fails
Advantages:
- More efficient for complex tasks
- Better resource allocation
- Clearer progress tracking
Challenges:
- Less adaptable to changing conditions
- Planning failures cascade
- Requires good task decomposition
3. Hierarchical Agent Systems
Multiple agents working together:
- Coordinator agent manages workflow
- Specialist agents handle specific domains
- Memory shared across agents
Advantages:
- Scalable to complex domains
- Parallel execution possible
- Clear separation of concerns
Challenges:
- Coordination overhead
- Complex error propagation
- Harder to debug
4. Autonomous Agent Pattern
Continuous operation without explicit tasks:
- Monitor environment for triggers
- Self-generate tasks and goals
- Learn from outcomes
- Adjust behavior over time
Advantages:
- Truly autonomous operation
- Proactive rather than reactive
- Continuous improvement
Challenges:
- Harder to control and bound
- Safety and alignment concerns
- Resource management critical
Essential Components
====================
1. Language Model Integration
Choosing the right LLM:
- GPT-4 family: Best reasoning, highest cost
- Claude: Strong safety, good reasoning
- Open source models: Lower cost, self-hosted
Optimization strategies:
- Use smaller models for simple tasks
- Implement prompt caching
- Batch similar requests
- Fine-tune for specific domains
2. Tool Registry and Execution
Managing agent capabilities:
- Dynamic tool discovery
- Type-safe tool schemas
- Sandboxed execution environment
- Rate limiting and quotas
- Error handling and retries
Tool design principles:
- Single responsibility per tool
- Clear input/output contracts
- Idempotent when possible
- Detailed error messages
- Comprehensive documentation
3. Memory Systems
Types of memory agents need:
- Working memory: Current task context
- Short-term memory: Recent interactions
- Long-term memory: Persistent knowledge
- Episodic memory: Past experiences
- Semantic memory: General knowledge
Implementation approaches:
- Vector databases for semantic search
- Graph databases for relationships
- Key-value stores for fast lookup
- SQL databases for structured data
4. Planning and Decision Making
Strategies for complex tasks:
- Breadth-first vs depth-first search
- Heuristic-guided planning
- Monte Carlo tree search
- Reinforcement learning
- Symbolic reasoning integration
5. Monitoring and Observability
Critical metrics to track:
- Task completion rate
- Average execution time
- Token usage and costs
- Error rates by type
- Tool usage patterns
- User satisfaction scores
Logging and tracing:
- Structured logging (JSON format)
- Distributed tracing (trace IDs)
- Action replay capabilities
- Performance profiling
- Anomaly detection
Production Deployment Considerations
====================================
1. Safety and Control
Implementing guardrails:
- Input validation and sanitization
- Output filtering and moderation
- Action confirmation for critical operations
- Rollback mechanisms
- Circuit breakers for cascading failures
- Human-in-the-loop for high-risk decisions
2. Cost Management
Strategies to control expenses:
- Budget limits per agent/user
- Automatic degradation to cheaper models
- Request batching and caching
- Usage analytics and alerts
- Prompt optimization for token efficiency
3. Latency Optimization
Reducing response time:
- Parallel tool execution when possible
- Streaming responses to users
- Pre-warming model connections
- Edge deployment for low latency
- Async processing for non-critical tasks
4. Scalability
Handling growth:
- Stateless agent design
- Horizontal scaling with load balancers
- Queue-based task distribution
- Database sharding strategies
- Caching layers (Redis, Memcached)
5. Reliability
Building fault-tolerant systems:
- Graceful degradation
- Automatic retry with exponential backoff
- Dead letter queues for failed tasks
- Health checks and auto-recovery
- Multi-region deployment
Common Pitfalls and Solutions
=============================
1. Infinite Loops
Problem: Agent gets stuck repeating same actions
Solutions:
- Implement maximum iteration limits
- Track state to detect loops
- Add randomization to break patterns
- Use reflection to detect stuck states
2. Context Window Overflow
Problem: Conversation history exceeds model limits
Solutions:
- Implement context summarization
- Use sliding window approach
- Store full history, send summaries
- Priority-based context selection
3. Tool Hallucination
Problem: Agent tries to use non-existent tools
Solutions:
- Provide clear tool documentation in prompt
- Validate tool names before execution
- Use structured output formats (JSON)
- Fine-tune on correct tool usage
4. Inconsistent Behavior
Problem: Agent gives different results for same input
Solutions:
- Set temperature to 0 for determinism
- Implement result caching
- Add explicit reasoning chain requirements
- Use voting/consensus from multiple runs
5. Poor Error Recovery
Problem: Single failures cause complete task abandonment
Solutions:
- Implement retry logic with backoff
- Fallback to alternative approaches
- Graceful degradation to partial results
- Clear error messages and recovery suggestions
Testing Strategies
==================
1. Unit Testing
- Test individual tool functions
- Mock LLM responses
- Validate prompt templates
- Test parsing and formatting logic
2. Integration Testing
- End-to-end task completion
- Tool chain execution
- Memory persistence
- API integration points
3. Evaluation Benchmarks
- Task success rate
- Response quality (human eval)
- Reasoning coherence
- Tool usage efficiency
- Cost per task
4. Adversarial Testing
- Malicious input handling
- Edge case scenarios
- Resource exhaustion attacks
- Prompt injection attempts
5. A/B Testing
- Compare prompt variations
- Test different model versions
- Evaluate architecture changes
- Measure user satisfaction
Real-World Use Cases
====================
1. Customer Support Agent
Capabilities:
- Answer common questions using knowledge base
- Create support tickets for complex issues
- Schedule appointments and callbacks
- Escalate to human agents when needed
Key challenges:
- Maintaining empathy in responses
- Handling frustrated customers
- Accurate issue classification
- Privacy and data security
2. Research Assistant Agent
Capabilities:
- Search academic databases
- Summarize research papers
- Identify trends and gaps
- Generate literature reviews
Key challenges:
- Source credibility verification
- Citation accuracy
- Handling conflicting information
- Domain expertise requirements
3. DevOps Automation Agent
Capabilities:
- Monitor system metrics
- Diagnose performance issues
- Execute remediation actions
- Generate incident reports
Key challenges:
- High stakes decision-making
- Complex system dependencies
- Security and access control
- Audit trail requirements
4. Sales Prospecting Agent
Capabilities:
- Research potential customers
- Personalize outreach messages
- Schedule meetings
- Track engagement and follow-ups
Key challenges:
- Avoiding spam-like behavior
- Personalization at scale
- CRM integration complexity
- Compliance with regulations
Performance Optimization
========================
1. Prompt Engineering
- Use clear, structured instructions
- Provide relevant examples (few-shot)
- Include constraints and boundaries
- Optimize token usage
- Version control prompts
2. Model Selection
- Match model capability to task complexity
- Use smaller models for simple tasks
- Consider latency requirements
- Balance cost vs performance
- Evaluate fine-tuning benefits
3. Caching Strategies
- Cache LLM responses for common queries
- Cache tool results when appropriate
- Implement embeddings cache
- Use CDN for static resources
- Cache database queries
4. Parallel Execution
- Identify independent tool calls
- Execute in parallel where possible
- Use async/await patterns
- Implement concurrent request limits
- Handle partial failures gracefully
Future Trends
=============
1. Multi-Modal Agents
- Vision, audio, and text integration
- Video understanding capabilities
- Embodied AI for robotics
- Mixed reality interactions
2. Improved Planning
- Better long-term reasoning
- Hierarchical task decomposition
- Probabilistic planning under uncertainty
- Resource-constrained optimization
3. Enhanced Memory
- Better long-term retention
- Efficient memory consolidation
- Personalization and adaptation
- Cross-agent knowledge sharing
4. Tool Learning
- Automatic tool discovery
- Tool composition and chaining
- Learning from tool usage patterns
- Generating new tools dynamically
5. Human-Agent Collaboration
- Natural delegation interfaces
- Explainable decision-making
- Interactive planning and refinement
- Shared mental models
Conclusion
==========
Building production-ready AI agents requires careful attention to architecture, safety, performance, and user experience. Success comes from:
- Starting with clear, well-defined use cases
- Iterating based on real-world feedback
- Investing in monitoring and observability
- Prioritizing safety and controllability
- Continuously optimizing costs and performance
The agent paradigm represents a significant leap from traditional applications, but with thoughtful design and implementation, agents can deliver tremendous value while operating reliably at scale.
References and Resources
========================
Key papers:
- "ReAct: Synergizing Reasoning and Acting in Language Models" (Yao et al., 2022)
- "Toolformer: Language Models Can Teach Themselves to Use Tools" (Schick et al., 2023)
- "Reflexion: Language Agents with Verbal Reinforcement Learning" (Shinn et al., 2023)
Frameworks and tools:
- LangChain: Popular agent framework
- AutoGPT: Autonomous agent implementation
- BabyAGI: Task-driven autonomous agent
- AgentGPT: Web-based agent platform
Communities:
- LangChain Discord
- AI Agent subreddit
- AutoGPT GitHub discussions
- Agent research papers on ArXiv
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

-212
View File
@@ -1,212 +0,0 @@
Agent Runtime Log - Production Microservice Deployment (ATTEMPT 1 - FAILURE)
================================================================================
Session ID: deploy-agent-20241120-1430-attempt1
Task: "Deploy recommendation microservice to production with zero downtime"
Agent: DeploymentAgent v3.2
Start Time: 2024-11-20 14:30:00 UTC
Attempt: 1/3
[14:30:00] [INIT] Agent initialization started
[14:30:00] [INFO] Task: Deploy recommendation-service v2.5.0 to production
[14:30:00] [INFO] Target environment: production-cluster-us-east
[14:30:01] [INFO] Deployment strategy: Blue-green with gradual traffic shift
[14:30:01] [SUCCESS] Agent initialized
[14:30:01] [PLANNING] Creating deployment plan
[14:30:02] [THOUGHT] Deployment steps should include:
1. Validate service configuration and dependencies
2. Build and push Docker image
3. Deploy green environment alongside blue
4. Run health checks and smoke tests
5. Gradually shift traffic from blue to green
6. Monitor metrics and rollback if needed
[14:30:02] [ACTION] Step 1: Validating service configuration
[14:30:02] [TOOL] Executing: validate_config(service="recommendation-service", version="v2.5.0")
[14:30:05] [OBSERVATION] Configuration validation results:
- Service manifest: Valid
- Environment variables: 12 variables defined
- Resource limits: CPU 2 cores, Memory 4Gi
- Dependencies: PostgreSQL, Redis, Kafka
- Health check endpoint: /health
[14:30:05] [THOUGHT] Configuration looks good. Proceeding with Docker build.
[14:30:05] [ACTION] Step 2: Building Docker image
[14:30:05] [TOOL] Executing: build_docker_image(
dockerfile="services/recommendation/Dockerfile",
tag="recommendation-service:v2.5.0"
)
[14:30:55] [OBSERVATION] Docker build completed:
- Image: recommendation-service:v2.5.0
- Size: 856 MB
- Build time: 50 seconds
- Base image: python:3.11-slim
- Layers: 12
[14:30:55] [ACTION] Pushing image to registry
[14:30:55] [TOOL] Executing: push_image(image="recommendation-service:v2.5.0")
[14:31:20] [OBSERVATION] Image pushed successfully to gcr.io/company/recommendation-service:v2.5.0
[14:31:20] [ACTION] Step 3: Deploying green environment
[14:31:20] [TOOL] Executing: deploy_kubernetes(
service="recommendation-service",
version="v2.5.0",
environment="green",
replicas=3
)
[14:31:25] [OBSERVATION] Kubernetes deployment initiated:
- Deployment: recommendation-service-green
- Replicas: 3
- Namespace: production
- Pods starting...
[14:31:45] [ACTION] Monitoring pod startup
[14:31:46] [OBSERVATION] Pod status:
- recommendation-green-0: Running
- recommendation-green-1: Running
- recommendation-green-2: Running
- All pods ready and passing health checks
[14:31:46] [ACTION] Step 4: Running smoke tests on green environment
[14:31:46] [TOOL] Executing: run_smoke_tests(target="recommendation-green")
[14:32:00] [OBSERVATION] Smoke test results:
- Health check: PASSED (200 OK)
- Database connectivity: PASSED
- Redis connectivity: PASSED
- Kafka connectivity: PASSED
- Sample recommendation request: PASSED (response time: 120ms)
[14:32:00] [THOUGHT] Green environment healthy. Ready to shift traffic.
[14:32:00] [ACTION] Step 5: Starting gradual traffic shift (Blue → Green)
[14:32:00] [TOOL] Executing: traffic_shift(
from_env="blue",
to_env="green",
strategy="gradual",
initial_percentage=10
)
[14:32:05] [OBSERVATION] Traffic shift initiated:
- Blue environment: 90% traffic
- Green environment: 10% traffic
- Monitoring for 5 minutes before next increment
[14:32:05] [ACTION] Monitoring metrics during traffic shift
[14:33:00] [OBSERVATION] Metrics at 10% traffic (1 minute):
- Request rate: 150 req/s to green (1500 req/s total)
- Average latency: 125ms (baseline: 110ms)
- Error rate: 0.1%
- All metrics within acceptable range
[14:35:00] [ACTION] Increasing traffic to 25%
[14:35:01] [TOOL] Executing: traffic_shift(percentage=25)
[14:35:05] [OBSERVATION] Traffic updated:
- Blue: 75%, Green: 25%
[14:36:00] [OBSERVATION] Metrics at 25% traffic (1 minute):
- Request rate: 375 req/s to green
- Average latency: 130ms
- Error rate: 0.2%
- Metrics stable
[14:38:00] [ACTION] Increasing traffic to 50%
[14:38:01] [TOOL] Executing: traffic_shift(percentage=50)
[14:38:05] [OBSERVATION] Traffic updated:
- Blue: 50%, Green: 50%
[14:39:00] [OBSERVATION] Metrics at 50% traffic (1 minute):
- Request rate: 750 req/s to green
- Average latency: 135ms
- Error rate: 0.3%
[14:40:30] [ERROR] Alert triggered: Error rate spike detected!
[14:40:31] [TOOL] Executing: check_error_logs()
[14:40:32] [OBSERVATION] ERROR ANALYSIS:
- Error rate jumped from 0.3% to 4.2% (14x increase)
- Error type: "DatabaseConnectionError: too many connections"
- Affected pods: All 3 green pods
- Pattern: Started at 50% traffic mark
[14:40:32] [THOUGHT] CRITICAL: Connection pool exhausted at 50% traffic!
[14:40:32] [ACTION] Investigating database connection configuration
[14:40:32] [TOOL] Executing: check_db_config(service="recommendation-green")
[14:40:35] [OBSERVATION] Database configuration:
- Max connections per pod: 20
- Total pods: 3
- Total connection capacity: 60 connections
- Current active connections: 58 (approaching limit)
- Database max_connections: 100
- Blue environment using: 40 connections
- Green environment using: 58 connections
- TOTAL: 98/100 connections (98% utilization!)
[14:40:35] [THOUGHT] Root cause identified: Insufficient connection pool size!
- At 50% traffic, green needs ~60 connections
- At 100% traffic, would need ~120 connections
- Database max_connections=100 is insufficient
- We're already at 98% capacity with both environments running
[14:40:35] [ACTION] Initiating emergency rollback
[14:40:35] [TOOL] Executing: rollback_traffic(to_env="blue")
[14:40:40] [OBSERVATION] Traffic rollback initiated:
- Shifting all traffic back to blue environment
- Green: 50% → 25% → 10% → 0%
[14:41:00] [OBSERVATION] Rollback progress:
- Traffic: 100% on blue, 0% on green
- Error rate: Dropping (4.2% → 2.1% → 0.4%)
- Service stabilizing
[14:41:30] [OBSERVATION] Rollback complete:
- Blue environment: 100% traffic, stable
- Green environment: 0% traffic, shutting down
- Error rate: Back to 0.2% (normal baseline)
- Service restored
[14:41:30] [FAILURE] Deployment failed - Root cause analysis:
PRIMARY ISSUE: Database connection pool misconfiguration
- Database max_connections: 100 (too low for blue+green running simultaneously)
- Each environment needs ~60 connections at 50% traffic
- During blue-green deployment, total needed: 100+ connections
- Database cannot support both environments concurrently
CONTRIBUTING FACTORS:
1. Did not account for connection pool sizing during dual environment operation
2. No load testing performed with both blue and green running simultaneously
3. Connection pool monitoring alerts not in place
4. Gradual rollout exposed issue only at 50% mark
LESSONS LEARNED:
1. ⚠️ CRITICAL: Database connection pools must accommodate BOTH environments during deployment
2. ⚠️ Connection pool size = (peak connections per pod) × (total pods in blue + green)
3. ⚠️ Must load test with full capacity of both environments before production
4. ⚠️ Set up connection pool monitoring and alerts BEFORE deployment
5. ⚠️ Have emergency rollback procedure ready (which saved us here)
REMEDIATION PLAN:
1. Increase database max_connections from 100 to 250
2. Adjust connection pool per pod from 20 to 30
3. Add connection pool utilization monitoring
4. Set alert threshold at 70% connection utilization
5. Document connection pool requirements in deployment checklist
DEPLOYMENT STATUS: FAILED
ROLLBACK STATUS: SUCCESSFUL (no customer impact beyond brief error spike)
TIME TO DETECT: 8 minutes
TIME TO ROLLBACK: 1.5 minutes
CUSTOMER IMPACT: 1.5 minutes of elevated errors (4.2% error rate)
[14:41:30] [EXPORT] Saving failure report: reports/deployment-attempt1-failure.json
[14:41:31] [NOTIFICATION] Alerting team about deployment failure and lessons learned
[14:41:31] [SHUTDOWN] Agent session terminated
=================================================================================
KEY TAKEAWAYS FOR NEXT ATTEMPT:
=================================================================================
1. Fix database connection pool sizing BEFORE retry
2. Calculate total connections needed: (pods_blue + pods_green) × connections_per_pod
3. Add connection monitoring to detect issues early
4. Test with both environments running at full capacity
5. Verify database can handle combined load before production deployment
-226
View File
@@ -1,226 +0,0 @@
Agent Runtime Log - Production Microservice Deployment (ATTEMPT 2 - FAILURE)
================================================================================
Session ID: deploy-agent-20241121-0930-attempt2
Task: "Deploy recommendation microservice to production with zero downtime"
Agent: DeploymentAgent v3.2
Start Time: 2024-11-21 09:30:00 UTC
Attempt: 2/3
Previous Attempt: FAILED (database connection pool exhaustion)
[09:30:00] [INIT] Agent initialization started
[09:30:00] [INFO] Task: Deploy recommendation-service v2.5.0 to production (RETRY)
[09:30:00] [INFO] Applying lessons from Attempt 1
[09:30:01] [INFO] Fixed issues:
- ✓ Database max_connections increased: 100 → 250
- ✓ Connection pool per pod increased: 20 → 30
- ✓ Added connection pool monitoring alerts
- ✓ Verified capacity calculation: (3 blue + 3 green) × 30 = 180 connections (72% of 250)
[09:30:01] [SUCCESS] Agent initialized with fixes applied
[09:30:01] [PLANNING] Creating deployment plan (improved from attempt 1)
[09:30:02] [THOUGHT] Key improvements for this attempt:
1. Pre-deployment connection pool validation
2. Monitor connection utilization during traffic shift
3. More gradual traffic ramp (10% → 25% → 50% → 75% → 100%)
4. Longer monitoring windows between increments (5 min instead of 3 min)
[09:30:02] [ACTION] Step 1: Pre-deployment validation
[09:30:02] [TOOL] Executing: validate_database_capacity()
[09:30:05] [OBSERVATION] Database capacity check:
- Current connections (blue only): 42/250 (17% utilization)
- Projected at 100% blue + 100% green: ~180/250 (72% utilization)
- Safety margin: 28%
- ✓ PASS: Sufficient capacity for blue-green deployment
[09:30:05] [THOUGHT] Connection pool capacity validated. Safe to proceed.
[09:30:05] [ACTION] Step 2: Building and pushing Docker image
[09:30:05] [TOOL] Executing: build_and_push_image(tag="v2.5.0")
[09:30:55] [OBSERVATION] Image ready: gcr.io/company/recommendation-service:v2.5.0
[09:30:55] [ACTION] Step 3: Deploying green environment
[09:30:55] [TOOL] Executing: deploy_kubernetes(environment="green", replicas=3)
[09:31:15] [OBSERVATION] Green environment deployed:
- All 3 pods running and healthy
- Health checks passing
- Ready for traffic
[09:31:15] [ACTION] Step 4: Starting gradual traffic shift with enhanced monitoring
[09:31:15] [TOOL] Executing: traffic_shift(percentage=10, monitor_connections=true)
[09:31:20] [OBSERVATION] Traffic at 10%:
- Blue: 90%, Green: 10%
- Monitoring connection pools...
[09:32:20] [OBSERVATION] Metrics after 1 minute at 10%:
- Request rate: 150 req/s to green
- Average latency: 118ms (good)
- Error rate: 0.1% (normal)
- DB connections: Blue 40, Green 15, Total 55/250 (22%)
- ✓ All metrics healthy
[09:36:20] [ACTION] Increasing traffic to 25% (after 5 minute monitoring)
[09:36:21] [TOOL] Executing: traffic_shift(percentage=25)
[09:36:25] [OBSERVATION] Traffic at 25%:
- Blue: 75%, Green: 25%
[09:37:25] [OBSERVATION] Metrics after 1 minute at 25%:
- Request rate: 375 req/s to green
- Average latency: 122ms
- Error rate: 0.1%
- DB connections: Blue 35, Green 28, Total 63/250 (25%)
- ✓ All metrics healthy
[09:41:25] [ACTION] Increasing traffic to 50%
[09:41:26] [TOOL] Executing: traffic_shift(percentage=50)
[09:41:30] [OBSERVATION] Traffic at 50%:
- Blue: 50%, Green: 50%
[09:42:30] [OBSERVATION] Metrics after 1 minute at 50%:
- Request rate: 750 req/s to green
- Average latency: 125ms
- Error rate: 0.2%
- DB connections: Blue 58, Green 62, Total 120/250 (48%)
- ✓ No connection issues! (Fixed from Attempt 1)
[09:43:30] [OBSERVATION] Metrics after 2 minutes at 50%:
- Latency: 128ms
- Error rate: 0.2%
- DB connections: 125/250 (50%)
- ✓ Stable performance
[09:46:30] [ACTION] Increasing traffic to 75%
[09:46:31] [TOOL] Executing: traffic_shift(percentage=75)
[09:46:35] [OBSERVATION] Traffic at 75%:
- Blue: 25%, Green: 75%
[09:47:35] [OBSERVATION] Metrics after 1 minute at 75%:
- Request rate: 1125 req/s to green
- Average latency: 132ms
- Error rate: 0.3%
[09:48:35] [OBSERVATION] Metrics after 2 minutes at 75%:
- Request rate: 1125 req/s
- Average latency: 145ms (increasing trend)
- Error rate: 0.4% (slight increase)
[09:49:35] [OBSERVATION] Metrics after 3 minutes at 75%:
- Request rate: 1125 req/s
- Average latency: 178ms (significant increase!)
- Error rate: 0.6%
- DB connections: 155/250 (62%)
[09:50:00] [WARNING] Latency threshold exceeded: 178ms (threshold: 150ms)
[09:50:01] [ACTION] Investigating latency spike
[09:50:01] [TOOL] Executing: analyze_performance_bottleneck()
[09:50:05] [OBSERVATION] Performance analysis:
- CPU utilization: 45% (normal)
- Memory utilization: 58% (normal)
- Network I/O: Normal
- Database query time: 85ms (elevated from baseline 40ms)
- Database CPU: 78% (high!)
- Database connections: 155 (healthy)
[09:50:05] [THOUGHT] Database performance degrading under load, but not connection related.
[09:51:00] [OBSERVATION] Continued monitoring:
- Average latency: 215ms (getting worse)
- P95 latency: 450ms
- P99 latency: 780ms (SLO violation: >500ms)
- Error rate: 1.2% (increasing)
[09:51:00] [ERROR] SLO violation: P99 latency exceeds 500ms threshold
[09:51:00] [ACTION] Deep dive into database performance
[09:51:00] [TOOL] Executing: analyze_database_queries()
[09:51:10] [OBSERVATION] Query performance analysis:
- Slow query detected: recommendation_candidates lookup
- Query time: 200-400ms (baseline: 30-50ms)
- Cause: Missing database index on new query pattern
- New v2.5.0 feature uses "user_segment" field in WHERE clause
- Index exists on "user_id" but NOT on "user_segment"
- Full table scan on 50M row table causing slowdown
[09:51:10] [THOUGHT] ROOT CAUSE: Missing database index for new query pattern!
- V2.5.0 introduced new recommendation algorithm
- Uses user_segment field not previously indexed
- Works fine at low traffic but degrades at high volume
- This was not caught in staging (smaller data volume)
[09:51:10] [ERROR] CRITICAL: Cannot fix index during deployment (requires DB downtime or long rebuild)
[09:51:10] [ACTION] Initiating rollback to prevent further SLO violations
[09:51:10] [TOOL] Executing: rollback_traffic(to_env="blue")
[09:51:15] [OBSERVATION] Rolling back traffic:
- Green: 75% → 50% → 25% → 10% → 0%
[09:52:00] [OBSERVATION] Rollback progress:
- Traffic: 100% on blue
- Latency dropping: 215ms → 145ms → 110ms
- Error rate: 1.2% → 0.5% → 0.2%
- Service stabilizing
[09:52:30] [OBSERVATION] Rollback complete:
- Blue environment: 100% traffic, stable
- Green environment: 0% traffic, shutting down
- Average latency: 108ms (back to normal)
- Error rate: 0.2% (baseline)
- Service fully restored
[09:52:30] [FAILURE] Deployment failed - Root cause analysis:
PRIMARY ISSUE: Missing database index for new query pattern
- V2.5.0 code uses new WHERE clause on "user_segment" field
- No index exists for this field
- Full table scan on 50M rows causes 200-400ms queries
- Acceptable at low traffic, degrades at high volume
CONTRIBUTING FACTORS:
1. Code review did not flag new query pattern requiring index
2. Staging environment has smaller dataset (5M rows vs 50M in prod)
3. Performance testing in staging didn't reveal the issue
4. Database indexing not part of deployment checklist
5. No query performance monitoring during deployment
WHY ATTEMPT 1 FIX WASN'T ENOUGH:
- Fixed connection pool issue ✓
- But didn't discover the index issue ✗
- Different root causes require different solutions
LESSONS LEARNED (New from Attempt 2):
1. ⚠️ CRITICAL: Analyze ALL new query patterns for index requirements
2. ⚠️ Run EXPLAIN ANALYZE on new queries before deployment
3. ⚠️ Performance test with production-scale data volumes
4. ⚠️ Monitor query execution time during deployment
5. ⚠️ Create required indexes BEFORE deploying code that needs them
6. ⚠️ Staging must have production-like data volume for realistic testing
REMEDIATION PLAN:
1. Create index: CREATE INDEX idx_user_segment ON users(user_segment)
2. Wait for index build to complete (~2 hours on 50M rows)
3. Add index creation to deployment prerequisites checklist
4. Update staging database to match production size
5. Add query performance monitoring to deployment pipeline
DEPLOYMENT STATUS: FAILED
ROLLBACK STATUS: SUCCESSFUL
TIME TO DETECT: 5 minutes at 75% traffic
TIME TO ROLLBACK: 1.5 minutes
CUSTOMER IMPACT: 5 minutes of degraded performance (P99 latency 500-780ms, 1.2% errors)
[09:52:30] [EXPORT] Saving failure report: reports/deployment-attempt2-failure.json
[09:52:31] [NOTIFICATION] Alerting team: Index required before retry
[09:52:31] [SHUTDOWN] Agent session terminated
=================================================================================
KEY TAKEAWAYS FOR NEXT ATTEMPT:
=================================================================================
1. Database index on user_segment MUST be created before retry
2. Verify index exists: SELECT * FROM pg_indexes WHERE indexname = 'idx_user_segment'
3. Add query performance monitoring to catch slow queries early
4. Test with production-scale data before deploying
5. Include "Database Schema Changes" section in deployment checklist
CUMULATIVE LESSONS (From Both Attempts):
- Connection pool sizing for dual environments (Attempt 1)
- Database index requirements for new queries (Attempt 2)
- Both are critical and both must be fixed for success
-374
View File
@@ -1,374 +0,0 @@
Agent Runtime Log - Production Microservice Deployment (ATTEMPT 3 - SUCCESS)
================================================================================
Session ID: deploy-agent-20241122-1000-attempt3
Task: "Deploy recommendation microservice to production with zero downtime"
Agent: DeploymentAgent v3.2
Start Time: 2024-11-22 10:00:00 UTC
Attempt: 3/3
Previous Attempts:
- Attempt 1: FAILED (database connection pool exhaustion)
- Attempt 2: FAILED (missing database index)
[10:00:00] [INIT] Agent initialization started
[10:00:00] [INFO] Task: Deploy recommendation-service v2.5.0 to production (FINAL ATTEMPT)
[10:00:00] [INFO] Applying ALL lessons from previous attempts
[10:00:01] [INFO] Pre-deployment checklist completed:
- ✓ Database max_connections: 250 (increased from 100)
- ✓ Connection pool per pod: 30 (increased from 20)
- ✓ Connection pool monitoring: ACTIVE
- ✓ Database index idx_user_segment: CREATED and READY
- ✓ Index verified: SELECT * FROM pg_indexes → Found
- ✓ Query performance tested with EXPLAIN ANALYZE → 35ms (excellent)
- ✓ Staging tests passed with production-scale data
- ✓ All monitoring alerts configured
[10:00:01] [SUCCESS] Agent initialized - Ready for deployment with all fixes applied
[10:00:01] [PLANNING] Creating comprehensive deployment plan
[10:00:02] [THOUGHT] This attempt incorporates:
FROM ATTEMPT 1: Connection pool sizing for dual environment operation
FROM ATTEMPT 2: Database index requirements and query performance monitoring
NEW FOR ATTEMPT 3: Enhanced pre-deployment validation and longer monitoring windows
[10:00:02] [ACTION] Step 1: Comprehensive pre-deployment validation
[10:00:02] [TOOL] Executing: run_predeploy_checks()
[10:00:10] [OBSERVATION] Pre-deployment validation results:
✓ Database Capacity Check:
- Current connections: 45/250 (18%)
- Projected peak: 180/250 (72%)
- Safety margin: 28% ✓
✓ Database Index Check:
- idx_user_segment exists: YES
- Index size: 1.2 GB
- Index scan cost: Low (35ms query time)
✓ Query Performance Check:
- EXPLAIN ANALYZE user_segment query: 35ms ✓
- All queries under 100ms threshold ✓
✓ Resource Availability:
- Kubernetes nodes: 8 available
- CPU capacity: 45% utilization
- Memory capacity: 52% utilization
✓ Dependency Health:
- PostgreSQL: Healthy
- Redis: Healthy
- Kafka: Healthy
✓ Monitoring Systems:
- Prometheus: Active
- Grafana: Active
- Alert manager: Active
- All deployment alerts configured
[10:00:10] [THOUGHT] All validation checks passed. Proceeding with high confidence.
[10:00:10] [ACTION] Step 2: Building and pushing Docker image
[10:00:10] [TOOL] Executing: build_and_push_image(tag="v2.5.0")
[10:01:00] [OBSERVATION] Image ready: gcr.io/company/recommendation-service:v2.5.0
[10:01:00] [ACTION] Step 3: Deploying green environment
[10:01:00] [TOOL] Executing: deploy_kubernetes(
environment="green",
replicas=3,
resource_limits={"cpu": "2", "memory": "4Gi"}
)
[10:01:05] [OBSERVATION] Kubernetes deployment started...
[10:01:25] [OBSERVATION] Green environment status:
- recommendation-green-0: Running, Ready
- recommendation-green-1: Running, Ready
- recommendation-green-2: Running, Ready
- Health checks: All passing
- Database connections: 15 (healthy)
[10:01:25] [ACTION] Step 4: Running comprehensive smoke tests
[10:01:25] [TOOL] Executing: run_smoke_tests(environment="green", comprehensive=true)
[10:01:45] [OBSERVATION] Smoke test results:
✓ Health endpoint: 200 OK
✓ Database connectivity: Connected, 15 connections
✓ Redis connectivity: Connected
✓ Kafka connectivity: Connected
✓ Sample recommendation request: Success (117ms)
✓ User segment query performance: 33ms (excellent!)
✓ Cache hit rate: 78%
✓ All functional tests: PASSED (20/20)
[10:01:45] [THOUGHT] Green environment fully validated. Beginning gradual traffic shift.
[10:01:45] [ACTION] Step 5: Gradual traffic shift - Stage 1 (10%)
[10:01:45] [TOOL] Executing: traffic_shift(percentage=10, monitoring_window="5min")
[10:01:50] [OBSERVATION] Traffic distribution:
- Blue: 90%, Green: 10%
- Monitoring for 5 minutes...
[10:02:50] [OBSERVATION] Metrics at 10% (1 minute):
- Request rate: 150 req/s to green
- Average latency: 115ms
- P95 latency: 145ms
- P99 latency: 185ms
- Error rate: 0.1%
- DB connections: Blue 40, Green 15, Total 55/250 (22%)
- Query performance: user_segment queries 30-40ms ✓
- ✓ All metrics within target ranges
[10:04:50] [OBSERVATION] Metrics at 10% (3 minutes):
- Average latency: 118ms (stable)
- Error rate: 0.1% (normal)
- DB connections: 57/250 (23%)
- ✓ Performance stable
[10:06:50] [OBSERVATION] Metrics at 10% (5 minutes):
- Average latency: 116ms (stable)
- Error rate: 0.1% (normal)
- DB connections: 56/250 (22%)
- ✓ Stage 1 successful - Ready for next increment
[10:06:50] [ACTION] Step 6: Gradual traffic shift - Stage 2 (25%)
[10:06:51] [TOOL] Executing: traffic_shift(percentage=25, monitoring_window="5min")
[10:06:55] [OBSERVATION] Traffic distribution:
- Blue: 75%, Green: 25%
[10:07:55] [OBSERVATION] Metrics at 25% (1 minute):
- Request rate: 375 req/s to green
- Average latency: 120ms
- P99 latency: 192ms
- Error rate: 0.1%
- DB connections: Blue 35, Green 28, Total 63/250 (25%)
- ✓ Healthy
[10:11:55] [OBSERVATION] Metrics at 25% (5 minutes):
- Average latency: 122ms (stable)
- Error rate: 0.1% (normal)
- DB connections: 65/250 (26%)
- ✓ Stage 2 successful
[10:11:55] [ACTION] Step 7: Gradual traffic shift - Stage 3 (50%)
[10:11:56] [TOOL] Executing: traffic_shift(percentage=50, monitoring_window="8min")
[10:12:00] [OBSERVATION] Traffic distribution:
- Blue: 50%, Green: 50%
[10:13:00] [OBSERVATION] Metrics at 50% (1 minute):
- Request rate: 750 req/s to green
- Average latency: 125ms
- P99 latency: 198ms
- Error rate: 0.2%
- DB connections: Blue 58, Green 62, Total 120/250 (48%)
- ✓ No connection pool issues (fixed from Attempt 1)
[10:15:00] [OBSERVATION] Metrics at 50% (3 minutes):
- Average latency: 127ms (stable)
- P99 latency: 195ms
- Error rate: 0.2%
- DB connections: 122/250 (49%)
- Query performance: 32-38ms (excellent - fixed from Attempt 2)
- ✓ Both previous issues resolved!
[10:20:00] [OBSERVATION] Metrics at 50% (8 minutes):
- Average latency: 126ms (stable)
- P99 latency: 196ms (under 500ms SLO ✓)
- Error rate: 0.2%
- DB connections: 125/250 (50%)
- ✓ Stage 3 successful - Performance excellent
[10:20:00] [ACTION] Step 8: Gradual traffic shift - Stage 4 (75%)
[10:20:01] [TOOL] Executing: traffic_shift(percentage=75, monitoring_window="8min")
[10:20:05] [OBSERVATION] Traffic distribution:
- Blue: 25%, Green: 75%
[10:21:05] [OBSERVATION] Metrics at 75% (1 minute):
- Request rate: 1125 req/s to green
- Average latency: 132ms
- P99 latency: 205ms
- Error rate: 0.2%
- DB connections: Blue 20, Green 88, Total 108/250 (43%)
- ✓ Healthy
[10:24:05] [OBSERVATION] Metrics at 75% (4 minutes):
- Average latency: 134ms (stable, no degradation!)
- P99 latency: 208ms (well under SLO)
- Error rate: 0.2%
- DB connections: 110/250 (44%)
- Query performance: 35-42ms (consistent, index working perfectly)
- ✓ No latency spike (Attempt 2 issue resolved!)
[10:28:05] [OBSERVATION] Metrics at 75% (8 minutes):
- Average latency: 135ms (stable)
- P99 latency: 210ms
- Error rate: 0.2%
- DB connections: 112/250 (45%)
- ✓ Stage 4 successful - Ready for full cutover
[10:28:05] [THOUGHT] All stages successful. Proceeding to 100% with confidence.
[10:28:05] [ACTION] Step 9: Final traffic shift - 100% to Green
[10:28:06] [TOOL] Executing: traffic_shift(percentage=100, monitoring_window="10min")
[10:28:10] [OBSERVATION] Traffic distribution:
- Blue: 0%, Green: 100%
- All production traffic now on v2.5.0
[10:29:10] [OBSERVATION] Metrics at 100% (1 minute):
- Request rate: 1500 req/s (full production load)
- Average latency: 138ms
- P95 latency: 172ms
- P99 latency: 215ms (well under 500ms SLO ✓)
- Error rate: 0.2% (baseline)
- DB connections: 115/250 (46%)
- ✓ Full cutover successful!
[10:33:10] [OBSERVATION] Metrics at 100% (5 minutes):
- Average latency: 137ms (stable)
- P99 latency: 218ms
- Error rate: 0.2%
- DB connections: 118/250 (47%)
- Cache hit rate: 79%
- Throughput: 1500 req/s
- ✓ Service performing excellently
[10:38:10] [OBSERVATION] Metrics at 100% (10 minutes):
- Average latency: 136ms (stable and excellent)
- P99 latency: 216ms (SLO: <500ms ✓)
- Error rate: 0.2% (normal baseline)
- DB connections: 120/250 (48% - plenty of headroom)
- Query performance: 33-40ms (index performing perfectly)
- ✓ Service stable at full production load
[10:38:10] [SUCCESS] Deployment completed successfully!
[10:38:10] [ACTION] Step 10: Cleanup old blue environment
[10:38:10] [TOOL] Executing: cleanup_old_environment(environment="blue")
[10:38:15] [OBSERVATION] Blue environment shutdown:
- Scaled down to 0 replicas
- Resources released
- DNS cutover complete
- Only green (v2.5.0) running
[10:38:15] [ACTION] Final validation and monitoring setup
[10:38:15] [TOOL] Executing: setup_continuous_monitoring()
[10:38:20] [OBSERVATION] Continuous monitoring configured:
- Latency alerts: P99 > 500ms
- Error rate alerts: > 1%
- Connection pool alerts: > 70%
- Query performance alerts: > 100ms
- Resource utilization alerts: > 80%
- All dashboards updated
[10:38:20] [SUCCESS] Deployment fully complete and stable!
=================================================================================
DEPLOYMENT SUCCESS SUMMARY
=================================================================================
FINAL METRICS:
- Average latency: 136ms (target: <200ms) ✓
- P99 latency: 216ms (SLO: <500ms) ✓
- Error rate: 0.2% (baseline, target: <1%) ✓
- Throughput: 1500 req/s (full production) ✓
- DB connections: 120/250 (48% utilization) ✓
- Zero downtime achieved ✓
DEPLOYMENT TIMELINE:
- Total deployment time: 38 minutes
- Traffic shift duration: 36 minutes (gradual)
- Monitoring windows: Conservative (5-10 min per stage)
- Zero customer impact ✓
ISSUES RESOLVED:
✓ Attempt 1 issue: Database connection pool sizing
- Fixed: Increased max_connections 100→250, pool per pod 20→30
- Result: 48% utilization at full load (plenty of headroom)
✓ Attempt 2 issue: Missing database index
- Fixed: Created idx_user_segment before deployment
- Result: Query time 33-40ms (was 200-400ms without index)
=================================================================================
COMPREHENSIVE LESSONS LEARNED (Across All 3 Attempts)
=================================================================================
1. DATABASE CAPACITY PLANNING (From Attempt 1):
⚠️ Calculate connections for BOTH environments during blue-green deployment
⚠️ Formula: (pods_blue + pods_green) × connections_per_pod + safety_margin
⚠️ Monitor connection utilization in real-time during deployment
⚠️ Set alerts at 70% threshold, rollback at 85%
2. DATABASE SCHEMA CHANGES (From Attempt 2):
⚠️ Analyze ALL new query patterns for index requirements
⚠️ Run EXPLAIN ANALYZE on new queries before production
⚠️ Create required indexes BEFORE deploying code
⚠️ Test with production-scale data volumes (not just staging)
⚠️ Monitor query execution times during deployment
3. DEPLOYMENT BEST PRACTICES (From Attempt 3):
⚠️ Comprehensive pre-deployment checklist is MANDATORY
⚠️ Gradual traffic shift with adequate monitoring windows
⚠️ Conservative approach: 10% → 25% → 50% → 75% → 100%
⚠️ Longer monitoring at critical stages (50%, 75%, 100%)
⚠️ Have automated rollback ready at every stage
4. MONITORING AND OBSERVABILITY:
⚠️ Connection pool utilization monitoring
⚠️ Query performance monitoring (slow query detection)
⚠️ Latency tracking (average, P95, P99)
⚠️ Error rate monitoring with automatic alerts
⚠️ Resource utilization (CPU, memory, network)
5. TESTING REQUIREMENTS:
⚠️ Staging must have production-like data volumes
⚠️ Load testing with both blue and green at capacity
⚠️ Query performance testing with EXPLAIN ANALYZE
⚠️ End-to-end smoke tests on green before traffic shift
⚠️ Rollback procedure testing in staging
=================================================================================
DEPLOYMENT CHECKLIST (For Future Use)
=================================================================================
PRE-DEPLOYMENT:
□ Database capacity validated (connection pools)
□ All required indexes created and verified
□ Query performance tested with EXPLAIN ANALYZE
□ Staging tests passed with production-scale data
□ Monitoring alerts configured
□ Rollback procedure tested
□ Team notified of deployment window
DURING DEPLOYMENT:
□ Green environment health checks passing
□ Smoke tests passed
□ Gradual traffic shift with monitoring
□ Connection pool utilization < 70%
□ Query performance < 100ms
□ Latency within SLOs
□ Error rate < 1%
POST-DEPLOYMENT:
□ Blue environment cleaned up
□ Continuous monitoring active
□ Documentation updated
□ Lessons learned documented
□ Team notified of success
[10:38:20] [EXPORT] Saving success report: reports/deployment-attempt3-success.json
[10:38:21] [EXPORT] Updating deployment playbook with lessons learned
[10:38:22] [NOTIFICATION] Alerting team: Deployment successful! 🎉
[10:38:22] [SHUTDOWN] Agent session terminated
=================================================================================
KEY SUCCESS FACTORS:
=================================================================================
1. ✓ Applied lessons from BOTH previous failures
2. ✓ Comprehensive pre-deployment validation
3. ✓ Conservative, gradual traffic shift approach
4. ✓ Adequate monitoring windows at each stage
5. ✓ Fixed root causes, not just symptoms
6. ✓ Documented everything for future reference
ATTEMPTS SUMMARY:
- Attempt 1: Failed (connection pools) → Learned capacity planning
- Attempt 2: Failed (missing index) → Learned schema validation
- Attempt 3: Success! → Applied all lessons learned
This demonstrates the power of learning from failures! 🚀
Binary file not shown.
-66
View File
@@ -1,66 +0,0 @@
# MemU Assistant - Sealos DevBox Example
A personal AI assistant with long-term memory, designed for deployment on [Sealos DevBox](https://sealos.io/products/devbox).
## Features
- **Persistent Memory**: Remembers user preferences and past conversations
- **REST API**: Simple endpoints for chat, memorize, and recall
- **OpenAI Compatible**: Works with OpenAI, Nebius, Groq, and other providers
- **1-Click Deploy**: Ready for Sealos DevBox deployment
## Quick Start
### Local Development
```bash
# Install dependencies
pip install -r requirements.txt
# Configure environment
cp .env.example .env
# Edit .env with your API key
# Run the server
python main.py
```
### Deploy on Sealos DevBox
1. Create a Python DevBox on [Sealos](https://cloud.sealos.io)
2. Clone this project
3. Set environment variables
4. Click **Deploy**
See the full guide: [docs/sealos-devbox-guide.md](../../docs/sealos-devbox-guide.md)
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Service info |
| `/health` | GET | Health check |
| `/chat` | POST | Chat with memory |
| `/memorize` | POST | Store information |
| `/recall` | GET | Query memories |
## Example Usage
```bash
# Store a memory
curl -X POST http://localhost:8000/memorize \
-H "Content-Type: application/json" \
-d '{"content": "I prefer Python and dark mode"}'
# Chat with memory
curl -X POST http://localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{"message": "What are my preferences?"}'
# Recall memories
curl "http://localhost:8000/recall?query=preferences"
```
## License
MIT - Part of the MemU project
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
# Sealos DevBox entrypoint script
# This script is executed when deploying to production
set -e
# Activate virtual environment if it exists
if [ -d "venv" ]; then
source venv/bin/activate
fi
# Start the application
exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}
-286
View File
@@ -1,286 +0,0 @@
"""
Personal AI Assistant with Long-Term Memory
Powered by MemU + FastAPI on Sealos DevBox
This example demonstrates how to build a memory-enabled AI assistant
that can be deployed on Sealos DevBox with 1-click deployment.
Usage:
# Local development
pip install -r requirements.txt
python main.py
# Or with uvicorn
uvicorn main:app --reload --host 0.0.0.0 --port 8000
"""
import os
import sys
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
load_dotenv()
# Add src to path for local development
src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "src"))
if os.path.exists(src_path):
sys.path.insert(0, src_path)
from memu.app import MemoryService
# Global memory service instance
memory_service: MemoryService | None = None
def get_llm_profiles() -> dict:
"""Build LLM profiles from environment variables."""
base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY environment variable is required")
return {
"default": {
"provider": "openai",
"base_url": base_url,
"api_key": api_key,
"chat_model": os.getenv("CHAT_MODEL", "gpt-4o-mini"),
"client_backend": "sdk",
},
"embedding": {
"provider": "openai",
"base_url": base_url,
"api_key": api_key,
"embed_model": os.getenv("EMBED_MODEL", "text-embedding-3-small"),
"client_backend": "sdk",
},
}
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize MemU memory service on startup."""
global memory_service
try:
llm_profiles = get_llm_profiles()
memory_service = MemoryService(llm_profiles=llm_profiles)
print("✓ MemU Memory Service initialized successfully")
except Exception as e:
print(f"✗ Failed to initialize MemU: {e}")
raise
yield
print("Shutting down MemU Assistant...")
app = FastAPI(
title="MemU Assistant",
description="AI Assistant with Long-Term Memory powered by MemU",
version="1.0.0",
lifespan=lifespan,
)
# Enable CORS for web clients
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request/Response Models
class ChatRequest(BaseModel):
message: str
user_id: str = "default"
class ChatResponse(BaseModel):
response: str
memories_used: int
memories_stored: int
class MemorizeRequest(BaseModel):
content: str
user_id: str = "default"
class MemorizeResponse(BaseModel):
status: str
items_created: int
categories: int
class RecallResponse(BaseModel):
query: str
memories_found: int
memories: list[dict]
# API Endpoints
@app.get("/")
async def root():
"""Service information and available endpoints."""
return {
"service": "MemU Assistant",
"description": "AI Assistant with Long-Term Memory",
"version": "1.0.0",
"status": "running",
"endpoints": {
"GET /": "This info",
"GET /health": "Health check",
"POST /chat": "Chat with memory-aware AI",
"POST /memorize": "Store information in memory",
"GET /recall": "Query stored memories",
},
}
@app.get("/health")
async def health():
"""Health check endpoint."""
return {
"status": "healthy",
"memory_service_initialized": memory_service is not None,
}
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""
Chat with the AI assistant.
The assistant will:
1. Retrieve relevant memories from past conversations
2. Use those memories as context for the response
3. Store new information from the conversation
"""
if not memory_service:
raise HTTPException(status_code=503, detail="Memory service not initialized")
try:
# Retrieve relevant memories
retrieve_result = await memory_service.retrieve(queries=[{"role": "user", "content": request.message}])
memories = retrieve_result.get("items", [])
# Build context from memories
memory_context = []
for mem in memories[:5]:
if isinstance(mem, dict):
summary = mem.get("summary", str(mem))
memory_context.append(summary)
# Generate response (in production, use full LLM with memory context)
if memory_context:
response_text = (
f"Based on what I remember about you, here's my response to: '{request.message}'\n\n"
f"Relevant context from our past conversations:\n"
+ "\n".join(f"- {ctx[:100]}..." if len(ctx) > 100 else f"- {ctx}" for ctx in memory_context)
)
else:
response_text = f"I received your message: '{request.message}'. I don't have any relevant memories yet."
# Store the conversation
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write(f"User ({request.user_id}) said: {request.message}")
temp_file = f.name
try:
memorize_result = await memory_service.memorize(
resource_url=temp_file,
modality="text",
)
memories_stored = len(memorize_result.get("items", []))
finally:
os.unlink(temp_file)
return ChatResponse(
response=response_text,
memories_used=len(memories),
memories_stored=memories_stored,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Chat failed: {e!s}")
@app.post("/memorize", response_model=MemorizeResponse)
async def memorize(request: MemorizeRequest):
"""Store information in long-term memory."""
if not memory_service:
raise HTTPException(status_code=503, detail="Memory service not initialized")
try:
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write(f"[User: {request.user_id}] {request.content}")
temp_file = f.name
try:
result = await memory_service.memorize(
resource_url=temp_file,
modality="text",
)
return MemorizeResponse(
status="stored",
items_created=len(result.get("items", [])),
categories=len(result.get("categories", [])),
)
finally:
os.unlink(temp_file)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Memorize failed: {e!s}")
@app.get("/recall", response_model=RecallResponse)
async def recall(query: str, limit: int = 5):
"""Recall memories related to a query."""
if not memory_service:
raise HTTPException(status_code=503, detail="Memory service not initialized")
try:
result = await memory_service.retrieve(queries=[{"role": "user", "content": query}])
items = result.get("items", [])[:limit]
memories = []
for item in items:
if isinstance(item, dict):
memories.append({
"summary": item.get("summary", str(item)),
"category": item.get("category", "unknown"),
})
else:
memories.append({"summary": str(item), "category": "unknown"})
return RecallResponse(
query=query,
memories_found=len(memories),
memories=memories,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Recall failed: {e!s}")
if __name__ == "__main__":
import uvicorn
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", 8000))
print(f"Starting MemU Assistant on {host}:{port}")
uvicorn.run("main:app", host=host, port=port, reload=True)
@@ -1,4 +0,0 @@
memu>=0.1.0
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
python-dotenv>=1.0.0
-70
View File
@@ -1,70 +0,0 @@
import sys
import time
# Intentamos importar la librería instalada por uv
try:
from memu import Memory # noqa: F401
MEMU_INSTALLED = True
except ImportError as e:
# Si falla, guardamos el error para debug
MEMU_INSTALLED = False
IMPORT_ERROR = str(e)
def print_slow(text, delay=0.02):
"""Typing effect for realism"""
for char in text:
sys.stdout.write(char)
sys.stdout.flush()
time.sleep(delay)
print()
def run_rigorous_demo():
print("\n🚀 Starting Sealos Support Agent Demo (Offline Mode)")
print("===================================================\n")
# 1. ENVIRONMENT CHECK
if MEMU_INSTALLED:
print("✅ Environment Check: MemU Library detected (Installed via uv).")
print("✅ Runtime: Sealos Devbox (Python 3.13+)")
else:
# En caso de error, mostramos advertencia pero permitimos la captura
print("⚠️ Warning: MemU library not detected. Running in Simulation Mode.")
if "IMPORT_ERROR" in globals():
print(f" Debug Error: {IMPORT_ERROR}")
time.sleep(0.5)
# 2. MEMORY INGESTION (PHASE 1)
print("\n📝 --- Phase 1: Ingesting Conversation History ---")
print('👤 Captain: "I\'m getting a 502 Bad Gateway error on port 3000."')
print_slow("🤖 Agent: (Processing input through Memory Pipeline...)", delay=0.01)
time.sleep(1.0)
print("✅ Memory stored! extracted 2 items:")
print(" - [issue] 502 Bad Gateway error")
print(" - [context] port 3000 configuration")
# 3. CONTEXT RETRIEVAL (PHASE 2)
print("\n🔍 --- Phase 2: Retrieval on New Interaction (New Session) ---")
print('👤 Captain: "Hello, any updates?"')
print_slow("🤖 Agent: (Searching vector store for user 'Captain'...)", delay=0.01)
time.sleep(1.0)
print("\n💡 Retrieved Context:")
print(" Found Memory (Score: 0.98): User reported 502 error on port 3000")
print(" Found Memory (Score: 0.95): User was frustrated with timeout")
# 4. AGENT RESPONSE (PHASE 3)
print("\n💬 --- Phase 3: Agent Response ---")
response = '🤖 Agent: "Welcome back, Captain. Regarding the 502 Bad Gateway error on port 3000 you reported earlier - have you tried checking the firewall logs?"'
print_slow(response)
print("\n✨ Demo Completed Successfully")
print("===================================================")
if __name__ == "__main__":
run_rigorous_demo()
-228
View File
@@ -1,228 +0,0 @@
"""
Test script to verify Nebius AI works with MemU.
Before running:
1. Get your Nebius API key from https://tokenfactory.nebius.com
2. Set environment variable: NEBIUS_API_KEY=your_key_here
Usage:
cd memU
set NEBIUS_API_KEY=your_key_here
python examples/test_nebius_provider.py
Nebius provides:
- Chat models: Qwen, DeepSeek, Llama, etc.
- Embedding models: BGE, E5, Qwen3-Embedding
- All via OpenAI-compatible API
"""
import asyncio
import os
import sys
# Add src to path for local development
src_path = os.path.abspath("src")
sys.path.insert(0, src_path)
# Nebius configuration
NEBIUS_BASE_URL = "https://api.tokenfactory.nebius.com/v1/"
# Available chat models (pick one):
# - "Qwen/Qwen3-30B-A3B-Instruct-2507" (fast, cheap)
# - "Qwen/Qwen3-32B" (good balance)
# - "deepseek-ai/DeepSeek-V3-0324" (powerful)
# - "meta-llama/Llama-3.3-70B-Instruct" (reliable)
NEBIUS_CHAT_MODEL = "Qwen/Qwen3-30B-A3B-Instruct-2507"
# Available embedding models:
# - "BAAI/bge-multilingual-gemma2" (3584 dims, multilingual)
# - "BAAI/BGE-ICL" (4096 dims)
# - "intfloat/e5-mistral-7b-instruct" (4096 dims)
# - "Qwen/Qwen3-Embedding-8B" (4096 dims)
NEBIUS_EMBED_MODEL = "BAAI/bge-multilingual-gemma2"
async def test_nebius_chat():
"""Test Nebius chat completion."""
from openai import AsyncOpenAI
api_key = os.environ.get("NEBIUS_API_KEY")
if not api_key:
print("ERROR: Set NEBIUS_API_KEY environment variable")
return False
client = AsyncOpenAI(
base_url=NEBIUS_BASE_URL,
api_key=api_key,
)
print(f"Testing Nebius Chat API ({NEBIUS_CHAT_MODEL})...")
try:
response = await client.chat.completions.create(
model=NEBIUS_CHAT_MODEL,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Say hello in one sentence."},
],
max_tokens=50,
)
content = response.choices[0].message.content
# Truncate long responses for display
display = content[:100] + "..." if len(content) > 100 else content
print(f" Response: {display}")
print(" ✓ Chat API works!")
return True
except Exception as e:
print(f" ✗ Chat API failed: {e}")
return False
async def test_nebius_embeddings():
"""Test Nebius embeddings API."""
from openai import AsyncOpenAI
api_key = os.environ.get("NEBIUS_API_KEY")
if not api_key:
print("ERROR: Set NEBIUS_API_KEY environment variable")
return False
client = AsyncOpenAI(
base_url=NEBIUS_BASE_URL,
api_key=api_key,
)
print(f"\nTesting Nebius Embeddings API ({NEBIUS_EMBED_MODEL})...")
try:
response = await client.embeddings.create(
model=NEBIUS_EMBED_MODEL,
input=["Hello world", "This is a test"],
)
print(f" Embedding dimensions: {len(response.data[0].embedding)}")
print(f" Number of embeddings: {len(response.data)}")
print(" ✓ Embeddings API works!")
return True
except Exception as e:
print(f" ✗ Embeddings API failed: {e}")
return False
async def test_memu_with_nebius():
"""Test MemU with Nebius as the LLM provider."""
from memu.app import MemoryService
api_key = os.environ.get("NEBIUS_API_KEY")
if not api_key:
print("ERROR: Set NEBIUS_API_KEY environment variable")
return False
print("\nTesting MemU with Nebius provider...")
# Configure MemU to use Nebius (using dict config like example_1)
llm_profiles = {
"default": {
"provider": "openai",
"base_url": NEBIUS_BASE_URL,
"api_key": api_key,
"chat_model": NEBIUS_CHAT_MODEL,
"client_backend": "sdk",
},
"embedding": {
"provider": "openai",
"base_url": NEBIUS_BASE_URL,
"api_key": api_key,
"embed_model": NEBIUS_EMBED_MODEL,
"client_backend": "sdk",
},
}
try:
# Create MemU service with Nebius
service = MemoryService(llm_profiles=llm_profiles)
print(" ✓ MemoryService initialized with Nebius!")
# Test memorize with a file (create temp file)
print("\n Testing memorize...")
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write("User likes Python programming and AI development. They prefer dark mode in their IDE.")
temp_file = f.name
try:
result = await service.memorize(
resource_url=temp_file,
modality="text",
)
items_count = len(result.get("items", []))
categories_count = len(result.get("categories", []))
print(f" ✓ Memorized! Items: {items_count}, Categories: {categories_count}")
# Show what was extracted
for item in result.get("items", [])[:3]:
summary = item.get("summary", "")[:80]
print(f" - {summary}...")
finally:
os.unlink(temp_file)
# Test retrieve
print("\n Testing retrieve...")
retrieve_result = await service.retrieve(
queries=[{"role": "user", "content": "What programming language does the user like?"}]
)
print(f" ✓ Retrieved! Needs retrieval: {retrieve_result.get('needs_retrieval')}")
items = retrieve_result.get("items", [])
if items:
print(f" Found {len(items)} relevant items:")
for item in items[:3]:
if isinstance(item, dict):
summary = item.get("summary", str(item))[:60]
else:
summary = str(item)[:60]
print(f" - {summary}...")
print("\n" + "=" * 60)
print("✓ SUCCESS: MemU works with Nebius!")
print("=" * 60)
return True
except Exception as e:
print(f" ✗ MemU with Nebius failed: {e}")
import traceback
traceback.print_exc()
return False
async def main():
print("=" * 60)
print("Nebius AI + MemU Integration Test")
print("=" * 60)
print(f"Base URL: {NEBIUS_BASE_URL}")
print(f"Chat Model: {NEBIUS_CHAT_MODEL}")
print(f"Embed Model: {NEBIUS_EMBED_MODEL}")
print("=" * 60 + "\n")
# Check for API key first
if not os.environ.get("NEBIUS_API_KEY"):
print("ERROR: NEBIUS_API_KEY environment variable not set!")
print("\nTo get your API key:")
print("1. Go to https://tokenfactory.nebius.com")
print("2. Create an account / Log in")
print("3. Get your API key")
print("4. Run: set NEBIUS_API_KEY=your_key_here")
return
# Test individual APIs first
chat_ok = await test_nebius_chat()
embed_ok = await test_nebius_embeddings()
if chat_ok and embed_ok:
# Test full MemU integration
await test_memu_with_nebius()
else:
print("\n" + "=" * 60)
print("✗ FAILED: Basic API tests failed, skipping MemU test")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
+12 -16
View File
@@ -1,8 +1,6 @@
# memu-cli
CLI for [memU](https://github.com/NevaMind-AI/MemU) — personal memory as files. Compile conversations, documents, and agent traces into a Markdown memory tree (`INDEX.md` / `MEMORY.md` / `SKILL.md`), then retrieve compact, ranked context instead of restuffing every prompt.
> 🚧 memU is under active rework; the workspace pair below is the primary surface, other commands and flags may still change.
CLI for [memU](https://github.com/NevaMind-AI/MemU) — personal memory as files. Persist agent-prepared memory and skill documents, then retrieve compact, ranked context instead of restuffing every prompt. Embedding-only: no LLM call happens inside memU.
## Quick start
@@ -18,27 +16,25 @@ Then:
```bash
export OPENAI_API_KEY=sk-...
# diff-sync a workspace folder into memory
# (chat/ -> memory topics, agent/ -> skills, everything else -> indexed context)
npx memu-cli memorize-workspace ./workspace
# persist prepared memory: {"recall_files": [...], "resource": [...]}
npx memu-cli commit results.json
# list every stored memory/skill file
npx memu-cli list-files
# single-shot embedding retrieval — LLM-free, fast
npx memu-cli retrieve-workspace "deploy checklist"
# rebuild the INDEX.md / MEMORY.md / SKILL.md markdown tree
npx memu-cli export
npx memu-cli retrieve "deploy checklist"
```
State persists in a local SQLite database (`./data/memu.sqlite3` by default), so memorize in one invocation and retrieve in the next. Every flag has a `MEMU_*` environment variable (`--provider`/`MEMU_LLM_PROVIDER`, `--model`/`MEMU_CHAT_MODEL`, `--db`/`MEMU_DB`, ...) — run `npx memu-cli <command> --help` for the full list.
State persists in a local SQLite database (`./data/memu.sqlite3` by default), so commit in one invocation and retrieve in the next. Every flag has a `MEMU_*` environment variable (`--provider`/`MEMU_LLM_PROVIDER`, `--embed-model`/`MEMU_EMBED_MODEL`, `--db`/`MEMU_DB`, ...) — run `npx memu-cli <command> --help` for the full list.
## Commands
| Command | Alias | What it does |
|---|---|---|
| `memorize-workspace <folder>` | `sync` | Diff-sync a folder into memory; only changed files are reprocessed |
| `retrieve-workspace <query>` | `search` | Rank memory segments, files, and resources by embedding similarity — zero LLM calls |
| `export` | | Rebuild the markdown memory tree from the store |
| `memorize <file>` / `retrieve <query>` | | Legacy single-file pair — LLM-routed, heavier; prefer the workspace pair |
| `retrieve <query>` | `search` | Rank memory segments, files, and resources by embedding similarity — zero LLM calls |
| `list-files` | | List every recall file across the memory and skill tracks |
| `commit <payload.json>` | | Persist externally-prepared recall files and resources (`-` reads stdin) |
## How it works
@@ -49,7 +45,7 @@ This npm package is a thin launcher (a few kB, no dependencies). The engine is t
3. `pipx run --spec memu-cli memu` — no install needed, cached by pipx
4. `python3 -m memu` — requires `pip install memu-cli`
Using Python already? Skip npm entirely: `uvx --from memu-cli memu --help`, or `pip install memu-cli`. For the library API (`MemoryService`, LangGraph integration, Postgres backends), use [`memu-py`](https://pypi.org/project/memu-py/) — but install only one of the two in a given environment; they ship the same `memu` module.
Using Python already? Skip npm entirely: `uvx --from memu-cli memu --help`, or `pip install memu-cli`. For the library API (`MemoryService`, Postgres backends), use [`memu-py`](https://pypi.org/project/memu-py/) — but install only one of the two in a given environment; they ship the same `memu` module.
See the [main README](https://github.com/NevaMind-AI/MemU#readme) for the memory model, library API, and self-hosting options.
-30
View File
@@ -18,8 +18,6 @@ classifiers = [
]
keywords = ["agent", "agentic", "agent-harness", "harness", "loop-engineering", "context-engineering", "context-window", "memory", "personal-information", "workspace", "retrieval", "llm"]
dependencies = [
"anthropic>=0.40.0",
"defusedxml>=0.7.1",
"httpx>=0.28.1",
"numpy>=2.3.4",
"openai>=2.8.0",
@@ -27,7 +25,6 @@ dependencies = [
"sqlmodel>=0.0.27",
"alembic>=1.14.0",
"pendulum>=3.1.0",
"langchain-core>=1.2.7",
]
[build-system]
@@ -41,17 +38,8 @@ python-source = "src"
[dependency-groups]
dev = [
{include-group = "docs"},
{include-group = "lint"},
{include-group = "test"},
"langchain-openai>=1.1.7",
"langgraph>=1.0.6",
]
docs = [
"mkdocs>=1.6.1",
"mkdocs-material>=9.6.23",
"mkdocstrings>=0.30.1",
"mkdocstrings-python>=1.18.2",
]
lint = [
"deptry>=0.23.1",
@@ -68,11 +56,6 @@ test = [
[project.optional-dependencies]
postgres = ["pgvector>=0.3.4", "sqlalchemy[postgresql-psycopgbinary]>=2.0.36"]
langgraph = ["langgraph>=0.0.10", "langchain-core>=0.1.0"]
claude = ["claude-agent-sdk>=0.1.24"]
lazyllm = ["lazyllm>=0.7.3"]
# Rich document ingestion (PDF, Word, PowerPoint, Excel, ...) via MarkItDown.
document = ["markitdown[docx,pptx,xlsx,xls,pdf]>=0.1.0"]
[project.scripts]
memu = "memu.cli:main"
@@ -85,10 +68,6 @@ memu-codex = "memu.hosts.codex.cli:main"
"Bug Tracker" = "https://github.com/NevaMind-AI/MemU/issues"
"Documentation" = "https://github.com/NevaMind-AI/MemU#readme"
[tool.deptry.per_rule_ignores]
# Optional dependencies used in examples/ or imported lazily behind extras.
DEP002 = ["claude-agent-sdk", "markitdown"]
[tool.mypy]
files = ["src", "tests"]
python_version = "3.13"
@@ -107,18 +86,10 @@ disallow_incomplete_defs = false
warn_unused_ignores = false
disable_error_code = ["attr-defined", "call-arg"]
[[tool.mypy.overrides]]
module = ["lazyllm.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["pgvector.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["markitdown", "markitdown.*"]
ignore_missing_imports = true
[tool.ruff]
target-version = "py313"
line-length = 120
@@ -166,7 +137,6 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]
"examples/*" = ["E402", "TRY003", "B904", "S104", "TRY300", "SIM108"]
[tool.ruff.format]
preview = true
-542
View File
@@ -1,542 +0,0 @@
![MemU Banner](../assets/banner.png)
<div align="center">
# memU
### Personal memory, stored as files
**Fast retrieval. Higher accuracy. Lower cost.**
[![PyPI version](https://badge.fury.io/py/memu-py.svg)](https://badge.fury.io/py/memu-py)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
[![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/hQZntfGsbJ)
[![Twitter](https://img.shields.io/badge/Twitter-Follow-1DA1F2?logo=x&logoColor=white)](https://x.com/memU_ai)
<a href="https://trendshift.io/repositories/17374" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17374" alt="NevaMind-AI%2FmemU | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**[English](README_en.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [Español](README_es.md) | [Français](README_fr.md)**
</div>
---
> [!WARNING]
> 🚧 **Under heavy construction** — memU is undergoing a major rework. APIs, CLI commands, and docs may change without notice. Things are expected to stabilize around **July 15, 2026**.
memU compiles conversations, documents, code, images, audio, video, URLs, and tool traces into human-readable Markdown files (`INDEX.md`, `MEMORY.md`, `SKILL.md`). Agents traverse the tree and load only what the moment needs — instead of rescanning everything or stuffing long histories into every prompt:
- **`INDEX.md`** — a map of everything: categories, files, and summaries, so the agent knows where to look first
- **`MEMORY.md`** — profile, preferences, goals, facts, and key events extracted from source data
- **`SKILL.md`** — learned tool patterns and workflows, auto-extracted from tool traces and refined on every `memorize()`
```txt
workspace/
├── INDEX.md ← map of everything: categories, files, and summaries
├── MEMORY.md ← profile, preferences, goals, and key events
└── skill/
├── {skill_name}/
│ └── SKILL.md ← a learned skill or tool pattern
└── {another_skill}/
└── SKILL.md
```
Three things make it different from stuffing everything into the prompt:
- **Fast retrieval** — walk to the right folder and rank the right files instead of scanning everything every time.
- **Higher accuracy** — scope by user, task, or session, and trace every item back to the exact source it came from.
- **Lower cost** — retrieve compact, scoped context instead of reinjecting long histories into every prompt.
- **Yours to inspect** — a human-readable file tree you can audit, edit, and route through your own storage and LLM providers.
---
## 🔄 How It Works
Think of it as two file-system operations: **writing** raw sources into organized memory, and **reading** the right files back into the agent.
```
WRITE — memorize() READ — retrieve()
────────────────────────────────────────────── ──────────────────────────────────────────────
raw files → extract → files + folders query → walk folders → ranked files
───────────── ───────── ────────────── ───── ──────────── ─────────────
chat logs → parse → profile / event items user / task query
documents / URLs → facts → knowledge / skill items │
images / video → caption → resources + summaries ├─ route + scope → relevant folders (categories)
audio → transcribe→ event / knowledge items ├─ rank by relevance → matching files (items)
tool logs → mine → tool / skill items └─ trace to source → original resources
```
**Writing to the file system (`memorize`)**
1. **Ingest** — store each source as a `Resource` (the raw file) with its modality and source location
2. **Preprocess** — parse text, caption images/video, transcribe audio, and normalize inputs
3. **Extract** — turn raw content into typed `RecallEntry` records (the files): profile, event, knowledge, behavior, skill, or tool memories
4. **Organize** — sort entries into `RecallFile` folders (a memory category, or a skill), cross-link, embed, and summarize into a browsable tree
5. **Persist** — write records, relations, embeddings, and folder summaries through the configured backend
**Reading from the file system (`retrieve`)**
6. **Retrieve** — navigate the folders and return only the files relevant to the current user, agent, session, or task
---
## 🗂️ The Memory File System
memU's primary output is a navigable memory tree — folders, files, and the source artifacts behind them — persisted through repository contracts and returned as dictionaries from `memorize()` and `retrieve()`.
```txt
RecallFile ← folder: a topic with an evolving summary
├── name, track (memory | skill), description, content
├── embedding
└── RecallEntry[] ← files: typed, atomic memories
├── memory_type: profile | event | knowledge | behavior | skill | tool
├── summary, extra, happened_at, embedding
└── Resource ← source: the raw file this memory came from
└── url, modality, local_path, caption, embedding
```
| Record | File-System Role | Used By |
|--------|------------------|---------|
| `RecallFile` | **Folder** — groups related memories (or holds a skill) and keeps a topic-level summary; a `track` field marks it `memory` or `skill` | Load compact context for broad queries |
| `RecallEntry` | **File** — a typed atomic memory with a summary and optional metadata | Inject precise facts, preferences, events, skills, and tool patterns |
| `Resource` | **Source artifact** — the original file behind a memory, with caption/text | Trace context back to where it came from |
| `RecallFileEntry` | **Link** — the edge that files an entry under a folder | Navigate related memories without reprocessing the source |
This gives agents a stable file system for memory: ingest raw sources once, then request scoped and ranked files instead of rereading every source artifact.
---
## 🧩 What memU Builds
Every layer of the file system is stored as a structured record:
| Layer | What It Represents | Why Agents Use It |
|-------|--------------------|-------------------|
| **RecallFile** | Auto-generated folder: a topic (or a skill) with an evolving summary | Load high-level context before drilling into details |
| **RecallEntry** | A file: atomic structured memory with a type and summary | Inject precise facts, preferences, events, skills, and tool patterns |
| **Resource** | Source artifact behind a file: conversation, document, image, video, audio, URL, or file | Trace memory back to its source |
| **RecallFileEntry** | The link that files an entry under a folder | Navigate related memories without reprocessing the source |
| **Embedding** | Vector index over folders, files, and sources | Retrieve relevant context with low latency |
Example `memorize()` output:
```json
{
"resource": {
"id": "res_01",
"url": "files/launch-meeting.mp4",
"modality": "video",
"caption": "A product planning discussion about onboarding and launch risks."
},
"items": [
{
"id": "mem_01",
"memory_type": "event",
"summary": "The team decided to simplify onboarding before the next launch review."
},
{
"id": "mem_02",
"memory_type": "profile",
"summary": "The user prefers concise implementation plans with explicit verification steps."
},
{
"id": "mem_03",
"memory_type": "tool",
"summary": "Use repository-wide search before editing configuration files to avoid missing duplicated settings."
}
],
"categories": [
{
"id": "cat_01",
"name": "product_goals",
"summary": "Current launch priorities, onboarding decisions, and unresolved risks."
}
],
"relations": [
{ "item_id": "mem_01", "category_id": "cat_01" }
]
}
```
Then an agent can call `retrieve()` to get a scoped, ranked context payload:
```python
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What context matters for this launch task?"}}],
where={"user_id": "123"},
)
```
---
## ⭐️ Star the repository
<img width="100%" src="https://github.com/NevaMind-AI/memU/blob/main/assets/star.gif" />
If you find memU useful or interesting, a GitHub Star ⭐️ would be greatly appreciated.
---
## ✨ Core Features
| Capability | Description |
|------------|-------------|
| 🗂️ **Multimodal Ingestion** | Write conversations, documents, images, video, audio, URLs, logs, and local files into memory |
| 📁 **Memory File System** | Persist folders (categories), files (items), source artifacts, links, summaries, and embeddings |
| 🧠 **Typed Memory Extraction** | Extract profile, event, knowledge, behavior, skill, and tool memories from raw sources |
| 🧭 **Self-Organizing Folders** | Auto-build categories, links, summaries, and embeddings without manual tagging |
| 🤖 **Agent-Ready Retrieval** | Read scoped, ranked context that can be injected into any agent workflow |
| 🧱 **Pluggable Storage** | Use in-memory, SQLite, or Postgres backends with the same repository contracts |
| 🔀 **Profile-Based LLM Routing** | Route chat, embedding, vision, and transcription work through configurable LLM profiles |
---
## 🎯 Use Cases
### 1. **Conversation Memory**
*Turn chat logs into user preferences, goals, events, and relationship context.*
```python
await service.memorize(
resource_url="examples/resources/conversations/conv1.json",
modality="conversation",
user={"user_id": "123"},
)
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What should I remember about this user?"}}],
where={"user_id": "123"},
)
```
### 2. **Workspace Context for Coding Agents**
*Convert docs, PR notes, logs, and design decisions into reusable project memory.*
```python
await service.memorize(resource_url="docs/architecture.md", modality="document")
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "How should I structure this module?"}}],
)
```
### 3. **Multimodal Knowledge Layer**
*Extract searchable facts from documents, screenshots, images, videos, and audio notes.*
```python
await service.memorize(resource_url="examples/resources/docs/doc1.txt", modality="document")
await service.memorize(resource_url="examples/resources/images/image1.png", modality="image")
# Audio is supported for your own .mp3/.wav/.m4a files.
await service.memorize(resource_url="meeting-audio.mp3", modality="audio")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What matters for the next research plan?"}}],
)
```
### 4. **Tool and Agent Learning**
*Turn execution traces into tool memories that tell future agents when to use a tool and what mistakes to avoid.*
```python
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "Which tools worked for config editing?"}}],
)
```
---
## 🗂️ Architecture
The memory file system is hierarchical enough for browsing and structured enough for direct retrieval:
<img width="100%" alt="structure" src="../assets/structure.png" />
| Layer | Primary Role | Retrieval Role |
|-------|--------------|----------------|
| **Category (folder)** | Maintain topic-level summaries | Assemble compact context for broad queries |
| **Item (file)** | Store typed atomic memories | Load precise facts, events, preferences, skills, and tool patterns |
| **Resource (source)** | Preserve source artifacts and captions | Recall original context when item/category summaries are not enough |
See [docs/architecture.md](../docs/architecture.md) for the runtime view of `MemoryService`, workflow pipelines, storage backends, and LLM routing.
---
## 🚀 Quick Start
### Option 1: Cloud Version
👉 **[memu.so](https://memu.so)** — Hosted API for managed ingestion, structured memory, and retrieval
For enterprise deployment: **info@nevamind.ai**
#### Cloud API (v3)
| Base URL | `https://api.memu.so` |
|----------|----------------------|
| Auth | `Authorization: Bearer <token>` |
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v3/memory/memorize` | Ingest raw data and build structured memory |
| `GET` | `/api/v3/memory/memorize/status/{task_id}` | Check processing status |
| `POST` | `/api/v3/memory/categories` | List auto-generated categories |
| `POST` | `/api/v3/memory/retrieve` | Query memory for agent context |
📚 **[Full API Documentation](https://memu.pro/docs#cloud-version)**
---
### Option 2: Self-Hosted
#### Installation
From a clone of this repository:
```bash
uv sync
# or, for the full development setup:
make install
```
To install the published package instead:
```bash
pip install memu-py
```
> **Requirements**: Python 3.13+. The default examples use OpenAI, so set `OPENAI_API_KEY` or pass another provider through `llm_profiles`.
**Run an in-memory smoke script:**
```bash
export OPENAI_API_KEY=your_key
cd tests
uv run python test_inmemory.py
```
**Run with PostgreSQL + pgvector:**
```bash
uv sync --extra postgres
docker run -d --name memu-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=memu \
-p 5432:5432 \
pgvector/pgvector:pg16
export OPENAI_API_KEY=your_key
export POSTGRES_DSN=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/memu
cd tests
uv run python test_postgres.py
```
---
### Custom LLM and Embedding Providers
```python
from memu import MemUService
service = MemUService(
llm_profiles={
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "your_key",
"chat_model": "qwen3-max",
"client_backend": "sdk"
},
"embedding": {
"base_url": "https://api.voyageai.com/v1",
"api_key": "your_key",
"embed_model": "voyage-3.5-lite"
}
},
)
```
---
### OpenRouter Integration
```python
from memu import MemoryService
service = MemoryService(
llm_profiles={
"default": {
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
"api_key": "your_key",
"chat_model": "anthropic/claude-3.5-sonnet",
"embed_model": "openai/text-embedding-3-small",
},
},
database_config={"metadata_store": {"provider": "inmemory"}},
)
```
---
## 📖 Core APIs
### `memorize()` — Structure Raw Data
<img width="100%" alt="memorize" src="../assets/memorize.png" />
```python
result = await service.memorize(
resource_url="path/to/file.json", # local file path or HTTP URL
modality="conversation", # conversation | document | image | video | audio
user={"user_id": "123"}, # optional: scope to a user or agent
)
# Returns after processing completes:
# { "resource": {...}, "items": [...], "categories": [...], "relations": [...] }
```
- Converts raw input into typed memory items
- Categorizes and embeds items without manual tagging
- Preserves source resources and item-category relations
---
### `retrieve()` — Load Agent Context
<img width="100%" alt="retrieve" src="../assets/retrieve.png" />
```python
# The retrieval strategy is set once on the service via retrieve_config:
# MemoryService(retrieve_config={"method": "rag"}) # vector-first recall
# MemoryService(retrieve_config={"method": "llm"}) # LLM-ranked recall
result = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What are their preferences?"}}],
where={"user_id": "123"}, # scope filter
)
# Returns:
# {
# "needs_retrieval": true,
# "original_query": "...",
# "rewritten_query": "...",
# "next_step_query": "...",
# "categories": [...],
# "items": [...],
# "resources": [...]
# }
```
| `retrieve_config.method` | Behavior | Cost | Best For |
|--------------------------|----------|------|----------|
| `rag` | Vector-first category/item/resource recall, with optional LLM routing and sufficiency checks enabled by default | Embeddings plus LLM calls unless `route_intention` and `sufficiency_check` are disabled | Fast scoped recall with controllable reasoning |
| `llm` | LLM-ranked category/item/resource recall | LLM ranking at each tier | Deeper semantic ranking |
---
## 💡 Example Workflows
### Always-Learning Assistant
```bash
export OPENAI_API_KEY=your_key
uv run python examples/example_1_conversation_memory.py
```
Automatically extracts preferences, builds relationship models, and surfaces relevant context in future conversations.
### Self-Improving Agent
```bash
uv run python examples/example_2_skill_extraction.py
```
Monitors agent actions, identifies patterns in successes and failures, auto-generates skill guides from experience.
### Multimodal Context Builder
```bash
uv run python examples/example_3_multimodal_memory.py
```
Cross-references text, images, and documents automatically into a unified memory layer.
---
## 📊 Performance
memU achieves **92.09% average accuracy** on the Locomo benchmark across all reasoning tasks.
<img width="100%" alt="benchmark" src="https://github.com/user-attachments/assets/6fec4884-94e5-4058-ad5c-baac3d7e76d9" />
View detailed results: [memU-experiment](https://github.com/NevaMind-AI/memU-experiment)
---
## 🧩 Ecosystem
| Repository | Description |
|------------|-------------|
| **[memU](https://github.com/NevaMind-AI/memU)** | Core memory file system — ingestion, extraction, retrieval |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | Backend with real-time sync and webhook triggers |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | Visual dashboard for browsing and monitoring memory |
**Quick Links:**
- 🚀 [Try MemU Cloud](https://app.memu.so/quick-start)
- 📚 [API Documentation](https://memu.pro/docs)
- 💬 [Discord Community](https://discord.com/invite/hQZntfGsbJ)
---
## 🤝 Partners
<div align="center">
<a href="https://github.com/TEN-framework/ten-framework"><img src="https://avatars.githubusercontent.com/u/113095513?s=200&v=4" alt="Ten" height="40" style="margin: 10px;"></a>
<a href="https://openagents.org"><img src="../assets/partners/openagents.png" alt="OpenAgents" height="40" style="margin: 10px;"></a>
<a href="https://github.com/milvus-io/milvus"><img src="https://miro.medium.com/v2/resize:fit:2400/1*-VEGyAgcIBD62XtZWavy8w.png" alt="Milvus" height="40" style="margin: 10px;"></a>
<a href="https://xroute.ai/"><img src="../assets/partners/xroute.png" alt="xRoute" height="40" style="margin: 10px;"></a>
<a href="https://jaaz.app/"><img src="../assets/partners/jazz.png" alt="Jazz" height="40" style="margin: 10px;"></a>
<a href="https://github.com/Buddie-AI/Buddie"><img src="../assets/partners/buddie.png" alt="Buddie" height="40" style="margin: 10px;"></a>
<a href="https://github.com/bytebase/bytebase"><img src="../assets/partners/bytebase.png" alt="Bytebase" height="40" style="margin: 10px;"></a>
<a href="https://github.com/LazyAGI/LazyLLM"><img src="../assets/partners/LazyLLM.png" alt="LazyLLM" height="40" style="margin: 10px;"></a>
<a href="https://clawdchat.ai/"><img src="../assets/partners/Clawdchat.png" alt="Clawdchat" height="40" style="margin: 10px;"></a>
</div>
---
## 🤝 Contributing
```bash
# Fork and clone
git clone https://github.com/YOUR_USERNAME/memU.git
cd memU
# Install dev dependencies
make install
# Run quality checks before submitting
make check
```
See [CONTRIBUTING.md](../CONTRIBUTING.md) for full guidelines.
**Prerequisites:** Python 3.13+, [uv](https://github.com/astral-sh/uv), Git
---
## 📄 License
[Apache License 2.0](../LICENSE.txt)
---
## 🌍 Community
- **GitHub Issues**: [Report bugs & request features](https://github.com/NevaMind-AI/memU/issues)
- **Discord**: [Join the community](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [Follow @memU_ai](https://x.com/memU_ai)
- **Contact**: info@nevamind.ai
---
<div align="center">
**Star us on GitHub** to get notified about new releases!
</div>
-542
View File
@@ -1,542 +0,0 @@
![MemU Banner](../assets/banner.png)
<div align="center">
# memU
### Memoria personal en archivos
**Recuperación rápida · Mayor precisión · Menor costo**
[![PyPI version](https://badge.fury.io/py/memu-py.svg)](https://badge.fury.io/py/memu-py)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
[![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/hQZntfGsbJ)
[![Twitter](https://img.shields.io/badge/Twitter-Follow-1DA1F2?logo=x&logoColor=white)](https://x.com/memU_ai)
<a href="https://trendshift.io/repositories/17374" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17374" alt="NevaMind-AI%2FmemU | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**[English](README_en.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [Español](README_es.md) | [Français](README_fr.md)**
</div>
---
> [!WARNING]
> 🚧 **En plena reconstrucción** — memU está en pleno rediseño; las APIs, los comandos de la CLI y la documentación pueden cambiar sin previo aviso. Se espera que se estabilice alrededor del **15 de julio de 2026**.
memU compila conversaciones, documentos, código, imágenes, audio, video, URLs y trazas de herramientas en archivos Markdown legibles por humanos (`INDEX.md`, `MEMORY.md`, `SKILL.md`). El agente recorre el árbol y carga solo la memoria que necesita en el momento — sin escanear todo ni meter historiales largos en cada prompt:
- **`INDEX.md`** — mapa de todo: categorías, archivos y resúmenes, para que el agente sepa dónde mirar primero
- **`MEMORY.md`** — perfil, preferencias, objetivos, hechos y eventos clave extraídos de los datos fuente
- **`SKILL.md`** — patrones de herramientas y flujos de trabajo reutilizables, extraídos automáticamente de trazas y refinados en cada `memorize()`
```txt
workspace/
├── INDEX.md ← mapa de todo: categorías, archivos y resúmenes
├── MEMORY.md ← perfil, preferencias, objetivos y eventos clave
└── skill/
├── {skill_name}/
│ └── SKILL.md ← una habilidad o patrón de herramienta aprendido
└── {another_skill}/
└── SKILL.md
```
Tres cosas lo diferencian de meter todo en el prompt:
- **Recuperación rápida** — ir a la carpeta relevante y ordenar archivos en lugar de escanear todo cada vez.
- **Mayor precisión** — acotar por usuario, tarea o sesión, y rastrear cada elemento hasta su fuente original.
- **Menor costo** — recuperar contexto compacto y acotado en lugar de reinyectar historiales largos en cada prompt.
- **Auditable** — un árbol de archivos legible por humanos que puedes revisar, editar y enrutar con tu propio almacenamiento y proveedores de LLM.
---
## 🔄 Cómo funciona
Piénsalo como dos operaciones de sistema de archivos: **escribir** fuentes en bruto en una memoria organizada y **leer** los archivos correctos de vuelta hacia el agente.
```
WRITE — memorize() READ — retrieve()
────────────────────────────────────────────── ──────────────────────────────────────────────
raw files → extract → files + folders query → walk folders → ranked files
───────────── ───────── ────────────── ───── ──────────── ─────────────
chat logs → parse → profile / event items user / task query
documents / URLs → facts → knowledge / skill items │
images / video → caption → resources + summaries ├─ route + scope → relevant folders (categories)
audio → transcribe→ event / knowledge items ├─ rank by relevance → matching files (items)
tool logs → mine → tool / skill items └─ trace to source → original resources
```
**Escribir en el sistema de archivos (`memorize`)**
1. **Ingerir (Ingest)**: almacena cada fuente como un `Resource` (el archivo en bruto) con su modalidad y ubicación de origen
2. **Preprocesar (Preprocess)**: analiza texto, genera descripciones de imágenes/video, transcribe audio y normaliza las entradas
3. **Extraer (Extract)**: convierte el contenido en bruto en registros `RecallEntry` tipados (los archivos): memorias de tipo profile, event, knowledge, behavior, skill o tool
4. **Organizar (Organize)**: clasifica los elementos en carpetas `RecallFile` (una categoría de memoria, o una habilidad), los enlaza entre sí, los vectoriza y los resume en un árbol navegable
5. **Persistir (Persist)**: escribe registros, relaciones, embeddings y resúmenes de carpeta a través del backend configurado
**Leer del sistema de archivos (`retrieve`)**
6. **Recuperar (Retrieve)**: navega por las carpetas y devuelve solo los archivos relevantes para el usuario, agente, sesión o tarea actuales
---
## 🗂️ El sistema de archivos de memoria
La salida principal de memU es un árbol de memoria navegable —carpetas, archivos y los artefactos de origen detrás de ellos— persistido mediante contratos de repositorio y devuelto como diccionarios desde `memorize()` y `retrieve()`.
```txt
RecallFile ← carpeta: un tema con un resumen en evolución
├── name, track (memory | skill), description, content
├── embedding
└── RecallEntry[] ← archivos: memorias atómicas y tipadas
├── memory_type: profile | event | knowledge | behavior | skill | tool
├── summary, extra, happened_at, embedding
└── Resource ← fuente: el archivo en bruto del que proviene esta memoria
└── url, modality, local_path, caption, embedding
```
| Registro | Rol en el sistema de archivos | Usado para |
|--------|------------------|---------|
| `RecallFile` | **Carpeta**: agrupa memorias relacionadas (o contiene una habilidad) y mantiene un resumen a nivel de tema; un campo `track` lo marca como `memory` o `skill` | Cargar contexto compacto para consultas amplias |
| `RecallEntry` | **Archivo**: memoria atómica tipada con un resumen y metadatos opcionales | Inyectar hechos, preferencias, eventos, habilidades y patrones de herramientas precisos |
| `Resource` | **Artefacto de origen**: el archivo original detrás de una memoria, con descripción/texto | Rastrear el contexto hasta su origen |
| `RecallFileEntry` | **Enlace**: la arista que archiva un elemento bajo una carpeta | Navegar memorias relacionadas sin reprocesar la fuente |
Esto da a los agentes un sistema de archivos de memoria estable: ingieren las fuentes en bruto una sola vez y luego solicitan archivos delimitados y ordenados, en lugar de releer cada artefacto de origen.
---
## 🧩 Qué construye memU
Cada capa del sistema de archivos se almacena como un registro estructurado:
| Capa | Qué representa | Por qué la usan los agentes |
|-------|--------------------|-------------------|
| **RecallFile** | Carpeta autogenerada: un tema (o una habilidad) con un resumen en evolución | Cargar contexto de alto nivel antes de profundizar en detalles |
| **RecallEntry** | Un archivo: memoria estructurada atómica con un tipo y un resumen | Inyectar hechos, preferencias, eventos, habilidades y patrones de herramientas precisos |
| **Resource** | Artefacto de origen detrás de un archivo: conversación, documento, imagen, video, audio, URL o archivo | Rastrear la memoria hasta su origen |
| **RecallFileEntry** | El enlace que archiva un elemento bajo una carpeta | Navegar memorias relacionadas sin reprocesar la fuente |
| **Embedding** | Índice vectorial sobre carpetas, archivos y fuentes | Recuperar contexto relevante con baja latencia |
Ejemplo de salida de `memorize()`:
```json
{
"resource": {
"id": "res_01",
"url": "files/launch-meeting.mp4",
"modality": "video",
"caption": "A product planning discussion about onboarding and launch risks."
},
"items": [
{
"id": "mem_01",
"memory_type": "event",
"summary": "The team decided to simplify onboarding before the next launch review."
},
{
"id": "mem_02",
"memory_type": "profile",
"summary": "The user prefers concise implementation plans with explicit verification steps."
},
{
"id": "mem_03",
"memory_type": "tool",
"summary": "Use repository-wide search before editing configuration files to avoid missing duplicated settings."
}
],
"categories": [
{
"id": "cat_01",
"name": "product_goals",
"summary": "Current launch priorities, onboarding decisions, and unresolved risks."
}
],
"relations": [
{ "item_id": "mem_01", "category_id": "cat_01" }
]
}
```
Luego un agente puede llamar a `retrieve()` para obtener una carga de contexto delimitada y ordenada por relevancia:
```python
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What context matters for this launch task?"}}],
where={"user_id": "123"},
)
```
---
## ⭐️ Dale una estrella al repositorio
<img width="100%" src="https://github.com/NevaMind-AI/memU/blob/main/assets/star.gif" />
Si encuentras memU útil o interesante, una estrella ⭐️ en GitHub sería muy apreciada.
---
## ✨ Funciones principales
| Capacidad | Descripción |
|------------|-------------|
| 🗂️ **Ingesta multimodal** | Escribe conversaciones, documentos, imágenes, video, audio, URLs, registros y archivos locales en la memoria |
| 📁 **Sistema de archivos de memoria** | Persiste carpetas (categorías), archivos (elementos), artefactos de origen, enlaces, resúmenes y embeddings |
| 🧠 **Extracción de memoria tipada** | Extrae memorias profile, event, knowledge, behavior, skill y tool a partir de fuentes en bruto |
| 🧭 **Carpetas autoorganizadas** | Construye automáticamente categorías, enlaces, resúmenes y embeddings sin etiquetado manual |
| 🤖 **Recuperación lista para agentes** | Lee contexto delimitado y ordenado que puede inyectarse en cualquier flujo de trabajo de agente |
| 🧱 **Almacenamiento conectable** | Usa backends in-memory, SQLite o Postgres con los mismos contratos de repositorio |
| 🔀 **Enrutamiento de LLM basado en perfiles** | Enruta tareas de chat, embeddings, visión y transcripción a través de perfiles de LLM configurables |
---
## 🎯 Casos de uso
### 1. **Memoria de conversación**
*Convierte registros de chat en preferencias, objetivos, eventos y contexto de relación del usuario.*
```python
await service.memorize(
resource_url="examples/resources/conversations/conv1.json",
modality="conversation",
user={"user_id": "123"},
)
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What should I remember about this user?"}}],
where={"user_id": "123"},
)
```
### 2. **Contexto de espacio de trabajo para agentes de programación**
*Convierte documentos, notas de PR, registros y decisiones de diseño en memoria de proyecto reutilizable.*
```python
await service.memorize(resource_url="docs/architecture.md", modality="document")
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "How should I structure this module?"}}],
)
```
### 3. **Capa de conocimiento multimodal**
*Extrae hechos buscables de documentos, capturas de pantalla, imágenes, videos y notas de audio.*
```python
await service.memorize(resource_url="examples/resources/docs/doc1.txt", modality="document")
await service.memorize(resource_url="examples/resources/images/image1.png", modality="image")
# Audio is supported for your own .mp3/.wav/.m4a files.
await service.memorize(resource_url="meeting-audio.mp3", modality="audio")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What matters for the next research plan?"}}],
)
```
### 4. **Aprendizaje de herramientas y agentes**
*Convierte las trazas de ejecución en memorias de herramientas que indican a los agentes futuros cuándo usar una herramienta y qué errores evitar.*
```python
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "Which tools worked for config editing?"}}],
)
```
---
## 🗂️ Arquitectura
El sistema de archivos de memoria es lo bastante jerárquico para navegarlo y lo bastante estructurado para una recuperación directa:
<img width="100%" alt="structure" src="../assets/structure.png" />
| Capa | Rol principal | Rol en la recuperación |
|-------|--------------|----------------|
| **Category (carpeta)** | Mantener resúmenes a nivel de tema | Ensamblar contexto compacto para consultas amplias |
| **Item (archivo)** | Almacenar memorias atómicas tipadas | Cargar hechos, eventos, preferencias, habilidades y patrones de herramientas precisos |
| **Resource (fuente)** | Preservar artefactos de origen y descripciones | Recuperar el contexto original cuando los resúmenes de elemento/categoría no bastan |
Consulta [docs/architecture.md](../docs/architecture.md) para la vista en tiempo de ejecución de `MemoryService`, los pipelines de flujo de trabajo, los backends de almacenamiento y el enrutamiento de LLM.
---
## 🚀 Inicio rápido
### Opción 1: Versión en la nube
👉 **[memu.so](https://memu.so)**: API alojada para ingesta gestionada, memoria estructurada y recuperación
Para despliegue empresarial: **info@nevamind.ai**
#### Cloud API (v3)
| Base URL | `https://api.memu.so` |
|----------|----------------------|
| Auth | `Authorization: Bearer <token>` |
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v3/memory/memorize` | Ingiere datos en bruto y construye memoria estructurada |
| `GET` | `/api/v3/memory/memorize/status/{task_id}` | Consulta el estado del procesamiento |
| `POST` | `/api/v3/memory/categories` | Lista las categorías autogeneradas |
| `POST` | `/api/v3/memory/retrieve` | Consulta la memoria para obtener contexto del agente |
📚 **[Documentación completa de la API](https://memu.pro/docs#cloud-version)**
---
### Opción 2: Autoalojado
#### Instalación
Desde un clon de este repositorio:
```bash
uv sync
# o, para la configuración completa de desarrollo:
make install
```
Para instalar el paquete publicado en su lugar:
```bash
pip install memu-py
```
> **Requisitos**: Python 3.13+. Los ejemplos por defecto usan OpenAI, así que define `OPENAI_API_KEY` o pasa otro proveedor mediante `llm_profiles`.
**Ejecuta un script de prueba en memoria:**
```bash
export OPENAI_API_KEY=your_key
cd tests
uv run python test_inmemory.py
```
**Ejecuta con PostgreSQL + pgvector:**
```bash
uv sync --extra postgres
docker run -d --name memu-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=memu \
-p 5432:5432 \
pgvector/pgvector:pg16
export OPENAI_API_KEY=your_key
export POSTGRES_DSN=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/memu
cd tests
uv run python test_postgres.py
```
---
### Proveedores personalizados de LLM y embeddings
```python
from memu import MemUService
service = MemUService(
llm_profiles={
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "your_key",
"chat_model": "qwen3-max",
"client_backend": "sdk"
},
"embedding": {
"base_url": "https://api.voyageai.com/v1",
"api_key": "your_key",
"embed_model": "voyage-3.5-lite"
}
},
)
```
---
### Integración con OpenRouter
```python
from memu import MemoryService
service = MemoryService(
llm_profiles={
"default": {
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
"api_key": "your_key",
"chat_model": "anthropic/claude-3.5-sonnet",
"embed_model": "openai/text-embedding-3-small",
},
},
database_config={"metadata_store": {"provider": "inmemory"}},
)
```
---
## 📖 APIs principales
### `memorize()`: estructura datos en bruto
<img width="100%" alt="memorize" src="../assets/memorize.png" />
```python
result = await service.memorize(
resource_url="path/to/file.json", # ruta de archivo local o URL HTTP
modality="conversation", # conversation | document | image | video | audio
user={"user_id": "123"}, # opcional: delimitar a un usuario o agente
)
# Devuelve tras completar el procesamiento:
# { "resource": {...}, "items": [...], "categories": [...], "relations": [...] }
```
- Convierte la entrada en bruto en elementos de memoria tipados
- Categoriza y vectoriza los elementos sin etiquetado manual
- Preserva los recursos de origen y las relaciones elemento-categoría
---
### `retrieve()`: carga contexto del agente
<img width="100%" alt="retrieve" src="../assets/retrieve.png" />
```python
# La estrategia de recuperación se define una vez en el servicio mediante retrieve_config:
# MemoryService(retrieve_config={"method": "rag"}) # recuperación con vectores primero
# MemoryService(retrieve_config={"method": "llm"}) # recuperación ordenada por LLM
result = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What are their preferences?"}}],
where={"user_id": "123"}, # filtro de alcance
)
# Devuelve:
# {
# "needs_retrieval": true,
# "original_query": "...",
# "rewritten_query": "...",
# "next_step_query": "...",
# "categories": [...],
# "items": [...],
# "resources": [...]
# }
```
| `retrieve_config.method` | Comportamiento | Coste | Mejor para |
|--------------------------|----------|------|----------|
| `rag` | Recuperación de categorías/elementos/recursos con vectores primero, con enrutamiento LLM y comprobaciones de suficiencia opcionales activadas por defecto | Embeddings más llamadas a LLM, salvo que se desactiven `route_intention` y `sufficiency_check` | Recuperación delimitada y rápida con razonamiento controlable |
| `llm` | Recuperación de categorías/elementos/recursos ordenada por LLM | Ordenación por LLM en cada nivel | Ordenación semántica más profunda |
---
## 💡 Flujos de trabajo de ejemplo
### Asistente que aprende siempre
```bash
export OPENAI_API_KEY=your_key
uv run python examples/example_1_conversation_memory.py
```
Extrae preferencias automáticamente, construye modelos de relación y hace aflorar contexto relevante en conversaciones futuras.
### Agente que se mejora a sí mismo
```bash
uv run python examples/example_2_skill_extraction.py
```
Supervisa las acciones del agente, identifica patrones en éxitos y fracasos, y autogenera guías de habilidades a partir de la experiencia.
### Constructor de contexto multimodal
```bash
uv run python examples/example_3_multimodal_memory.py
```
Cruza automáticamente texto, imágenes y documentos en una capa de memoria unificada.
---
## 📊 Rendimiento
memU alcanza un **92,09 % de precisión promedio** en el benchmark Locomo en todas las tareas de razonamiento.
<img width="100%" alt="benchmark" src="https://github.com/user-attachments/assets/6fec4884-94e5-4058-ad5c-baac3d7e76d9" />
Ver resultados detallados: [memU-experiment](https://github.com/NevaMind-AI/memU-experiment)
---
## 🧩 Ecosistema
| Repositorio | Descripción |
|------------|-------------|
| **[memU](https://github.com/NevaMind-AI/memU)** | Sistema de archivos de memoria central: ingesta, extracción, recuperación |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | Backend con sincronización en tiempo real y disparadores webhook |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | Panel visual para explorar y monitorizar la memoria |
**Enlaces rápidos:**
- 🚀 [Prueba MemU Cloud](https://app.memu.so/quick-start)
- 📚 [Documentación de la API](https://memu.pro/docs)
- 💬 [Comunidad de Discord](https://discord.com/invite/hQZntfGsbJ)
---
## 🤝 Socios
<div align="center">
<a href="https://github.com/TEN-framework/ten-framework"><img src="https://avatars.githubusercontent.com/u/113095513?s=200&v=4" alt="Ten" height="40" style="margin: 10px;"></a>
<a href="https://openagents.org"><img src="../assets/partners/openagents.png" alt="OpenAgents" height="40" style="margin: 10px;"></a>
<a href="https://github.com/milvus-io/milvus"><img src="https://miro.medium.com/v2/resize:fit:2400/1*-VEGyAgcIBD62XtZWavy8w.png" alt="Milvus" height="40" style="margin: 10px;"></a>
<a href="https://xroute.ai/"><img src="../assets/partners/xroute.png" alt="xRoute" height="40" style="margin: 10px;"></a>
<a href="https://jaaz.app/"><img src="../assets/partners/jazz.png" alt="Jazz" height="40" style="margin: 10px;"></a>
<a href="https://github.com/Buddie-AI/Buddie"><img src="../assets/partners/buddie.png" alt="Buddie" height="40" style="margin: 10px;"></a>
<a href="https://github.com/bytebase/bytebase"><img src="../assets/partners/bytebase.png" alt="Bytebase" height="40" style="margin: 10px;"></a>
<a href="https://github.com/LazyAGI/LazyLLM"><img src="../assets/partners/LazyLLM.png" alt="LazyLLM" height="40" style="margin: 10px;"></a>
<a href="https://clawdchat.ai/"><img src="../assets/partners/Clawdchat.png" alt="Clawdchat" height="40" style="margin: 10px;"></a>
</div>
---
## 🤝 Contribuir
```bash
# Haz fork y clona
git clone https://github.com/YOUR_USERNAME/memU.git
cd memU
# Instala las dependencias de desarrollo
make install
# Ejecuta las comprobaciones de calidad antes de enviar
make check
```
Consulta [CONTRIBUTING.md](../CONTRIBUTING.md) para las pautas completas.
**Requisitos previos:** Python 3.13+, [uv](https://github.com/astral-sh/uv), Git
---
## 📄 Licencia
[Apache License 2.0](../LICENSE.txt)
---
## 🌍 Comunidad
- **GitHub Issues**: [Reporta errores y solicita funciones](https://github.com/NevaMind-AI/memU/issues)
- **Discord**: [Únete a la comunidad](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [Sigue a @memU_ai](https://x.com/memU_ai)
- **Contacto**: info@nevamind.ai
---
<div align="center">
**Danos una estrella en GitHub** para recibir notificaciones sobre nuevas versiones.
</div>
-542
View File
@@ -1,542 +0,0 @@
![MemU Banner](../assets/banner.png)
<div align="center">
# memU
### Mémoire personnelle en fichiers
**Récupération rapide · Plus grande précision · Coût réduit**
[![PyPI version](https://badge.fury.io/py/memu-py.svg)](https://badge.fury.io/py/memu-py)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
[![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/hQZntfGsbJ)
[![Twitter](https://img.shields.io/badge/Twitter-Follow-1DA1F2?logo=x&logoColor=white)](https://x.com/memU_ai)
<a href="https://trendshift.io/repositories/17374" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17374" alt="NevaMind-AI%2FmemU | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**[English](README_en.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [Español](README_es.md) | [Français](README_fr.md)**
</div>
---
> [!WARNING]
> 🚧 **En pleine refonte** — memU fait l'objet d'une refonte majeure ; les APIs, les commandes CLI et la documentation peuvent changer sans préavis. Stabilisation prévue autour du **15 juillet 2026**.
memU compile conversations, documents, code, images, audio, vidéo, URLs et traces d'outils en fichiers Markdown lisibles par l'humain (`INDEX.md`, `MEMORY.md`, `SKILL.md`). L'agent parcourt l'arborescence et ne charge que la mémoire utile au moment présent — sans tout scanner ni remplir chaque prompt d'historiques longs :
- **`INDEX.md`** — carte de tout : catégories, fichiers et résumés, pour que l'agent sache où chercher en premier
- **`MEMORY.md`** — profil, préférences, objectifs, faits et événements clés extraits des données sources
- **`SKILL.md`** — schémas d'outils et workflows réutilisables, extraits automatiquement des traces et affinés à chaque `memorize()`
```txt
workspace/
├── INDEX.md ← carte de tout : catégories, fichiers et résumés
├── MEMORY.md ← profil, préférences, objectifs et événements clés
└── skill/
├── {skill_name}/
│ └── SKILL.md ← une compétence ou un schéma d'outil appris
└── {another_skill}/
└── SKILL.md
```
Trois éléments le distinguent de tout mettre dans le prompt :
- **Récupération rapide** — aller au dossier pertinent et classer les fichiers au lieu de tout scanner à chaque fois.
- **Plus grande précision** — cadrer par utilisateur, tâche ou session, et remonter chaque élément jusqu'à sa source exacte.
- **Coût réduit** — récupérer un contexte compact et cadré au lieu de réinjecter de longs historiques dans chaque prompt.
- **Auditable** — une arborescence lisible par l'humain que vous pouvez inspecter, modifier et router via votre propre stockage et vos fournisseurs LLM.
---
## 🔄 Comment ça marche
Voyez cela comme deux opérations de système de fichiers : **écrire** des sources brutes dans une mémoire organisée, et **relire** les bons fichiers vers l'agent.
```
WRITE — memorize() READ — retrieve()
────────────────────────────────────────────── ──────────────────────────────────────────────
raw files → extract → files + folders query → walk folders → ranked files
───────────── ───────── ────────────── ───── ──────────── ─────────────
chat logs → parse → profile / event items user / task query
documents / URLs → facts → knowledge / skill items │
images / video → caption → resources + summaries ├─ route + scope → relevant folders (categories)
audio → transcribe→ event / knowledge items ├─ rank by relevance → matching files (items)
tool logs → mine → tool / skill items └─ trace to source → original resources
```
**Écrire dans le système de fichiers (`memorize`)**
1. **Ingérer (Ingest)** : stocke chaque source sous forme de `Resource` (le fichier brut) avec sa modalité et son emplacement d'origine
2. **Prétraiter (Preprocess)** : analyse le texte, légende les images/vidéos, transcrit l'audio et normalise les entrées
3. **Extraire (Extract)** : transforme le contenu brut en enregistrements `RecallEntry` typés (les fichiers) : mémoires de type profile, event, knowledge, behavior, skill ou tool
4. **Organiser (Organize)** : range les éléments dans des dossiers `RecallFile` (une catégorie de mémoire, ou une compétence), les relie entre eux, les vectorise et les résume en une arborescence navigable
5. **Persister (Persist)** : écrit les enregistrements, relations, embeddings et résumés de dossier via le backend configuré
**Lire depuis le système de fichiers (`retrieve`)**
6. **Récupérer (Retrieve)** : parcourt les dossiers et ne renvoie que les fichiers pertinents pour l'utilisateur, l'agent, la session ou la tâche en cours
---
## 🗂️ Le système de fichiers de mémoire
La sortie principale de memU est une arborescence de mémoire navigable — dossiers, fichiers et les artefacts source qui se trouvent derrière — persistée via des contrats de dépôt et renvoyée sous forme de dictionnaires par `memorize()` et `retrieve()`.
```txt
RecallFile ← dossier : un thème avec un résumé en évolution
├── name, track (memory | skill), description, content
├── embedding
└── RecallEntry[] ← fichiers : des mémoires atomiques et typées
├── memory_type: profile | event | knowledge | behavior | skill | tool
├── summary, extra, happened_at, embedding
└── Resource ← source : le fichier brut dont provient cette mémoire
└── url, modality, local_path, caption, embedding
```
| Enregistrement | Rôle dans le système de fichiers | Utilisé pour |
|--------|------------------|---------|
| `RecallFile` | **Dossier** : regroupe les mémoires liées (ou contient une compétence) et tient un résumé au niveau du thème ; un champ `track` le marque `memory` ou `skill` | Charger un contexte compact pour les requêtes larges |
| `RecallEntry` | **Fichier** : mémoire atomique typée avec un résumé et des métadonnées optionnelles | Injecter des faits, préférences, événements, compétences et schémas d'outils précis |
| `Resource` | **Artefact source** : le fichier d'origine derrière une mémoire, avec légende/texte | Remonter le contexte jusqu'à son origine |
| `RecallFileEntry` | **Lien** : l'arête qui classe un élément sous un dossier | Naviguer entre les mémoires liées sans retraiter la source |
Cela donne aux agents un système de fichiers de mémoire stable : ils ingèrent les sources brutes une seule fois, puis demandent des fichiers délimités et classés plutôt que de relire chaque artefact source.
---
## 🧩 Ce que memU construit
Chaque couche du système de fichiers est stockée sous forme d'enregistrement structuré :
| Couche | Ce qu'elle représente | Pourquoi les agents l'utilisent |
|-------|--------------------|-------------------|
| **RecallFile** | Dossier auto-généré : un thème (ou une compétence) avec un résumé en évolution | Charger un contexte de haut niveau avant de plonger dans les détails |
| **RecallEntry** | Un fichier : mémoire structurée atomique avec un type et un résumé | Injecter des faits, préférences, événements, compétences et schémas d'outils précis |
| **Resource** | Artefact source derrière un fichier : conversation, document, image, vidéo, audio, URL ou fichier | Remonter la mémoire jusqu'à son origine |
| **RecallFileEntry** | Le lien qui classe un élément sous un dossier | Naviguer entre les mémoires liées sans retraiter la source |
| **Embedding** | Index vectoriel couvrant dossiers, fichiers et sources | Récupérer le contexte pertinent avec une faible latence |
Exemple de sortie de `memorize()` :
```json
{
"resource": {
"id": "res_01",
"url": "files/launch-meeting.mp4",
"modality": "video",
"caption": "A product planning discussion about onboarding and launch risks."
},
"items": [
{
"id": "mem_01",
"memory_type": "event",
"summary": "The team decided to simplify onboarding before the next launch review."
},
{
"id": "mem_02",
"memory_type": "profile",
"summary": "The user prefers concise implementation plans with explicit verification steps."
},
{
"id": "mem_03",
"memory_type": "tool",
"summary": "Use repository-wide search before editing configuration files to avoid missing duplicated settings."
}
],
"categories": [
{
"id": "cat_01",
"name": "product_goals",
"summary": "Current launch priorities, onboarding decisions, and unresolved risks."
}
],
"relations": [
{ "item_id": "mem_01", "category_id": "cat_01" }
]
}
```
Un agent peut ensuite appeler `retrieve()` pour obtenir une charge de contexte délimitée et classée par pertinence :
```python
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What context matters for this launch task?"}}],
where={"user_id": "123"},
)
```
---
## ⭐️ Mettez une étoile au dépôt
<img width="100%" src="https://github.com/NevaMind-AI/memU/blob/main/assets/star.gif" />
Si vous trouvez memU utile ou intéressant, une étoile ⭐️ sur GitHub serait grandement appréciée.
---
## ✨ Fonctionnalités principales
| Capacité | Description |
|------------|-------------|
| 🗂️ **Ingestion multimodale** | Écrit conversations, documents, images, vidéos, audio, URL, journaux et fichiers locaux dans la mémoire |
| 📁 **Système de fichiers de mémoire** | Persiste dossiers (catégories), fichiers (éléments), artefacts source, liens, résumés et embeddings |
| 🧠 **Extraction de mémoire typée** | Extrait des mémoires profile, event, knowledge, behavior, skill et tool à partir de sources brutes |
| 🧭 **Dossiers auto-organisés** | Construit automatiquement catégories, liens, résumés et embeddings sans étiquetage manuel |
| 🤖 **Récupération prête pour les agents** | Lit un contexte délimité et classé pouvant être injecté dans n'importe quel workflow d'agent |
| 🧱 **Stockage enfichable** | Utilise des backends in-memory, SQLite ou Postgres avec les mêmes contrats de dépôt |
| 🔀 **Routage de LLM basé sur les profils** | Achemine les tâches de chat, embeddings, vision et transcription via des profils de LLM configurables |
---
## 🎯 Cas d'usage
### 1. **Mémoire de conversation**
*Transforme les journaux de chat en préférences, objectifs, événements et contexte relationnel de l'utilisateur.*
```python
await service.memorize(
resource_url="examples/resources/conversations/conv1.json",
modality="conversation",
user={"user_id": "123"},
)
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What should I remember about this user?"}}],
where={"user_id": "123"},
)
```
### 2. **Contexte d'espace de travail pour les agents de codage**
*Convertit documents, notes de PR, journaux et décisions de conception en mémoire de projet réutilisable.*
```python
await service.memorize(resource_url="docs/architecture.md", modality="document")
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "How should I structure this module?"}}],
)
```
### 3. **Couche de connaissances multimodale**
*Extrait des faits interrogeables à partir de documents, captures d'écran, images, vidéos et notes audio.*
```python
await service.memorize(resource_url="examples/resources/docs/doc1.txt", modality="document")
await service.memorize(resource_url="examples/resources/images/image1.png", modality="image")
# Audio is supported for your own .mp3/.wav/.m4a files.
await service.memorize(resource_url="meeting-audio.mp3", modality="audio")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What matters for the next research plan?"}}],
)
```
### 4. **Apprentissage des outils et des agents**
*Transforme les traces d'exécution en mémoires d'outils qui indiquent aux agents futurs quand utiliser un outil et quelles erreurs éviter.*
```python
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "Which tools worked for config editing?"}}],
)
```
---
## 🗂️ Architecture
Le système de fichiers de mémoire est assez hiérarchique pour être parcouru et assez structuré pour une récupération directe :
<img width="100%" alt="structure" src="../assets/structure.png" />
| Couche | Rôle principal | Rôle dans la récupération |
|-------|--------------|----------------|
| **Category (dossier)** | Maintenir des résumés au niveau du thème | Assembler un contexte compact pour les requêtes larges |
| **Item (fichier)** | Stocker des mémoires atomiques typées | Charger des faits, événements, préférences, compétences et schémas d'outils précis |
| **Resource (source)** | Préserver les artefacts source et les légendes | Rappeler le contexte d'origine lorsque les résumés d'élément/catégorie ne suffisent pas |
Consultez [docs/architecture.md](../docs/architecture.md) pour la vue à l'exécution de `MemoryService`, des pipelines de workflow, des backends de stockage et du routage de LLM.
---
## 🚀 Démarrage rapide
### Option 1 : Version cloud
👉 **[memu.so](https://memu.so)** : API hébergée pour l'ingestion gérée, la mémoire structurée et la récupération
Pour un déploiement en entreprise : **info@nevamind.ai**
#### Cloud API (v3)
| Base URL | `https://api.memu.so` |
|----------|----------------------|
| Auth | `Authorization: Bearer <token>` |
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v3/memory/memorize` | Ingère des données brutes et construit une mémoire structurée |
| `GET` | `/api/v3/memory/memorize/status/{task_id}` | Vérifie l'état du traitement |
| `POST` | `/api/v3/memory/categories` | Liste les catégories auto-générées |
| `POST` | `/api/v3/memory/retrieve` | Interroge la mémoire pour obtenir le contexte de l'agent |
📚 **[Documentation complète de l'API](https://memu.pro/docs#cloud-version)**
---
### Option 2 : Auto-hébergé
#### Installation
À partir d'un clone de ce dépôt :
```bash
uv sync
# ou, pour la configuration de développement complète :
make install
```
Pour installer le paquet publié à la place :
```bash
pip install memu-py
```
> **Prérequis** : Python 3.13+. Les exemples par défaut utilisent OpenAI ; définissez donc `OPENAI_API_KEY` ou passez un autre fournisseur via `llm_profiles`.
**Exécuter un script de test en mémoire :**
```bash
export OPENAI_API_KEY=your_key
cd tests
uv run python test_inmemory.py
```
**Exécuter avec PostgreSQL + pgvector :**
```bash
uv sync --extra postgres
docker run -d --name memu-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=memu \
-p 5432:5432 \
pgvector/pgvector:pg16
export OPENAI_API_KEY=your_key
export POSTGRES_DSN=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/memu
cd tests
uv run python test_postgres.py
```
---
### Fournisseurs de LLM et d'embeddings personnalisés
```python
from memu import MemUService
service = MemUService(
llm_profiles={
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "your_key",
"chat_model": "qwen3-max",
"client_backend": "sdk"
},
"embedding": {
"base_url": "https://api.voyageai.com/v1",
"api_key": "your_key",
"embed_model": "voyage-3.5-lite"
}
},
)
```
---
### Intégration OpenRouter
```python
from memu import MemoryService
service = MemoryService(
llm_profiles={
"default": {
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
"api_key": "your_key",
"chat_model": "anthropic/claude-3.5-sonnet",
"embed_model": "openai/text-embedding-3-small",
},
},
database_config={"metadata_store": {"provider": "inmemory"}},
)
```
---
## 📖 APIs principales
### `memorize()` : structurer les données brutes
<img width="100%" alt="memorize" src="../assets/memorize.png" />
```python
result = await service.memorize(
resource_url="path/to/file.json", # chemin de fichier local ou URL HTTP
modality="conversation", # conversation | document | image | video | audio
user={"user_id": "123"}, # optionnel : délimiter à un utilisateur ou un agent
)
# Renvoie une fois le traitement terminé :
# { "resource": {...}, "items": [...], "categories": [...], "relations": [...] }
```
- Convertit l'entrée brute en éléments de mémoire typés
- Catégorise et vectorise les éléments sans étiquetage manuel
- Préserve les ressources source et les relations élément-catégorie
---
### `retrieve()` : charger le contexte de l'agent
<img width="100%" alt="retrieve" src="../assets/retrieve.png" />
```python
# La stratégie de récupération est définie une fois sur le service via retrieve_config :
# MemoryService(retrieve_config={"method": "rag"}) # rappel vecteurs d'abord
# MemoryService(retrieve_config={"method": "llm"}) # rappel classé par LLM
result = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What are their preferences?"}}],
where={"user_id": "123"}, # filtre de portée
)
# Renvoie :
# {
# "needs_retrieval": true,
# "original_query": "...",
# "rewritten_query": "...",
# "next_step_query": "...",
# "categories": [...],
# "items": [...],
# "resources": [...]
# }
```
| `retrieve_config.method` | Comportement | Coût | Idéal pour |
|--------------------------|----------|------|----------|
| `rag` | Rappel des catégories/éléments/ressources avec vecteurs d'abord, avec routage LLM et vérifications de suffisance optionnels activés par défaut | Embeddings plus appels LLM, sauf si `route_intention` et `sufficiency_check` sont désactivés | Rappel délimité et rapide avec un raisonnement contrôlable |
| `llm` | Rappel des catégories/éléments/ressources classé par LLM | Classement par LLM à chaque niveau | Classement sémantique plus profond |
---
## 💡 Exemples de workflows
### Assistant en apprentissage permanent
```bash
export OPENAI_API_KEY=your_key
uv run python examples/example_1_conversation_memory.py
```
Extrait automatiquement les préférences, construit des modèles de relation et fait remonter le contexte pertinent dans les conversations futures.
### Agent qui s'améliore lui-même
```bash
uv run python examples/example_2_skill_extraction.py
```
Surveille les actions de l'agent, identifie les schémas de réussites et d'échecs, et génère automatiquement des guides de compétences à partir de l'expérience.
### Constructeur de contexte multimodal
```bash
uv run python examples/example_3_multimodal_memory.py
```
Recoupe automatiquement texte, images et documents dans une couche de mémoire unifiée.
---
## 📊 Performances
memU atteint une **précision moyenne de 92,09 %** sur le benchmark Locomo, sur l'ensemble des tâches de raisonnement.
<img width="100%" alt="benchmark" src="https://github.com/user-attachments/assets/6fec4884-94e5-4058-ad5c-baac3d7e76d9" />
Voir les résultats détaillés : [memU-experiment](https://github.com/NevaMind-AI/memU-experiment)
---
## 🧩 Écosystème
| Dépôt | Description |
|------------|-------------|
| **[memU](https://github.com/NevaMind-AI/memU)** | Système de fichiers de mémoire central : ingestion, extraction, récupération |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | Backend avec synchronisation en temps réel et déclencheurs webhook |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | Tableau de bord visuel pour explorer et surveiller la mémoire |
**Liens rapides :**
- 🚀 [Essayer MemU Cloud](https://app.memu.so/quick-start)
- 📚 [Documentation de l'API](https://memu.pro/docs)
- 💬 [Communauté Discord](https://discord.com/invite/hQZntfGsbJ)
---
## 🤝 Partenaires
<div align="center">
<a href="https://github.com/TEN-framework/ten-framework"><img src="https://avatars.githubusercontent.com/u/113095513?s=200&v=4" alt="Ten" height="40" style="margin: 10px;"></a>
<a href="https://openagents.org"><img src="../assets/partners/openagents.png" alt="OpenAgents" height="40" style="margin: 10px;"></a>
<a href="https://github.com/milvus-io/milvus"><img src="https://miro.medium.com/v2/resize:fit:2400/1*-VEGyAgcIBD62XtZWavy8w.png" alt="Milvus" height="40" style="margin: 10px;"></a>
<a href="https://xroute.ai/"><img src="../assets/partners/xroute.png" alt="xRoute" height="40" style="margin: 10px;"></a>
<a href="https://jaaz.app/"><img src="../assets/partners/jazz.png" alt="Jazz" height="40" style="margin: 10px;"></a>
<a href="https://github.com/Buddie-AI/Buddie"><img src="../assets/partners/buddie.png" alt="Buddie" height="40" style="margin: 10px;"></a>
<a href="https://github.com/bytebase/bytebase"><img src="../assets/partners/bytebase.png" alt="Bytebase" height="40" style="margin: 10px;"></a>
<a href="https://github.com/LazyAGI/LazyLLM"><img src="../assets/partners/LazyLLM.png" alt="LazyLLM" height="40" style="margin: 10px;"></a>
<a href="https://clawdchat.ai/"><img src="../assets/partners/Clawdchat.png" alt="Clawdchat" height="40" style="margin: 10px;"></a>
</div>
---
## 🤝 Contribuer
```bash
# Forkez et clonez
git clone https://github.com/YOUR_USERNAME/memU.git
cd memU
# Installez les dépendances de développement
make install
# Lancez les contrôles qualité avant de soumettre
make check
```
Consultez [CONTRIBUTING.md](../CONTRIBUTING.md) pour les directives complètes.
**Prérequis :** Python 3.13+, [uv](https://github.com/astral-sh/uv), Git
---
## 📄 Licence
[Apache License 2.0](../LICENSE.txt)
---
## 🌍 Communauté
- **GitHub Issues** : [Signaler des bugs et demander des fonctionnalités](https://github.com/NevaMind-AI/memU/issues)
- **Discord** : [Rejoindre la communauté](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)** : [Suivre @memU_ai](https://x.com/memU_ai)
- **Contact** : info@nevamind.ai
---
<div align="center">
**Mettez-nous une étoile sur GitHub** pour être averti des nouvelles versions !
</div>
-542
View File
@@ -1,542 +0,0 @@
![MemU Banner](../assets/banner.png)
<div align="center">
# memU
### 個人の記憶を、ファイルとして
**高速な検索 · 高い精度 · 低コスト**
[![PyPI version](https://badge.fury.io/py/memu-py.svg)](https://badge.fury.io/py/memu-py)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
[![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/hQZntfGsbJ)
[![Twitter](https://img.shields.io/badge/Twitter-Follow-1DA1F2?logo=x&logoColor=white)](https://x.com/memU_ai)
<a href="https://trendshift.io/repositories/17374" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17374" alt="NevaMind-AI%2FmemU | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**[English](README_en.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [Español](README_es.md) | [Français](README_fr.md)**
</div>
---
> [!WARNING]
> 🚧 **大規模改修中** — memU は大規模なリワークを進めており、API・CLI コマンド・ドキュメントは予告なく変更される可能性があります。**2026年7月15日**頃に安定する見込みです。
memU は、会話、ドキュメント、コード、画像、音声、動画、URL、ツールトレースを、人間が読める Markdown ファイル(`INDEX.md``MEMORY.md``SKILL.md`)にコンパイルします。エージェントはファイルツリーをたどり、その場に必要な記憶だけを読み込みます——すべてをスキャンしたり、長い履歴を毎回のプロンプトに詰め込んだりする必要はありません:
- **`INDEX.md`** —— 全体のマップ:カテゴリ、ファイル、サマリー。まずどこを見るべきかをエージェントに示す
- **`MEMORY.md`** —— プロフィール、嗜好、目標、事実、およびソースデータから抽出した主要な出来事
- **`SKILL.md`** —— ツールトレースから自動抽出され、毎回の `memorize()` で洗練される再利用可能なツールパターンとワークフロー
```txt
workspace/
├── INDEX.md ← 全体のマップ:カテゴリ、ファイル、サマリー
├── MEMORY.md ← プロフィール、嗜好、目標、主要な出来事
└── skill/
├── {skill_name}/
│ └── SKILL.md ← 学習したスキルまたはツールのパターン
└── {another_skill}/
└── SKILL.md
```
すべてをプロンプトに詰め込む方式との違いは 3 点です:
- **高速な検索** —— 毎回すべてをスキャンするのではなく、関連フォルダへたどり着き、ファイルをランク付けする。
- **高い精度** —— ユーザー、タスク、セッションでスコープを絞り、各項目を元のソースまで遡れる。
- **低コスト** —— 長い履歴を毎回注入するのではなく、コンパクトでスコープされたコンテキストだけを取得する。
- **監査可能** —— 人間が読めるファイルツリーを監査・編集し、独自のストレージと LLM プロバイダーでルーティングできる。
---
## 🔄 仕組み
2 つのファイルシステム操作として考えてください:生のソースを整理された記憶に**書き込み**、適切なファイルをエージェントに**読み戻す**。
```
WRITE — memorize() READ — retrieve()
────────────────────────────────────────────── ──────────────────────────────────────────────
raw files → extract → files + folders query → walk folders → ranked files
───────────── ───────── ────────────── ───── ──────────── ─────────────
chat logs → parse → profile / event items user / task query
documents / URLs → facts → knowledge / skill items │
images / video → caption → resources + summaries ├─ route + scope → relevant folders (categories)
audio → transcribe→ event / knowledge items ├─ rank by relevance → matching files (items)
tool logs → mine → tool / skill items └─ trace to source → original resources
```
**ファイルシステムへの書き込み(`memorize`)**
1. **取り込み(Ingest** — 各ソースを `Resource`(生のファイル)として、そのモダリティとソースの場所とともに保存する
2. **前処理(Preprocess** — テキストを解析し、画像/動画にキャプションを付け、音声を文字起こしし、入力を正規化する
3. **抽出(Extract** — 生のコンテンツを型付きの `RecallEntry` レコード(ファイル)に変換する:profile、event、knowledge、behavior、skill、tool の記憶
4. **整理(Organize** — アイテムを `RecallFile` フォルダ(記憶カテゴリ、またはスキル)に振り分け、相互リンクし、埋め込みを作り、閲覧可能なツリーへ要約する
5. **永続化(Persist** — 設定されたバックエンドを通じてレコード、関係、埋め込み、フォルダのサマリーを書き込む
**ファイルシステムからの読み込み(`retrieve`)**
6. **検索(Retrieve** — フォルダをたどり、現在のユーザー、エージェント、セッション、タスクに関連するファイルだけを返す
---
## 🗂️ 記憶ファイルシステム
memU の主要な出力は、ナビゲート可能な記憶のツリーです——フォルダ、ファイル、そしてそれらの背後にあるソース素材——リポジトリ契約を通じて永続化され、`memorize()``retrieve()` から辞書として返されます。
```txt
RecallFile ← フォルダ:進化するサマリーを持つトピック
├── name, track (memory | skill), description, content
├── embedding
└── RecallEntry[] ← ファイル:型付きのアトミックな記憶
├── memory_type: profile | event | knowledge | behavior | skill | tool
├── summary, extra, happened_at, embedding
└── Resource ← ソース:この記憶の由来となった生のファイル
└── url, modality, local_path, caption, embedding
```
| レコード | ファイルシステム上の役割 | 用途 |
|--------|------------------|---------|
| `RecallFile` | **フォルダ** — 関連する記憶をまとめ(またはスキルを保持し)、トピックレベルのサマリーを保持する。`track` フィールドが `memory``skill` かを示す | 広いクエリ向けにコンパクトなコンテキストを読み込む |
| `RecallEntry` | **ファイル** — サマリーと任意のメタデータを持つ型付きアトミック記憶 | 正確な事実、嗜好、出来事、スキル、ツールパターンを注入する |
| `Resource` | **ソース素材** — 記憶の背後にある元ファイル(キャプション/テキスト付き) | コンテキストをその由来まで遡る |
| `RecallFileEntry` | **リンク** — アイテムをフォルダ配下に振り分けるエッジ | ソースを再処理せずに関連する記憶をナビゲートする |
これによりエージェントは安定した記憶のファイルシステムを得ます:生のソースは一度取り込めばよく、以降はすべてのソース素材を読み直す代わりに、スコープ付き・ランク付けされたファイルを要求できます。
---
## 🧩 memU が構築するもの
ファイルシステムの各層は構造化レコードとして保存されます:
| 層 | 何を表すか | エージェントが使う理由 |
|-------|--------------------|-------------------|
| **RecallFile** | 自動生成されるフォルダ:進化するサマリーを持つトピック(またはスキル) | 詳細に踏み込む前に高レベルのコンテキストを読み込む |
| **RecallEntry** | ファイル:型とサマリーを持つアトミックな構造化記憶 | 正確な事実、嗜好、出来事、スキル、ツールパターンを注入する |
| **Resource** | ファイルの背後にあるソース素材:会話、ドキュメント、画像、動画、音声、URL、ファイル | 記憶をその由来まで遡る |
| **RecallFileEntry** | アイテムをフォルダ配下に振り分けるリンク | ソースを再処理せずに関連する記憶をナビゲートする |
| **Embedding** | フォルダ・ファイル・ソースにまたがるベクトルインデックス | 低レイテンシで関連コンテキストを検索する |
`memorize()` の出力例:
```json
{
"resource": {
"id": "res_01",
"url": "files/launch-meeting.mp4",
"modality": "video",
"caption": "A product planning discussion about onboarding and launch risks."
},
"items": [
{
"id": "mem_01",
"memory_type": "event",
"summary": "The team decided to simplify onboarding before the next launch review."
},
{
"id": "mem_02",
"memory_type": "profile",
"summary": "The user prefers concise implementation plans with explicit verification steps."
},
{
"id": "mem_03",
"memory_type": "tool",
"summary": "Use repository-wide search before editing configuration files to avoid missing duplicated settings."
}
],
"categories": [
{
"id": "cat_01",
"name": "product_goals",
"summary": "Current launch priorities, onboarding decisions, and unresolved risks."
}
],
"relations": [
{ "item_id": "mem_01", "category_id": "cat_01" }
]
}
```
その後、エージェントは `retrieve()` を呼び出してスコープ付き・ランク付けされたコンテキストのペイロードを取得できます:
```python
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What context matters for this launch task?"}}],
where={"user_id": "123"},
)
```
---
## ⭐️ リポジトリにスターを
<img width="100%" src="https://github.com/NevaMind-AI/memU/blob/main/assets/star.gif" />
memU が役に立つ・面白いと感じたら、GitHub のスター ⭐️ をいただけると大変ありがたいです。
---
## ✨ 主な機能
| 機能 | 説明 |
|------------|-------------|
| 🗂️ **マルチモーダル取り込み** | 会話、ドキュメント、画像、動画、音声、URL、ログ、ローカルファイルを記憶に書き込む |
| 📁 **記憶ファイルシステム** | フォルダ(カテゴリ)、ファイル(アイテム)、ソース素材、リンク、サマリー、埋め込みを永続化する |
| 🧠 **型付き記憶の抽出** | 生のソースから profile、event、knowledge、behavior、skill、tool の記憶を抽出する |
| 🧭 **自己組織化フォルダ** | 手動タグ付けなしでカテゴリ、リンク、サマリー、埋め込みを自動構築する |
| 🤖 **エージェント対応の検索** | 任意のエージェントワークフローに注入できる、スコープ付き・ランク付けされたコンテキストを読み出す |
| 🧱 **プラグイン可能なストレージ** | 同一のリポジトリ契約で in-memory、SQLite、Postgres バックエンドを使い分ける |
| 🔀 **プロファイルベースの LLM ルーティング** | 設定可能な LLM プロファイルでチャット、埋め込み、ビジョン、文字起こしの処理をルーティングする |
---
## 🎯 ユースケース
### 1. **会話の記憶**
*チャットログをユーザーの嗜好、目標、出来事、関係性のコンテキストに変える。*
```python
await service.memorize(
resource_url="examples/resources/conversations/conv1.json",
modality="conversation",
user={"user_id": "123"},
)
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What should I remember about this user?"}}],
where={"user_id": "123"},
)
```
### 2. **コーディングエージェント向けのワークスペースコンテキスト**
*ドキュメント、PR メモ、ログ、設計判断を再利用可能なプロジェクト記憶に変える。*
```python
await service.memorize(resource_url="docs/architecture.md", modality="document")
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "How should I structure this module?"}}],
)
```
### 3. **マルチモーダルなナレッジ層**
*ドキュメント、スクリーンショット、画像、動画、音声メモから検索可能な事実を抽出する。*
```python
await service.memorize(resource_url="examples/resources/docs/doc1.txt", modality="document")
await service.memorize(resource_url="examples/resources/images/image1.png", modality="image")
# Audio is supported for your own .mp3/.wav/.m4a files.
await service.memorize(resource_url="meeting-audio.mp3", modality="audio")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What matters for the next research plan?"}}],
)
```
### 4. **ツールとエージェントの学習**
*実行トレースをツール記憶に変え、将来のエージェントにいつツールを使うべきか・どんな失敗を避けるべきかを伝える。*
```python
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "Which tools worked for config editing?"}}],
)
```
---
## 🗂️ アーキテクチャ
記憶ファイルシステムは、閲覧できるほど階層的であり、直接検索できるほど構造化されています:
<img width="100%" alt="structure" src="../assets/structure.png" />
| 層 | 主な役割 | 検索での役割 |
|-------|--------------|----------------|
| **Category(フォルダ)** | トピックレベルのサマリーを維持する | 広いクエリ向けにコンパクトなコンテキストを組み立てる |
| **Item(ファイル)** | 型付きのアトミック記憶を保存する | 正確な事実、出来事、嗜好、スキル、ツールパターンを読み込む |
| **Resource(ソース)** | ソース素材とキャプションを保持する | アイテム/カテゴリのサマリーで不十分なとき元のコンテキストを呼び戻す |
`MemoryService`、ワークフローパイプライン、ストレージバックエンド、LLM ルーティングのランタイムビューについては [docs/architecture.md](../docs/architecture.md) を参照してください。
---
## 🚀 クイックスタート
### オプション 1:クラウド版
👉 **[memu.so](https://memu.so)** — 取り込み、構造化記憶、検索をマネージドで提供するホスト型 API
エンタープライズ導入:**info@nevamind.ai**
#### Cloud API (v3)
| Base URL | `https://api.memu.so` |
|----------|----------------------|
| Auth | `Authorization: Bearer <token>` |
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v3/memory/memorize` | 生データを取り込み構造化記憶を構築する |
| `GET` | `/api/v3/memory/memorize/status/{task_id}` | 処理ステータスを確認する |
| `POST` | `/api/v3/memory/categories` | 自動生成されたカテゴリを一覧する |
| `POST` | `/api/v3/memory/retrieve` | エージェントのコンテキストを得るために記憶を検索する |
📚 **[完全な API ドキュメント](https://memu.pro/docs#cloud-version)**
---
### オプション 2:セルフホスト
#### インストール
本リポジトリのクローンから:
```bash
uv sync
# または、フルの開発セットアップ:
make install
```
公開パッケージをインストールする場合:
```bash
pip install memu-py
```
> **要件**Python 3.13+。デフォルトの例は OpenAI を使うため、`OPENAI_API_KEY` を設定するか、`llm_profiles` で別のプロバイダーを渡してください。
**インメモリのスモークスクリプトを実行:**
```bash
export OPENAI_API_KEY=your_key
cd tests
uv run python test_inmemory.py
```
**PostgreSQL + pgvector で実行:**
```bash
uv sync --extra postgres
docker run -d --name memu-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=memu \
-p 5432:5432 \
pgvector/pgvector:pg16
export OPENAI_API_KEY=your_key
export POSTGRES_DSN=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/memu
cd tests
uv run python test_postgres.py
```
---
### カスタム LLM と埋め込みプロバイダー
```python
from memu import MemUService
service = MemUService(
llm_profiles={
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "your_key",
"chat_model": "qwen3-max",
"client_backend": "sdk"
},
"embedding": {
"base_url": "https://api.voyageai.com/v1",
"api_key": "your_key",
"embed_model": "voyage-3.5-lite"
}
},
)
```
---
### OpenRouter 連携
```python
from memu import MemoryService
service = MemoryService(
llm_profiles={
"default": {
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
"api_key": "your_key",
"chat_model": "anthropic/claude-3.5-sonnet",
"embed_model": "openai/text-embedding-3-small",
},
},
database_config={"metadata_store": {"provider": "inmemory"}},
)
```
---
## 📖 コア API
### `memorize()` — 生データを構造化する
<img width="100%" alt="memorize" src="../assets/memorize.png" />
```python
result = await service.memorize(
resource_url="path/to/file.json", # ローカルファイルパスまたは HTTP URL
modality="conversation", # conversation | document | image | video | audio
user={"user_id": "123"}, # 任意:ユーザーやエージェントにスコープを限定
)
# 処理完了後に返る:
# { "resource": {...}, "items": [...], "categories": [...], "relations": [...] }
```
- 生の入力を型付きのメモリアイテムに変換する
- 手動タグ付けなしでアイテムを分類し埋め込みを作る
- ソースリソースとアイテム–カテゴリ関係を保持する
---
### `retrieve()` — エージェントのコンテキストを読み込む
<img width="100%" alt="retrieve" src="../assets/retrieve.png" />
```python
# 検索戦略は retrieve_config でサービスに一度だけ設定します:
# MemoryService(retrieve_config={"method": "rag"}) # ベクトル優先の想起
# MemoryService(retrieve_config={"method": "llm"}) # LLM ランク付けの想起
result = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What are their preferences?"}}],
where={"user_id": "123"}, # スコープフィルタ
)
# 返り値:
# {
# "needs_retrieval": true,
# "original_query": "...",
# "rewritten_query": "...",
# "next_step_query": "...",
# "categories": [...],
# "items": [...],
# "resources": [...]
# }
```
| `retrieve_config.method` | 動作 | コスト | 適した用途 |
|--------------------------|----------|------|----------|
| `rag` | ベクトル優先のカテゴリ/アイテム/リソース想起。任意の LLM ルーティングと充足性チェックがデフォルトで有効 | `route_intention``sufficiency_check` を無効にしない限り、埋め込みに加えて LLM 呼び出し | 推論を制御可能にした高速なスコープ想起 |
| `llm` | LLM がランク付けするカテゴリ/アイテム/リソース想起 | 各階層での LLM ランク付け | より深い意味的ランク付け |
---
## 💡 サンプルワークフロー
### 学び続けるアシスタント
```bash
export OPENAI_API_KEY=your_key
uv run python examples/example_1_conversation_memory.py
```
嗜好を自動的に抽出し、関係モデルを構築し、将来の会話で関連コンテキストを浮かび上がらせます。
### 自己改善するエージェント
```bash
uv run python examples/example_2_skill_extraction.py
```
エージェントの行動を監視し、成功と失敗のパターンを特定し、経験からスキルガイドを自動生成します。
### マルチモーダル・コンテキストビルダー
```bash
uv run python examples/example_3_multimodal_memory.py
```
テキスト、画像、ドキュメントを自動的に相互参照し、統一された記憶層にまとめます。
---
## 📊 パフォーマンス
memU は Locomo ベンチマークのすべての推論タスクで **平均 92.09% の精度** を達成しています。
<img width="100%" alt="benchmark" src="https://github.com/user-attachments/assets/6fec4884-94e5-4058-ad5c-baac3d7e76d9" />
詳細な結果を見る:[memU-experiment](https://github.com/NevaMind-AI/memU-experiment)
---
## 🧩 エコシステム
| リポジトリ | 説明 |
|------------|-------------|
| **[memU](https://github.com/NevaMind-AI/memU)** | コアの記憶ファイルシステム —— 取り込み、抽出、検索 |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | リアルタイム同期と webhook トリガーを備えたバックエンド |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | 記憶を閲覧・監視するためのビジュアルダッシュボード |
**クイックリンク:**
- 🚀 [MemU Cloud を試す](https://app.memu.so/quick-start)
- 📚 [API ドキュメント](https://memu.pro/docs)
- 💬 [Discord コミュニティ](https://discord.com/invite/hQZntfGsbJ)
---
## 🤝 パートナー
<div align="center">
<a href="https://github.com/TEN-framework/ten-framework"><img src="https://avatars.githubusercontent.com/u/113095513?s=200&v=4" alt="Ten" height="40" style="margin: 10px;"></a>
<a href="https://openagents.org"><img src="../assets/partners/openagents.png" alt="OpenAgents" height="40" style="margin: 10px;"></a>
<a href="https://github.com/milvus-io/milvus"><img src="https://miro.medium.com/v2/resize:fit:2400/1*-VEGyAgcIBD62XtZWavy8w.png" alt="Milvus" height="40" style="margin: 10px;"></a>
<a href="https://xroute.ai/"><img src="../assets/partners/xroute.png" alt="xRoute" height="40" style="margin: 10px;"></a>
<a href="https://jaaz.app/"><img src="../assets/partners/jazz.png" alt="Jazz" height="40" style="margin: 10px;"></a>
<a href="https://github.com/Buddie-AI/Buddie"><img src="../assets/partners/buddie.png" alt="Buddie" height="40" style="margin: 10px;"></a>
<a href="https://github.com/bytebase/bytebase"><img src="../assets/partners/bytebase.png" alt="Bytebase" height="40" style="margin: 10px;"></a>
<a href="https://github.com/LazyAGI/LazyLLM"><img src="../assets/partners/LazyLLM.png" alt="LazyLLM" height="40" style="margin: 10px;"></a>
<a href="https://clawdchat.ai/"><img src="../assets/partners/Clawdchat.png" alt="Clawdchat" height="40" style="margin: 10px;"></a>
</div>
---
## 🤝 コントリビュート
```bash
# フォークしてクローン
git clone https://github.com/YOUR_USERNAME/memU.git
cd memU
# 開発依存をインストール
make install
# 送信前に品質チェックを実行
make check
```
詳しいガイドラインは [CONTRIBUTING.md](../CONTRIBUTING.md) を参照してください。
**前提条件:** Python 3.13+、[uv](https://github.com/astral-sh/uv)、Git
---
## 📄 ライセンス
[Apache License 2.0](../LICENSE.txt)
---
## 🌍 コミュニティ
- **GitHub Issues**[バグ報告と機能リクエスト](https://github.com/NevaMind-AI/memU/issues)
- **Discord**[コミュニティに参加](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**[@memU_ai をフォロー](https://x.com/memU_ai)
- **連絡先**info@nevamind.ai
---
<div align="center">
**GitHub でスターを** つけて、新リリースの通知を受け取りましょう!
</div>
-542
View File
@@ -1,542 +0,0 @@
![MemU Banner](../assets/banner.png)
<div align="center">
# memU
### 개인 기억, 파일로 저장
**빠른 검색 · 높은 정확도 · 낮은 비용**
[![PyPI version](https://badge.fury.io/py/memu-py.svg)](https://badge.fury.io/py/memu-py)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
[![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/hQZntfGsbJ)
[![Twitter](https://img.shields.io/badge/Twitter-Follow-1DA1F2?logo=x&logoColor=white)](https://x.com/memU_ai)
<a href="https://trendshift.io/repositories/17374" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17374" alt="NevaMind-AI%2FmemU | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**[English](README_en.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [Español](README_es.md) | [Français](README_fr.md)**
</div>
---
> [!WARNING]
> 🚧 **대규모 개편 진행 중** — memU는 대규모 리워크 중이며 API, CLI 명령어, 문서가 예고 없이 변경될 수 있습니다. **2026년 7월 15일**경 안정화될 예정입니다.
memU는 대화, 문서, 코드, 이미지, 오디오, 비디오, URL, 도구 트레이스를 사람이 읽을 수 있는 Markdown 파일(`INDEX.md`, `MEMORY.md`, `SKILL.md`)로 컴파일합니다. 에이전트는 파일 트리를 탐색하며 그 순간 필요한 기억만 불러옵니다 — 매번 전체를 스캔하거나 긴 기록을 프롬프트에 넣을 필요가 없습니다:
- **`INDEX.md`** — 전체 지도: 카테고리, 파일, 요약. 에이전트가 먼저 어디를 봐야 하는지 알려줍니다
- **`MEMORY.md`** — 프로필, 선호, 목표, 사실, 그리고 소스 데이터에서 추출한 주요 사건
- **`SKILL.md`** — 도구 트레이스에서 자동 추출되고 매 `memorize()`마다 정제되는 재사용 가능한 도구 패턴과 워크플로
```txt
workspace/
├── INDEX.md ← 전체 지도: 카테고리, 파일, 요약
├── MEMORY.md ← 프로필, 선호, 목표, 주요 사건
└── skill/
├── {skill_name}/
│ └── SKILL.md ← 학습한 스킬 또는 도구 패턴
└── {another_skill}/
└── SKILL.md
```
모든 것을 프롬프트에 넣는 방식과 달리 memU는 세 가지 핵심 이점이 있습니다:
- **빠른 검색** — 매번 전체를 스캔하는 대신 관련 폴더로 이동하고 파일을 순위화합니다.
- **높은 정확도** — 사용자, 작업, 세션별로 범위를 좁히고 각 항목을 원본 소스까지 추적합니다.
- **낮은 비용** — 긴 기록을 매번 주입하는 대신 압축된 범위 지정 컨텍스트만 검색합니다.
- **감사 가능** — 사람이 읽을 수 있는 파일 트리를 검토·편집하고, 자체 스토리지와 LLM 제공자로 라우팅할 수 있습니다.
---
## 🔄 작동 방식
두 가지 파일 시스템 작업으로 생각하세요: 원본 소스를 정리된 메모리에 **쓰기**, 그리고 알맞은 파일을 에이전트로 다시 **읽기**.
```
WRITE — memorize() READ — retrieve()
────────────────────────────────────────────── ──────────────────────────────────────────────
raw files → extract → files + folders query → walk folders → ranked files
───────────── ───────── ────────────── ───── ──────────── ─────────────
chat logs → parse → profile / event items user / task query
documents / URLs → facts → knowledge / skill items │
images / video → caption → resources + summaries ├─ route + scope → relevant folders (categories)
audio → transcribe→ event / knowledge items ├─ rank by relevance → matching files (items)
tool logs → mine → tool / skill items └─ trace to source → original resources
```
**파일 시스템에 쓰기(`memorize`)**
1. **수집(Ingest)** — 각 소스를 모달리티와 소스 위치와 함께 `Resource`(원본 파일)로 저장합니다
2. **전처리(Preprocess)** — 텍스트를 파싱하고, 이미지/비디오에 캡션을 달고, 오디오를 전사하고, 입력을 정규화합니다
3. **추출(Extract)** — 원본 콘텐츠를 타입이 지정된 `RecallEntry` 레코드(파일)로 변환합니다: profile, event, knowledge, behavior, skill, tool 메모리
4. **조직화(Organize)** — 아이템을 `RecallFile` 폴더(메모리 카테고리 또는 스킬)로 분류하고, 상호 링크하고, 임베딩하고, 탐색 가능한 트리로 요약합니다
5. **영속화(Persist)** — 설정된 백엔드를 통해 레코드, 관계, 임베딩, 폴더 요약을 기록합니다
**파일 시스템에서 읽기(`retrieve`)**
6. **검색(Retrieve)** — 폴더를 탐색하여 현재 사용자, 에이전트, 세션 또는 태스크에 관련된 파일만 반환합니다
---
## 🗂️ 메모리 파일 시스템
memU의 주요 산출물은 탐색 가능한 메모리 트리입니다 — 폴더, 파일, 그리고 그 뒤에 있는 소스 산출물 — 리포지토리 계약을 통해 영속화되며 `memorize()``retrieve()`에서 딕셔너리로 반환됩니다.
```txt
RecallFile ← 폴더: 진화하는 요약을 가진 주제
├── name, track (memory | skill), description, content
├── embedding
└── RecallEntry[] ← 파일: 타입이 지정된 원자적 메모리
├── memory_type: profile | event | knowledge | behavior | skill | tool
├── summary, extra, happened_at, embedding
└── Resource ← 소스: 이 메모리가 비롯된 원본 파일
└── url, modality, local_path, caption, embedding
```
| 레코드 | 파일 시스템 역할 | 사용 목적 |
|--------|------------------|---------|
| `RecallFile` | **폴더** — 관련 메모리를 묶고(또는 스킬을 담고) 주제 수준 요약을 유지; `track` 필드가 `memory` 또는 `skill`을 표시 | 폭넓은 질의를 위해 간결한 컨텍스트를 로드 |
| `RecallEntry` | **파일** — 요약과 선택적 메타데이터를 가진 타입 지정 원자 메모리 | 정확한 사실, 선호, 사건, 스킬, 도구 패턴을 주입 |
| `Resource` | **소스 산출물** — 메모리 뒤의 원본 파일(캡션/텍스트 포함) | 컨텍스트를 그 출처까지 추적 |
| `RecallFileEntry` | **링크** — 아이템을 폴더 아래에 분류하는 엣지 | 소스를 재처리하지 않고 관련 메모리를 탐색 |
이를 통해 에이전트는 안정적인 메모리 파일 시스템을 얻습니다: 원본 소스는 한 번만 수집하면 되고, 이후에는 모든 소스 산출물을 다시 읽는 대신 범위가 지정되고 순위가 매겨진 파일을 요청할 수 있습니다.
---
## 🧩 memU가 구축하는 것
파일 시스템의 각 계층은 구조화 레코드로 저장됩니다:
| 계층 | 무엇을 나타내는가 | 에이전트가 사용하는 이유 |
|-------|--------------------|-------------------|
| **RecallFile** | 자동 생성 폴더: 진화하는 요약을 가진 주제(또는 스킬) | 세부로 파고들기 전에 상위 수준 컨텍스트를 로드 |
| **RecallEntry** | 파일: 타입과 요약을 가진 원자적 구조화 메모리 | 정확한 사실, 선호, 사건, 스킬, 도구 패턴을 주입 |
| **Resource** | 파일 뒤의 소스 산출물: 대화, 문서, 이미지, 비디오, 오디오, URL, 파일 | 메모리를 그 출처까지 추적 |
| **RecallFileEntry** | 아이템을 폴더 아래에 분류하는 링크 | 소스를 재처리하지 않고 관련 메모리를 탐색 |
| **Embedding** | 폴더, 파일, 소스를 아우르는 벡터 인덱스 | 낮은 지연으로 관련 컨텍스트를 검색 |
`memorize()` 출력 예시:
```json
{
"resource": {
"id": "res_01",
"url": "files/launch-meeting.mp4",
"modality": "video",
"caption": "A product planning discussion about onboarding and launch risks."
},
"items": [
{
"id": "mem_01",
"memory_type": "event",
"summary": "The team decided to simplify onboarding before the next launch review."
},
{
"id": "mem_02",
"memory_type": "profile",
"summary": "The user prefers concise implementation plans with explicit verification steps."
},
{
"id": "mem_03",
"memory_type": "tool",
"summary": "Use repository-wide search before editing configuration files to avoid missing duplicated settings."
}
],
"categories": [
{
"id": "cat_01",
"name": "product_goals",
"summary": "Current launch priorities, onboarding decisions, and unresolved risks."
}
],
"relations": [
{ "item_id": "mem_01", "category_id": "cat_01" }
]
}
```
그런 다음 에이전트는 `retrieve()`를 호출하여 범위가 지정되고 순위가 매겨진 컨텍스트 페이로드를 얻을 수 있습니다:
```python
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What context matters for this launch task?"}}],
where={"user_id": "123"},
)
```
---
## ⭐️ 저장소에 스타 누르기
<img width="100%" src="https://github.com/NevaMind-AI/memU/blob/main/assets/star.gif" />
memU가 유용하거나 흥미롭다고 느끼셨다면, GitHub 스타 ⭐️ 를 눌러 주시면 정말 감사하겠습니다.
---
## ✨ 핵심 기능
| 기능 | 설명 |
|------------|-------------|
| 🗂️ **멀티모달 수집** | 대화, 문서, 이미지, 비디오, 오디오, URL, 로그, 로컬 파일을 메모리에 기록 |
| 📁 **메모리 파일 시스템** | 폴더(카테고리), 파일(아이템), 소스 산출물, 링크, 요약, 임베딩을 영속화 |
| 🧠 **타입 지정 메모리 추출** | 원본 소스에서 profile, event, knowledge, behavior, skill, tool 메모리를 추출 |
| 🧭 **자기 조직화 폴더** | 수동 태깅 없이 카테고리, 링크, 요약, 임베딩을 자동 구축 |
| 🤖 **에이전트 친화적 검색** | 어떤 에이전트 워크플로에도 주입할 수 있는, 범위 지정되고 순위가 매겨진 컨텍스트를 읽기 |
| 🧱 **플러그형 스토리지** | 동일한 리포지토리 계약으로 in-memory, SQLite, Postgres 백엔드 사용 |
| 🔀 **프로파일 기반 LLM 라우팅** | 설정 가능한 LLM 프로파일로 채팅, 임베딩, 비전, 전사 작업을 라우팅 |
---
## 🎯 활용 사례
### 1. **대화 메모리**
*채팅 로그를 사용자 선호, 목표, 사건, 관계 컨텍스트로 전환.*
```python
await service.memorize(
resource_url="examples/resources/conversations/conv1.json",
modality="conversation",
user={"user_id": "123"},
)
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What should I remember about this user?"}}],
where={"user_id": "123"},
)
```
### 2. **코딩 에이전트를 위한 워크스페이스 컨텍스트**
*문서, PR 메모, 로그, 설계 결정을 재사용 가능한 프로젝트 메모리로 전환.*
```python
await service.memorize(resource_url="docs/architecture.md", modality="document")
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "How should I structure this module?"}}],
)
```
### 3. **멀티모달 지식 계층**
*문서, 스크린샷, 이미지, 비디오, 음성 메모에서 검색 가능한 사실을 추출.*
```python
await service.memorize(resource_url="examples/resources/docs/doc1.txt", modality="document")
await service.memorize(resource_url="examples/resources/images/image1.png", modality="image")
# Audio is supported for your own .mp3/.wav/.m4a files.
await service.memorize(resource_url="meeting-audio.mp3", modality="audio")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What matters for the next research plan?"}}],
)
```
### 4. **도구와 에이전트 학습**
*실행 트레이스를 도구 메모리로 전환하여, 미래의 에이전트에게 언제 도구를 써야 하고 어떤 실수를 피해야 하는지 알려줍니다.*
```python
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "Which tools worked for config editing?"}}],
)
```
---
## 🗂️ 아키텍처
메모리 파일 시스템은 탐색할 만큼 계층적이면서 직접 검색할 만큼 구조화되어 있습니다:
<img width="100%" alt="structure" src="../assets/structure.png" />
| 계층 | 주요 역할 | 검색 역할 |
|-------|--------------|----------------|
| **Category(폴더)** | 주제 수준 요약을 유지 | 폭넓은 질의를 위해 간결한 컨텍스트를 조립 |
| **Item(파일)** | 타입 지정 원자 메모리를 저장 | 정확한 사실, 사건, 선호, 스킬, 도구 패턴을 로드 |
| **Resource(소스)** | 소스 산출물과 캡션을 보존 | 아이템/카테고리 요약으로 부족할 때 원본 컨텍스트를 회수 |
`MemoryService`, 워크플로 파이프라인, 스토리지 백엔드, LLM 라우팅의 런타임 관점은 [docs/architecture.md](../docs/architecture.md)를 참고하세요.
---
## 🚀 빠른 시작
### 옵션 1: 클라우드 버전
👉 **[memu.so](https://memu.so)** — 관리형 수집, 구조화 메모리, 검색을 제공하는 호스팅 API
엔터프라이즈 배포: **info@nevamind.ai**
#### Cloud API (v3)
| Base URL | `https://api.memu.so` |
|----------|----------------------|
| Auth | `Authorization: Bearer <token>` |
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v3/memory/memorize` | 원본 데이터를 수집하고 구조화 메모리를 구축 |
| `GET` | `/api/v3/memory/memorize/status/{task_id}` | 처리 상태 확인 |
| `POST` | `/api/v3/memory/categories` | 자동 생성된 카테고리 나열 |
| `POST` | `/api/v3/memory/retrieve` | 에이전트 컨텍스트를 위해 메모리 질의 |
📚 **[전체 API 문서](https://memu.pro/docs#cloud-version)**
---
### 옵션 2: 셀프 호스팅
#### 설치
이 저장소의 클론에서:
```bash
uv sync
# 또는, 전체 개발 환경 설정:
make install
```
게시된 패키지를 설치하려면:
```bash
pip install memu-py
```
> **요구 사항**: Python 3.13+. 기본 예제는 OpenAI를 사용하므로 `OPENAI_API_KEY`를 설정하거나 `llm_profiles`로 다른 제공자를 전달하세요.
**인메모리 스모크 스크립트 실행:**
```bash
export OPENAI_API_KEY=your_key
cd tests
uv run python test_inmemory.py
```
**PostgreSQL + pgvector로 실행:**
```bash
uv sync --extra postgres
docker run -d --name memu-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=memu \
-p 5432:5432 \
pgvector/pgvector:pg16
export OPENAI_API_KEY=your_key
export POSTGRES_DSN=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/memu
cd tests
uv run python test_postgres.py
```
---
### 사용자 지정 LLM 및 임베딩 제공자
```python
from memu import MemUService
service = MemUService(
llm_profiles={
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "your_key",
"chat_model": "qwen3-max",
"client_backend": "sdk"
},
"embedding": {
"base_url": "https://api.voyageai.com/v1",
"api_key": "your_key",
"embed_model": "voyage-3.5-lite"
}
},
)
```
---
### OpenRouter 연동
```python
from memu import MemoryService
service = MemoryService(
llm_profiles={
"default": {
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
"api_key": "your_key",
"chat_model": "anthropic/claude-3.5-sonnet",
"embed_model": "openai/text-embedding-3-small",
},
},
database_config={"metadata_store": {"provider": "inmemory"}},
)
```
---
## 📖 핵심 API
### `memorize()` — 원본 데이터 구조화
<img width="100%" alt="memorize" src="../assets/memorize.png" />
```python
result = await service.memorize(
resource_url="path/to/file.json", # 로컬 파일 경로 또는 HTTP URL
modality="conversation", # conversation | document | image | video | audio
user={"user_id": "123"}, # 선택: 사용자 또는 에이전트로 범위 한정
)
# 처리 완료 후 반환:
# { "resource": {...}, "items": [...], "categories": [...], "relations": [...] }
```
- 원본 입력을 타입 지정 메모리 아이템으로 변환
- 수동 태깅 없이 아이템을 분류하고 임베딩
- 소스 리소스와 아이템–카테고리 관계를 보존
---
### `retrieve()` — 에이전트 컨텍스트 로드
<img width="100%" alt="retrieve" src="../assets/retrieve.png" />
```python
# 검색 전략은 retrieve_config로 서비스에 한 번만 설정합니다:
# MemoryService(retrieve_config={"method": "rag"}) # 벡터 우선 회수
# MemoryService(retrieve_config={"method": "llm"}) # LLM 순위 회수
result = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What are their preferences?"}}],
where={"user_id": "123"}, # 범위 필터
)
# 반환:
# {
# "needs_retrieval": true,
# "original_query": "...",
# "rewritten_query": "...",
# "next_step_query": "...",
# "categories": [...],
# "items": [...],
# "resources": [...]
# }
```
| `retrieve_config.method` | 동작 | 비용 | 적합한 용도 |
|--------------------------|----------|------|----------|
| `rag` | 벡터 우선의 카테고리/아이템/리소스 회수. 선택적 LLM 라우팅과 충분성 검사가 기본 활성화 | `route_intention``sufficiency_check`를 비활성화하지 않는 한, 임베딩에 더해 LLM 호출 | 추론을 제어 가능한 빠른 범위 회수 |
| `llm` | LLM이 순위를 매기는 카테고리/아이템/리소스 회수 | 각 계층에서 LLM 순위 매김 | 더 깊은 의미적 순위 매김 |
---
## 💡 예시 워크플로
### 항상 학습하는 어시스턴트
```bash
export OPENAI_API_KEY=your_key
uv run python examples/example_1_conversation_memory.py
```
선호를 자동으로 추출하고, 관계 모델을 구축하며, 이후 대화에서 관련 컨텍스트를 떠올립니다.
### 자기 개선하는 에이전트
```bash
uv run python examples/example_2_skill_extraction.py
```
에이전트의 행동을 모니터링하고, 성공과 실패의 패턴을 식별하며, 경험으로부터 스킬 가이드를 자동 생성합니다.
### 멀티모달 컨텍스트 빌더
```bash
uv run python examples/example_3_multimodal_memory.py
```
텍스트, 이미지, 문서를 자동으로 상호 참조하여 통합된 메모리 계층으로 묶습니다.
---
## 📊 성능
memU는 Locomo 벤치마크의 모든 추론 작업에서 **평균 92.09% 정확도**를 달성합니다.
<img width="100%" alt="benchmark" src="https://github.com/user-attachments/assets/6fec4884-94e5-4058-ad5c-baac3d7e76d9" />
자세한 결과 보기: [memU-experiment](https://github.com/NevaMind-AI/memU-experiment)
---
## 🧩 에코시스템
| 저장소 | 설명 |
|------------|-------------|
| **[memU](https://github.com/NevaMind-AI/memU)** | 핵심 메모리 파일 시스템 — 수집, 추출, 검색 |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | 실시간 동기화와 webhook 트리거를 갖춘 백엔드 |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | 메모리를 탐색하고 모니터링하는 비주얼 대시보드 |
**빠른 링크:**
- 🚀 [MemU Cloud 사용해 보기](https://app.memu.so/quick-start)
- 📚 [API 문서](https://memu.pro/docs)
- 💬 [Discord 커뮤니티](https://discord.com/invite/hQZntfGsbJ)
---
## 🤝 파트너
<div align="center">
<a href="https://github.com/TEN-framework/ten-framework"><img src="https://avatars.githubusercontent.com/u/113095513?s=200&v=4" alt="Ten" height="40" style="margin: 10px;"></a>
<a href="https://openagents.org"><img src="../assets/partners/openagents.png" alt="OpenAgents" height="40" style="margin: 10px;"></a>
<a href="https://github.com/milvus-io/milvus"><img src="https://miro.medium.com/v2/resize:fit:2400/1*-VEGyAgcIBD62XtZWavy8w.png" alt="Milvus" height="40" style="margin: 10px;"></a>
<a href="https://xroute.ai/"><img src="../assets/partners/xroute.png" alt="xRoute" height="40" style="margin: 10px;"></a>
<a href="https://jaaz.app/"><img src="../assets/partners/jazz.png" alt="Jazz" height="40" style="margin: 10px;"></a>
<a href="https://github.com/Buddie-AI/Buddie"><img src="../assets/partners/buddie.png" alt="Buddie" height="40" style="margin: 10px;"></a>
<a href="https://github.com/bytebase/bytebase"><img src="../assets/partners/bytebase.png" alt="Bytebase" height="40" style="margin: 10px;"></a>
<a href="https://github.com/LazyAGI/LazyLLM"><img src="../assets/partners/LazyLLM.png" alt="LazyLLM" height="40" style="margin: 10px;"></a>
<a href="https://clawdchat.ai/"><img src="../assets/partners/Clawdchat.png" alt="Clawdchat" height="40" style="margin: 10px;"></a>
</div>
---
## 🤝 기여하기
```bash
# 포크하고 클론
git clone https://github.com/YOUR_USERNAME/memU.git
cd memU
# 개발 의존성 설치
make install
# 제출 전 품질 검사 실행
make check
```
자세한 가이드라인은 [CONTRIBUTING.md](../CONTRIBUTING.md)를 참고하세요.
**필수 조건:** Python 3.13+, [uv](https://github.com/astral-sh/uv), Git
---
## 📄 라이선스
[Apache License 2.0](../LICENSE.txt)
---
## 🌍 커뮤니티
- **GitHub Issues**: [버그 신고 및 기능 요청](https://github.com/NevaMind-AI/memU/issues)
- **Discord**: [커뮤니티 참여](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**: [@memU_ai 팔로우](https://x.com/memU_ai)
- **연락처**: info@nevamind.ai
---
<div align="center">
**GitHub에서 스타를** 눌러 새 릴리스 알림을 받으세요!
</div>
-542
View File
@@ -1,542 +0,0 @@
![MemU Banner](../assets/banner.png)
<div align="center">
# memU
### 个人记忆,存为文件
**快速检索 · 更高准确性 · 更低成本**
[![PyPI version](https://badge.fury.io/py/memu-py.svg)](https://badge.fury.io/py/memu-py)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
[![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/hQZntfGsbJ)
[![Twitter](https://img.shields.io/badge/Twitter-Follow-1DA1F2?logo=x&logoColor=white)](https://x.com/memU_ai)
<a href="https://trendshift.io/repositories/17374" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17374" alt="NevaMind-AI%2FmemU | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
**[English](README_en.md) | [中文](README_zh.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [Español](README_es.md) | [Français](README_fr.md)**
</div>
---
> [!WARNING]
> 🚧 **正在大规模重构** — memU 正在进行重大重构,API、CLI 命令和文档可能随时变动,预计 **2026 年 7 月 15 日**前后趋于稳定。
memU 将对话、文档、代码、图片、音频、视频、URL 和工具轨迹编译成人类可读的 Markdown 文件(`INDEX.md``MEMORY.md``SKILL.md`)。智能体遍历文件树,只加载当下需要的记忆——而不是每次扫描全部内容,或把冗长历史塞进 prompt:
- **`INDEX.md`** —— 全局地图:类别、文件与摘要,让智能体先知道该去哪里找
- **`MEMORY.md`** —— 画像、偏好、目标、事实,以及从来源数据中提取的关键事件
- **`SKILL.md`** —— 从工具轨迹中自动提取、并在每次 `memorize()` 时持续精炼的可复用工具模式与工作流
```txt
workspace/
├── INDEX.md ← 全局地图:类别、文件与摘要
├── MEMORY.md ← 画像、偏好、目标与关键事件
└── skill/
├── {skill_name}/
│ └── SKILL.md ← 一项学到的技能或工具模式
└── {another_skill}/
└── SKILL.md
```
与把所有内容塞进 prompt 相比,memU 有三个核心优势:
- **快速检索** —— 直接定位相关文件夹并排序,而不是每次都扫描全部记忆。
- **更高准确性** —— 按用户、任务或会话限定范围,并将每条记忆追溯到其原始来源。
- **更低成本** —— 检索紧凑、有范围的上下文,而不是把冗长历史反复注入每次 prompt。
- **可审计** —— 人类可读的文件树,可检查、编辑,并通过自有存储与 LLM 提供商路由。
---
## 🔄 工作原理
可以把它看成两个文件系统操作:把原始来源**写入**有组织的记忆,再把正确的文件**读回**给智能体。
```
WRITE — memorize() READ — retrieve()
────────────────────────────────────────────── ──────────────────────────────────────────────
raw files → extract → files + folders query → walk folders → ranked files
───────────── ───────── ────────────── ───── ──────────── ─────────────
chat logs → parse → profile / event items user / task query
documents / URLs → facts → knowledge / skill items │
images / video → caption → resources + summaries ├─ route + scope → relevant folders (categories)
audio → transcribe→ event / knowledge items ├─ rank by relevance → matching files (items)
tool logs → mine → tool / skill items └─ trace to source → original resources
```
**写入文件系统(`memorize`**
1. **摄入(Ingest** — 把每个来源作为一个 `Resource`(原始文件)存下,记录其模态与来源位置
2. **预处理(Preprocess** — 解析文本、为图片/视频生成描述、转写音频,并规范化输入
3. **提取(Extract** — 把原始内容转成带类型的 `RecallEntry` 记录(文件):profile、event、knowledge、behavior、skill 或 tool 记忆
4. **组织(Organize** — 把记忆项归入 `RecallFile` 文件夹(一个记忆分类,或一个技能),交叉关联、向量化,并汇总成可浏览的树
5. **持久化(Persist** — 通过所配置的后端写入记录、关系、向量与文件夹摘要
**从文件系统读取(`retrieve`**
6. **检索(Retrieve** — 在文件夹中导航,仅返回与当前用户、智能体、会话或任务相关的文件
---
## 🗂️ 记忆文件系统
memU 的主要产出是一棵可浏览的记忆树——文件夹、文件,以及它们背后的来源素材——通过仓储契约持久化,并以字典形式从 `memorize()``retrieve()` 返回。
```txt
RecallFile ← 文件夹:带演进式摘要的主题
├── name, track (memory | skill), description, content
├── embedding
└── RecallEntry[] ← 文件:带类型的原子记忆
├── memory_type: profile | event | knowledge | behavior | skill | tool
├── summary, extra, happened_at, embedding
└── Resource ← 来源:这条记忆所来自的原始文件
└── url, modality, local_path, caption, embedding
```
| 记录 | 文件系统角色 | 用途 |
|--------|------------------|---------|
| `RecallFile` | **文件夹** — 归集相关记忆(或承载一个技能)并维护主题级摘要;`track` 字段标记为 `memory``skill` | 为宽泛查询加载紧凑上下文 |
| `RecallEntry` | **文件** — 带类型的原子记忆,含摘要与可选元数据 | 注入精确的事实、偏好、事件、技能与工具模式 |
| `Resource` | **来源素材** — 记忆背后的原始文件,附带描述/文本 | 把上下文追溯回它的来源 |
| `RecallFileEntry` | **链接** — 把记忆项归档到文件夹下的边 | 在不重新处理来源的情况下导航相关记忆 |
这为智能体提供了一个稳定的记忆文件系统:原始来源只需摄入一次,之后便可请求限定范围、经过排序的文件,而无需重读每一份来源素材。
---
## 🧩 memU 构建了什么
文件系统的每一层都以结构化记录的形式存储:
| 层 | 代表什么 | 智能体为何使用 |
|-------|--------------------|-------------------|
| **RecallFile** | 自动生成的文件夹:带演进式摘要的主题(或一个技能) | 先加载高层上下文,再深入细节 |
| **RecallEntry** | 文件:带类型与摘要的原子结构化记忆 | 注入精确的事实、偏好、事件、技能与工具模式 |
| **Resource** | 文件背后的来源素材:对话、文档、图片、视频、音频、URL 或文件 | 把记忆追溯回其来源 |
| **RecallFileEntry** | 把记忆项归档到文件夹下的链接 | 在不重新处理来源的情况下导航相关记忆 |
| **Embedding** | 覆盖文件夹、文件与来源的向量索引 | 以低延迟检索相关上下文 |
`memorize()` 输出示例:
```json
{
"resource": {
"id": "res_01",
"url": "files/launch-meeting.mp4",
"modality": "video",
"caption": "A product planning discussion about onboarding and launch risks."
},
"items": [
{
"id": "mem_01",
"memory_type": "event",
"summary": "The team decided to simplify onboarding before the next launch review."
},
{
"id": "mem_02",
"memory_type": "profile",
"summary": "The user prefers concise implementation plans with explicit verification steps."
},
{
"id": "mem_03",
"memory_type": "tool",
"summary": "Use repository-wide search before editing configuration files to avoid missing duplicated settings."
}
],
"categories": [
{
"id": "cat_01",
"name": "product_goals",
"summary": "Current launch priorities, onboarding decisions, and unresolved risks."
}
],
"relations": [
{ "item_id": "mem_01", "category_id": "cat_01" }
]
}
```
随后,智能体可以调用 `retrieve()` 获取一份限定范围、经过排序的上下文负载:
```python
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What context matters for this launch task?"}}],
where={"user_id": "123"},
)
```
---
## ⭐️ 给仓库点个 Star
<img width="100%" src="https://github.com/NevaMind-AI/memU/blob/main/assets/star.gif" />
如果你觉得 memU 有用或有意思,欢迎在 GitHub 上点一个 Star ⭐️,我们将不胜感激。
---
## ✨ 核心特性
| 能力 | 说明 |
|------------|-------------|
| 🗂️ **多模态摄入** | 把对话、文档、图片、视频、音频、URL、日志与本地文件写入记忆 |
| 📁 **记忆文件系统** | 持久化文件夹(类别)、文件(记忆项)、来源素材、链接、摘要与向量 |
| 🧠 **带类型的记忆提取** | 从原始来源提取 profile、event、knowledge、behavior、skill 与 tool 记忆 |
| 🧭 **自组织文件夹** | 自动构建类别、链接、摘要与向量,无需手工打标签 |
| 🤖 **面向智能体的检索** | 读取限定范围、经过排序的上下文,可注入任意智能体工作流 |
| 🧱 **可插拔存储** | 使用 in-memory、SQLite 或 Postgres 后端,共享同一套仓储契约 |
| 🔀 **基于 Profile 的 LLM 路由** | 通过可配置的 LLM profile 路由对话、向量、视觉与转写任务 |
---
## 🎯 应用场景
### 1. **对话记忆**
*把聊天记录转化为用户偏好、目标、事件与关系上下文。*
```python
await service.memorize(
resource_url="examples/resources/conversations/conv1.json",
modality="conversation",
user={"user_id": "123"},
)
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What should I remember about this user?"}}],
where={"user_id": "123"},
)
```
### 2. **面向编码智能体的工作区上下文**
*把文档、PR 说明、日志与设计决策转化为可复用的项目记忆。*
```python
await service.memorize(resource_url="docs/architecture.md", modality="document")
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "How should I structure this module?"}}],
)
```
### 3. **多模态知识层**
*从文档、截图、图片、视频与语音笔记中提取可检索的事实。*
```python
await service.memorize(resource_url="examples/resources/docs/doc1.txt", modality="document")
await service.memorize(resource_url="examples/resources/images/image1.png", modality="image")
# Audio is supported for your own .mp3/.wav/.m4a files.
await service.memorize(resource_url="meeting-audio.mp3", modality="audio")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What matters for the next research plan?"}}],
)
```
### 4. **工具与智能体学习**
*把执行轨迹转化为工具记忆,告诉未来的智能体何时使用某个工具、应避免哪些错误。*
```python
await service.memorize(resource_url="examples/resources/logs/log1.txt", modality="document")
context = await service.retrieve(
queries=[{"role": "user", "content": {"text": "Which tools worked for config editing?"}}],
)
```
---
## 🗂️ 架构
记忆文件系统既层级化到足以浏览,又结构化到足以直接检索:
<img width="100%" alt="structure" src="../assets/structure.png" />
| 层 | 主要职责 | 检索职责 |
|-------|--------------|----------------|
| **Category(文件夹)** | 维护主题级摘要 | 为宽泛查询组装紧凑上下文 |
| **Item(文件)** | 存储带类型的原子记忆 | 加载精确的事实、事件、偏好、技能与工具模式 |
| **Resource(来源)** | 保留来源素材与描述 | 当记忆项/类别摘要不够时召回原始上下文 |
关于 `MemoryService`、工作流流水线、存储后端与 LLM 路由的运行时视图,参见 [docs/architecture.md](../docs/architecture.md)。
---
## 🚀 快速开始
### 方式一:云端版本
👉 **[memu.so](https://memu.so)** — 托管 API,提供托管式摄入、结构化记忆与检索
企业部署请联系:**info@nevamind.ai**
#### Cloud API (v3)
| Base URL | `https://api.memu.so` |
|----------|----------------------|
| Auth | `Authorization: Bearer <token>` |
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v3/memory/memorize` | 摄入原始数据并构建结构化记忆 |
| `GET` | `/api/v3/memory/memorize/status/{task_id}` | 查询处理状态 |
| `POST` | `/api/v3/memory/categories` | 列出自动生成的类别 |
| `POST` | `/api/v3/memory/retrieve` | 查询记忆以获取智能体上下文 |
📚 **[完整 API 文档](https://memu.pro/docs#cloud-version)**
---
### 方式二:自托管
#### 安装
从本仓库的克隆中安装:
```bash
uv sync
# 或者,进行完整的开发环境安装:
make install
```
或者安装已发布的包:
```bash
pip install memu-py
```
> **环境要求**Python 3.13+。默认示例使用 OpenAI,请设置 `OPENAI_API_KEY`,或通过 `llm_profiles` 传入其它提供方。
**运行内存模式冒烟脚本:**
```bash
export OPENAI_API_KEY=your_key
cd tests
uv run python test_inmemory.py
```
**使用 PostgreSQL + pgvector 运行:**
```bash
uv sync --extra postgres
docker run -d --name memu-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=memu \
-p 5432:5432 \
pgvector/pgvector:pg16
export OPENAI_API_KEY=your_key
export POSTGRES_DSN=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/memu
cd tests
uv run python test_postgres.py
```
---
### 自定义 LLM 与 Embedding 提供方
```python
from memu import MemUService
service = MemUService(
llm_profiles={
"default": {
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "your_key",
"chat_model": "qwen3-max",
"client_backend": "sdk"
},
"embedding": {
"base_url": "https://api.voyageai.com/v1",
"api_key": "your_key",
"embed_model": "voyage-3.5-lite"
}
},
)
```
---
### OpenRouter 集成
```python
from memu import MemoryService
service = MemoryService(
llm_profiles={
"default": {
"provider": "openrouter",
"client_backend": "httpx",
"base_url": "https://openrouter.ai",
"api_key": "your_key",
"chat_model": "anthropic/claude-3.5-sonnet",
"embed_model": "openai/text-embedding-3-small",
},
},
database_config={"metadata_store": {"provider": "inmemory"}},
)
```
---
## 📖 核心 API
### `memorize()` — 结构化原始数据
<img width="100%" alt="memorize" src="../assets/memorize.png" />
```python
result = await service.memorize(
resource_url="path/to/file.json", # 本地文件路径或 HTTP URL
modality="conversation", # conversation | document | image | video | audio
user={"user_id": "123"}, # 可选:限定到某个用户或智能体
)
# 处理完成后返回:
# { "resource": {...}, "items": [...], "categories": [...], "relations": [...] }
```
- 把原始输入转换为带类型的记忆项
- 自动对记忆项分类并向量化,无需手工打标签
- 保留来源资源以及记忆项—类别关系
---
### `retrieve()` — 加载智能体上下文
<img width="100%" alt="retrieve" src="../assets/retrieve.png" />
```python
# 检索策略在服务上通过 retrieve_config 一次性设定:
# MemoryService(retrieve_config={"method": "rag"}) # 向量优先召回
# MemoryService(retrieve_config={"method": "llm"}) # LLM 排序召回
result = await service.retrieve(
queries=[{"role": "user", "content": {"text": "What are their preferences?"}}],
where={"user_id": "123"}, # 范围过滤
)
# 返回:
# {
# "needs_retrieval": true,
# "original_query": "...",
# "rewritten_query": "...",
# "next_step_query": "...",
# "categories": [...],
# "items": [...],
# "resources": [...]
# }
```
| `retrieve_config.method` | 行为 | 成本 | 适用场景 |
|--------------------------|----------|------|----------|
| `rag` | 向量优先的类别/记忆项/资源召回,默认启用可选的 LLM 路由与充分性检查 | 向量调用加 LLM 调用,除非关闭 `route_intention``sufficiency_check` | 可控推理下的快速限定召回 |
| `llm` | 由 LLM 排序的类别/记忆项/资源召回 | 每一层都进行 LLM 排序 | 更深的语义排序 |
---
## 💡 示例工作流
### 持续学习的助手
```bash
export OPENAI_API_KEY=your_key
uv run python examples/example_1_conversation_memory.py
```
自动提取偏好、构建关系模型,并在未来对话中浮现相关上下文。
### 自我改进的智能体
```bash
uv run python examples/example_2_skill_extraction.py
```
监控智能体的行为,识别成功与失败中的模式,从经验中自动生成技能指南。
### 多模态上下文构建器
```bash
uv run python examples/example_3_multimodal_memory.py
```
自动交叉关联文本、图片与文档,汇入统一的记忆层。
---
## 📊 性能
memU 在 Locomo 基准的所有推理任务上取得了 **92.09% 的平均准确率**
<img width="100%" alt="benchmark" src="https://github.com/user-attachments/assets/6fec4884-94e5-4058-ad5c-baac3d7e76d9" />
查看详细结果:[memU-experiment](https://github.com/NevaMind-AI/memU-experiment)
---
## 🧩 生态
| 仓库 | 说明 |
|------------|-------------|
| **[memU](https://github.com/NevaMind-AI/memU)** | 核心记忆文件系统 —— 摄入、提取、检索 |
| **[memU-server](https://github.com/NevaMind-AI/memU-server)** | 带实时同步与 webhook 触发的后端 |
| **[memU-ui](https://github.com/NevaMind-AI/memU-ui)** | 用于浏览与监控记忆的可视化面板 |
**快速链接:**
- 🚀 [试用 MemU Cloud](https://app.memu.so/quick-start)
- 📚 [API 文档](https://memu.pro/docs)
- 💬 [Discord 社区](https://discord.com/invite/hQZntfGsbJ)
---
## 🤝 合作伙伴
<div align="center">
<a href="https://github.com/TEN-framework/ten-framework"><img src="https://avatars.githubusercontent.com/u/113095513?s=200&v=4" alt="Ten" height="40" style="margin: 10px;"></a>
<a href="https://openagents.org"><img src="../assets/partners/openagents.png" alt="OpenAgents" height="40" style="margin: 10px;"></a>
<a href="https://github.com/milvus-io/milvus"><img src="https://miro.medium.com/v2/resize:fit:2400/1*-VEGyAgcIBD62XtZWavy8w.png" alt="Milvus" height="40" style="margin: 10px;"></a>
<a href="https://xroute.ai/"><img src="../assets/partners/xroute.png" alt="xRoute" height="40" style="margin: 10px;"></a>
<a href="https://jaaz.app/"><img src="../assets/partners/jazz.png" alt="Jazz" height="40" style="margin: 10px;"></a>
<a href="https://github.com/Buddie-AI/Buddie"><img src="../assets/partners/buddie.png" alt="Buddie" height="40" style="margin: 10px;"></a>
<a href="https://github.com/bytebase/bytebase"><img src="../assets/partners/bytebase.png" alt="Bytebase" height="40" style="margin: 10px;"></a>
<a href="https://github.com/LazyAGI/LazyLLM"><img src="../assets/partners/LazyLLM.png" alt="LazyLLM" height="40" style="margin: 10px;"></a>
<a href="https://clawdchat.ai/"><img src="../assets/partners/Clawdchat.png" alt="Clawdchat" height="40" style="margin: 10px;"></a>
</div>
---
## 🤝 贡献
```bash
# Fork 并克隆
git clone https://github.com/YOUR_USERNAME/memU.git
cd memU
# 安装开发依赖
make install
# 提交前运行质量检查
make check
```
完整指南参见 [CONTRIBUTING.md](../CONTRIBUTING.md)。
**前置条件:** Python 3.13+、[uv](https://github.com/astral-sh/uv)、Git
---
## 📄 许可证
[Apache License 2.0](../LICENSE.txt)
---
## 🌍 社区
- **GitHub Issues**[报告 bug 与提交功能请求](https://github.com/NevaMind-AI/memU/issues)
- **Discord**[加入社区](https://discord.com/invite/hQZntfGsbJ)
- **X (Twitter)**[关注 @memU_ai](https://x.com/memU_ai)
- **联系**info@nevamind.ai
---
<div align="center">
**在 GitHub 上给我们点 Star**,第一时间获取新版本通知!
</div>
+2 -20
View File
@@ -1,37 +1,19 @@
from memu.app.service import MemoryService
from memu.app.settings import (
BlobConfig,
DatabaseConfig,
DefaultUserModel,
EmbeddingConfig,
EmbeddingProfilesConfig,
LLMConfig,
LLMProfilesConfig,
MemorizeConfig,
RetrieveConfig,
ProgressiveRetrieveConfig,
UserConfig,
)
from memu.workflow.runner import (
LocalWorkflowRunner,
WorkflowRunner,
register_workflow_runner,
resolve_workflow_runner,
)
__all__ = [
"BlobConfig",
"DatabaseConfig",
"DefaultUserModel",
"EmbeddingConfig",
"EmbeddingProfilesConfig",
"LLMConfig",
"LLMProfilesConfig",
"LocalWorkflowRunner",
"MemorizeConfig",
"MemoryService",
"RetrieveConfig",
"ProgressiveRetrieveConfig",
"UserConfig",
"WorkflowRunner",
"register_workflow_runner",
"resolve_workflow_runner",
]
+32 -47
View File
@@ -1,40 +1,35 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
from memu.blob.folder import infer_modality
from memu.vector import cosine_topk
if TYPE_CHECKING:
from memu.app.service import Context
from memu.app.settings import RetrieveWorkspaceConfig
from memu.app.settings import ProgressiveRetrieveConfig
from memu.database.interfaces import Database
from memu.database.models import RecallFile, Resource
class AgenticMixin:
if TYPE_CHECKING:
_get_context: Callable[[], Context]
_get_database: Callable[[], Database]
_normalize_where: Callable[[Mapping[str, Any] | None], dict[str, Any]]
_model_dump_without_embeddings: Callable[[BaseModel], dict[str, Any]]
_ensure_categories_ready: Callable[[Context, Database, Mapping[str, Any] | None], Awaitable[None]]
_get_embedding_client: Callable[..., Any]
retrieve_workspace_config: RetrieveWorkspaceConfig
progressive_retrieve_config: ProgressiveRetrieveConfig
user_model: type[BaseModel]
async def list_all_recall_files(
self,
where: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""List RecallFiles across every track without workflow orchestration.
"""List RecallFiles across every track.
Unlike :meth:`CRUDMixin.list_recall_files`, this does not force the
``track="memory"`` filter (ADR 0006), so skill-track files are included,
and it queries the repository directly instead of running a workflow.
No ``track`` filter is forced (ADR 0006), so skill-track files are
included alongside memory-track ones; the repository is queried directly.
"""
store = self._get_database()
where_filters = self._normalize_where(where)
@@ -49,10 +44,8 @@ class AgenticMixin:
) -> dict[str, Any]:
"""Single-shot, LLM-free retrieval over the segment/file/resource layers.
This mirrors :meth:`RetrieveWorkspaceMixin.retrieve_workspace` but runs no
workflow — the four stages are executed sequentially in-line, the same way
:meth:`commit_results` inverts ``memorize_workspace``. The query is embedded
once and used to rank two layers by vector similarity; no intention routing,
The stages run sequentially in-line: the query is embedded once and used
to rank two layers by vector similarity; no intention routing,
sufficiency checks, or summarization:
* ``segments``: :class:`RecallFileSegment` slices ranked by embedding,
@@ -69,7 +62,7 @@ class AgenticMixin:
raise ValueError("empty_query")
store = self._get_database()
where_filters = self._normalize_where(where)
config = self.retrieve_workspace_config
config = self.progressive_retrieve_config
embed_client = self._get_embedding_client("embedding")
query_vector = (await embed_client.embed([query]))[0]
@@ -102,22 +95,22 @@ class AgenticMixin:
) -> tuple[list[tuple[str, float]], dict[str, Any]]:
"""Rank :class:`RecallFileSegment` slices by embedding similarity.
Mirrors :meth:`RetrieveWorkspaceMixin._ws_recall_segments`. The segment repo
has no vector search, so the stored segment embeddings are ranked directly,
optionally scoped to the configured tracks via the denormalized ``track``.
The segment repo has no vector search, so the stored segment embeddings
are ranked directly, optionally scoped to the configured tracks via the
denormalized ``track``.
"""
if not enabled:
return [], {}
segment_where = dict(where_filters)
tracks = self.retrieve_workspace_config.file.tracks
tracks = self.progressive_retrieve_config.file.tracks
if tracks:
segment_where["track__in"] = list(tracks)
segment_pool = {seg.id: seg for seg in store.recall_file_segment_repo.list_segments(segment_where)}
segment_hits = cosine_topk(
query_vector,
[(sid, seg.embedding) for sid, seg in segment_pool.items()],
k=self.retrieve_workspace_config.file.top_k,
k=self.progressive_retrieve_config.file.top_k,
)
return segment_hits, segment_pool
@@ -131,9 +124,8 @@ class AgenticMixin:
) -> tuple[list[tuple[str, float]], dict[str, Any], dict[str, list[str]]]:
"""Roll the ranked segments up to their files (no ranked file search).
Mirrors :meth:`RetrieveWorkspaceMixin._ws_collect_files`. Every file pointed
to by a top segment is returned; a file's score is the max score across the
segments that point to it.
Every file pointed to by a top segment is returned; a file's score is the
max score across the segments that point to it.
"""
file_pool = store.recall_file_repo.list_categories(where_filters)
@@ -160,9 +152,9 @@ class AgenticMixin:
) -> dict[str, list[str]]:
"""Map each file id to the URLs of the resources linked to it.
Mirrors :meth:`RetrieveWorkspaceMixin._ws_collect_file_resource_urls`. Resolves
the ``RecallFileResource`` link table (file -> resource) and the resource
records (resource -> url) within the current scope, surfacing url strings only.
Resolves the ``RecallFileResource`` link table (file -> resource) and the
resource records (resource -> url) within the current scope, surfacing url
strings only.
"""
resources = store.resource_repo.list_resources(where_filters)
file_resource_urls: dict[str, list[str]] = {}
@@ -185,9 +177,8 @@ class AgenticMixin:
) -> tuple[list[tuple[str, float]], dict[str, Any]]:
"""Rank workspace-track resources by embedding similarity.
Mirrors :meth:`RetrieveWorkspaceMixin._ws_recall_resources`. Only resources
ingested by ``memorize_workspace`` (``track="workspace"``) are surfaced; other
tracks are excluded.
Only ``track="workspace"`` resources (the kind :meth:`commit_results`
writes) are surfaced; other tracks are excluded.
"""
if not enabled:
return [], {}
@@ -195,15 +186,12 @@ class AgenticMixin:
resource_where = {**where_filters, "track": "workspace"}
resource_pool = store.resource_repo.list_resources(resource_where)
resource_hits = store.resource_repo.vector_search_resources(
query_vector, self.retrieve_workspace_config.resource.top_k, where=resource_where
query_vector, self.progressive_retrieve_config.resource.top_k, where=resource_where
)
return resource_hits, resource_pool
def _materialize_hits(self, hits: Sequence[tuple[str, float]], pool: dict[str, Any]) -> list[dict[str, Any]]:
"""Expand ``(id, score)`` hits into scored, embedding-free dicts.
Mirrors :meth:`RetrieveWorkspaceMixin._materialize_hits`.
"""
"""Expand ``(id, score)`` hits into scored, embedding-free dicts."""
out = []
for _id, score in hits:
obj = pool.get(_id)
@@ -223,10 +211,9 @@ class AgenticMixin:
) -> dict[str, Any]:
"""Persist externally-prepared resources and recall files into the store.
This mirrors the persistence half of :meth:`MemorizeWorkspaceMixin.memorize_workspace`
but takes items that were already preprocessed/synthesized off-service (see
:mod:`memu.hosts.bridging.pipeline`), so it runs no ingest/preprocess/LLM steps and
no workflow — just create-or-update straight into storage:
Takes items that were already preprocessed/synthesized off-service (see
:mod:`memu.hosts.bridging.pipeline`), so it runs no ingest/preprocess/LLM
steps — just create-or-update straight into storage:
- ``resource`` — a list of ``{path, description}`` records. Each is a
:class:`Resource` keyed by ``url`` (``= path``); the description becomes the
@@ -262,8 +249,7 @@ class AgenticMixin:
``ResourceRepo`` has no in-place update, so an "update" is a delete-then-create:
any existing resource sharing this url (and its file provenance links) is dropped
before the fresh record is created. Mirrors
:meth:`MemorizeWorkspaceMixin._create_resource_with_caption`.
before the fresh record is created.
"""
where = user_scope or None
committed: list[Resource] = []
@@ -282,11 +268,13 @@ class AgenticMixin:
caption_embedding = (await embed_client.embed([caption]))[0] if caption else None
res = store.resource_repo.create_resource(
url=url,
modality=infer_modality(url) or "document",
local_path=url,
caption=caption,
embedding=caption_embedding,
user_data=dict(user_scope or {}),
# progressive_retrieve's resource layer filters on track="workspace";
# commit is now the only resource writer, so tag it accordingly.
track="workspace",
)
committed.append(res)
return committed
@@ -303,8 +291,7 @@ class AgenticMixin:
Keyed by ``name`` within the record's ``track`` (``memory``/``skill``). New files embed
their ``name: description`` for file-level recall; existing files keep their embedding
and only take the new content. Segments are then reconciled per track. Mirrors the
persist path of :meth:`MemorizeWorkspaceMixin._synthesize_file_ops`.
and only take the new content. Segments are then reconciled per track.
"""
user_data = dict(user_scope or {})
committed: list[RecallFile] = []
@@ -343,8 +330,6 @@ class AgenticMixin:
def _commit_segment_texts_for_file(file: RecallFile, file_track: str) -> list[str]:
"""Compute a file's searchable segment texts (ADR 0007 L2 items), track-specific.
Mirrors :meth:`MemorizeWorkspaceMixin._segment_texts_for_file`:
- ``skill``: a single ``name: ...\\ndescription: ...`` segment for the whole skill.
- ``memory``: one segment per content line, skipping blank lines and markdown
headings, de-duplicated while preserving order.
@@ -373,7 +358,7 @@ class AgenticMixin:
Drop-and-add on the difference only: segments whose text disappeared are deleted and
only genuinely new texts are embedded and inserted, so unchanged lines keep their
embedding. Mirrors :meth:`MemorizeWorkspaceMixin._sync_file_segments` (single file).
embedding.
"""
new_texts = self._commit_segment_texts_for_file(file, file_track)
existing = store.recall_file_segment_repo.list_segments_for_file(file.id)
-792
View File
@@ -1,792 +0,0 @@
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Awaitable, Callable, Mapping
from typing import TYPE_CHECKING, Any, cast, get_args
from pydantic import BaseModel
from memu.database.models import EntryType, RecallFile
from memu.prompts.category_patch import CATEGORY_PATCH_PROMPT
from memu.workflow.step import WorkflowState, WorkflowStep
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from memu.app.service import Context
from memu.app.settings import PatchConfig
from memu.database.interfaces import Database
class CRUDMixin:
if TYPE_CHECKING:
_run_workflow: Callable[..., Awaitable[WorkflowState]]
_get_context: Callable[[], Context]
_get_database: Callable[[], Database]
_get_step_llm_client: Callable[[Mapping[str, Any] | None], Any]
_get_step_embedding_client: Callable[[Mapping[str, Any] | None], Any]
_get_llm_client: Callable[..., Any]
_model_dump_without_embeddings: Callable[[BaseModel], dict[str, Any]]
_extract_json_blob: Callable[[str], str]
_escape_prompt_value: Callable[[str], str]
user_model: type[BaseModel]
patch_config: PatchConfig
_ensure_categories_ready: Callable[[Context, Database, Mapping[str, Any] | None], Awaitable[None]]
async def list_recall_entries(
self,
where: dict[str, Any] | None = None,
) -> dict[str, Any]:
ctx = self._get_context()
store = self._get_database()
where_filters = self._normalize_where(where)
state: WorkflowState = {
"ctx": ctx,
"store": store,
"where": where_filters,
}
result = await self._run_workflow("crud_list_recall_entries", state)
response = cast(dict[str, Any] | None, result.get("response"))
if response is None:
msg = "List memory items workflow failed to produce a response"
raise RuntimeError(msg)
return response
async def list_recall_files(
self,
where: dict[str, Any] | None = None,
) -> dict[str, Any]:
ctx = self._get_context()
store = self._get_database()
where_filters = self._normalize_where(where)
state: WorkflowState = {
"ctx": ctx,
"store": store,
"where": where_filters,
}
result = await self._run_workflow("crud_list_recall_files", state)
response = cast(dict[str, Any] | None, result.get("response"))
if response is None:
msg = "List memory categories workflow failed to produce a response"
raise RuntimeError(msg)
return response
async def clear_memory(
self,
where: dict[str, Any] | None = None,
) -> dict[str, Any]:
ctx = self._get_context()
store = self._get_database()
where_filters = self._normalize_where(where)
state: WorkflowState = {
"ctx": ctx,
"store": store,
"where": where_filters,
}
result = await self._run_workflow("crud_clear_memory", state)
response = cast(dict[str, Any] | None, result.get("response"))
if response is None:
msg = "Clear memory workflow failed to produce a response"
raise RuntimeError(msg)
return response
def _build_list_recall_entries_workflow(self) -> list[WorkflowStep]:
steps = [
WorkflowStep(
step_id="list_recall_entries",
role="read_memories",
handler=self._crud_list_recall_entries,
requires={"ctx", "store", "where"},
produces={"items"},
capabilities={"db"},
),
WorkflowStep(
step_id="build_response",
role="emit",
handler=self._crud_build_list_items_response,
requires={"items", "ctx", "store"},
produces={"response"},
capabilities=set(),
),
]
return steps
@staticmethod
def _list_list_memories_initial_keys() -> set[str]:
return {
"ctx",
"store",
"where",
}
def _build_list_recall_files_workflow(self) -> list[WorkflowStep]:
steps = [
WorkflowStep(
step_id="list_recall_files",
role="read_categories",
handler=self._crud_list_recall_files,
requires={"ctx", "store", "where"},
produces={"categories"},
capabilities={"db"},
),
WorkflowStep(
step_id="build_response",
role="emit",
handler=self._crud_build_list_categories_response,
requires={"categories", "ctx", "store"},
produces={"response"},
capabilities=set(),
),
]
return steps
def _build_clear_memory_workflow(self) -> list[WorkflowStep]:
steps = [
WorkflowStep(
step_id="clear_memory_relations",
role="delete_memories",
handler=self._crud_clear_memory_relations,
requires={"ctx", "store", "where"},
produces={"deleted_relations"},
capabilities={"db"},
),
WorkflowStep(
step_id="clear_recall_files",
role="delete_memories",
handler=self._crud_clear_recall_files,
requires={"ctx", "store", "where"},
produces={"deleted_categories"},
capabilities={"db"},
),
WorkflowStep(
step_id="clear_recall_entries",
role="delete_memories",
handler=self._crud_clear_recall_entries,
requires={"ctx", "store", "where"},
produces={"deleted_items"},
capabilities={"db"},
),
WorkflowStep(
step_id="clear_memory_resources",
role="delete_memories",
handler=self._crud_clear_memory_resources,
requires={"ctx", "store", "where"},
produces={"deleted_resources"},
capabilities={"db"},
),
WorkflowStep(
step_id="build_response",
role="emit",
handler=self._crud_build_clear_memory_response,
requires={
"ctx",
"store",
"deleted_relations",
"deleted_categories",
"deleted_items",
"deleted_resources",
},
produces={"response"},
capabilities=set(),
),
]
return steps
@staticmethod
def _list_clear_memories_initial_keys() -> set[str]:
return {
"ctx",
"store",
"where",
}
def _normalize_where(self, where: Mapping[str, Any] | None) -> dict[str, Any]:
"""Validate and clean the `where` scope filters against the configured user model."""
if not where:
return {}
valid_fields = set(getattr(self.user_model, "model_fields", {}).keys())
cleaned: dict[str, Any] = {}
for raw_key, value in where.items():
if value is None:
continue
field = raw_key.split("__", 1)[0]
if field not in valid_fields:
msg = f"Unknown filter field '{field}' for current user scope"
raise ValueError(msg)
cleaned[raw_key] = value
return cleaned
def _crud_list_recall_entries(self, state: WorkflowState, step_context: Any) -> WorkflowState:
where_filters = state.get("where") or {}
store = state["store"]
items = store.recall_entry_repo.list_items(where_filters)
state["items"] = items
return state
def _crud_list_recall_files(self, state: WorkflowState, step_context: Any) -> WorkflowState:
where_filters = state.get("where") or {}
store = state["store"]
# Lists memory-track files only; skill-track RecallFiles (ADR 0006) are excluded.
categories = store.recall_file_repo.list_categories({**where_filters, "track": "memory"})
state["categories"] = categories
return state
def _crud_build_list_items_response(self, state: WorkflowState, step_context: Any) -> WorkflowState:
items = state["items"]
items_list = [self._model_dump_without_embeddings(item) for item in items.values()]
response = {
"items": items_list,
}
state["response"] = response
return state
def _crud_build_list_categories_response(self, state: WorkflowState, step_context: Any) -> WorkflowState:
categories = state["categories"]
categories_list = [self._model_dump_without_embeddings(category) for category in categories.values()]
response = {
"categories": categories_list,
}
state["response"] = response
return state
def _crud_clear_memory_relations(self, state: WorkflowState, step_context: Any) -> WorkflowState:
where_filters = state.get("where") or {}
store = state["store"]
deleted = store.recall_file_entry_repo.clear_relations(where_filters)
state["deleted_relations"] = deleted
return state
def _crud_clear_recall_files(self, state: WorkflowState, step_context: Any) -> WorkflowState:
where_filters = state.get("where") or {}
store = state["store"]
# Segments hang off files (ADR 0007 L2); clear them alongside their categories.
store.recall_file_segment_repo.clear_segments(where_filters)
deleted = store.recall_file_repo.clear_categories(where_filters)
state["deleted_categories"] = deleted
return state
def _crud_clear_recall_entries(self, state: WorkflowState, step_context: Any) -> WorkflowState:
where_filters = state.get("where") or {}
store = state["store"]
deleted = store.recall_entry_repo.clear_items(where_filters)
state["deleted_items"] = deleted
return state
def _crud_clear_memory_resources(self, state: WorkflowState, step_context: Any) -> WorkflowState:
where_filters = state.get("where") or {}
store = state["store"]
deleted = store.resource_repo.clear_resources(where_filters)
state["deleted_resources"] = deleted
return state
def _crud_build_clear_memory_response(self, state: WorkflowState, step_context: Any) -> WorkflowState:
deleted_categories = state.get("deleted_categories", {})
deleted_items = state.get("deleted_items", {})
deleted_resources = state.get("deleted_resources", {})
deleted_relations = state.get("deleted_relations", [])
response = {
"deleted_categories": [self._model_dump_without_embeddings(cat) for cat in deleted_categories.values()],
"deleted_items": [self._model_dump_without_embeddings(item) for item in deleted_items.values()],
"deleted_resources": [self._model_dump_without_embeddings(res) for res in deleted_resources.values()],
"deleted_relations": [rel.model_dump() for rel in deleted_relations],
}
state["response"] = response
return state
async def create_recall_entry(
self,
*,
memory_type: EntryType,
memory_content: str,
recall_files: list[str],
user: dict[str, Any] | None = None,
propagate: bool = True,
) -> dict[str, Any]:
if memory_type not in get_args(EntryType):
msg = f"Invalid memory type: '{memory_type}', must be one of {get_args(EntryType)}"
raise ValueError(msg)
ctx = self._get_context()
store = self._get_database()
user_scope = self.user_model(**user).model_dump() if user is not None else None
await self._ensure_categories_ready(ctx, store, user_scope)
state: WorkflowState = {
"memory_payload": {
"type": memory_type,
"content": memory_content,
"categories": recall_files,
},
"ctx": ctx,
"store": store,
"category_ids": list(ctx.category_ids),
"user": user_scope,
"propagate": propagate,
}
result = await self._run_workflow("patch_create", state)
response = cast(dict[str, Any] | None, result.get("response"))
if response is None:
msg = "Create memory item workflow failed to produce a response"
raise RuntimeError(msg)
return response
async def update_recall_entry(
self,
*,
memory_id: str,
memory_type: EntryType | None = None,
memory_content: str | None = None,
recall_files: list[str] | None = None,
user: dict[str, Any] | None = None,
propagate: bool = True,
) -> dict[str, Any]:
if all((memory_type is None, memory_content is None, recall_files is None)):
msg = "At least one of memory type, memory content, or memory categories is required for UPDATE operation"
raise ValueError(msg)
if memory_type and memory_type not in get_args(EntryType):
msg = f"Invalid memory type: '{memory_type}', must be one of {get_args(EntryType)}"
raise ValueError(msg)
ctx = self._get_context()
store = self._get_database()
user_scope = self.user_model(**user).model_dump() if user is not None else None
await self._ensure_categories_ready(ctx, store, user_scope)
state: WorkflowState = {
"memory_id": memory_id,
"memory_payload": {
"type": memory_type,
"content": memory_content,
"categories": recall_files,
},
"ctx": ctx,
"store": store,
"category_ids": list(ctx.category_ids),
"user": user_scope,
"propagate": propagate,
}
result = await self._run_workflow("patch_update", state)
response = cast(dict[str, Any] | None, result.get("response"))
if response is None:
msg = "Update memory item workflow failed to produce a response"
raise RuntimeError(msg)
return response
async def delete_recall_entry(
self,
*,
memory_id: str,
user: dict[str, Any] | None = None,
propagate: bool = True,
) -> dict[str, Any]:
ctx = self._get_context()
store = self._get_database()
user_scope = self.user_model(**user).model_dump() if user is not None else None
await self._ensure_categories_ready(ctx, store, user_scope)
state: WorkflowState = {
"memory_id": memory_id,
"ctx": ctx,
"store": store,
"category_ids": list(ctx.category_ids),
"user": user_scope,
"propagate": propagate,
}
result = await self._run_workflow("patch_delete", state)
response = cast(dict[str, Any] | None, result.get("response"))
if response is None:
msg = "Delete memory item workflow failed to produce a response"
raise RuntimeError(msg)
return response
def _build_create_recall_entry_workflow(self) -> list[WorkflowStep]:
steps = [
WorkflowStep(
step_id="create_recall_entry",
role="patch",
handler=self._patch_create_recall_entry,
requires={"memory_payload", "ctx", "store", "user"},
produces={"recall_entry", "category_updates"},
capabilities={"db", "llm"},
config={"embed_llm_profile": "embedding"},
),
WorkflowStep(
step_id="persist_index",
role="persist",
handler=self._patch_persist_and_index,
requires={"category_updates", "ctx", "store"},
produces={"categories"},
capabilities={"db", "llm"},
config={"chat_llm_profile": "default"},
),
WorkflowStep(
step_id="build_response",
role="emit",
handler=self._patch_build_response,
requires={"recall_entry", "category_updates", "ctx", "store"},
produces={"response"},
capabilities=set(),
),
]
return steps
@staticmethod
def _list_create_recall_entry_initial_keys() -> set[str]:
return {
"memory_payload",
"ctx",
"store",
"user",
}
def _build_update_recall_entry_workflow(self) -> list[WorkflowStep]:
steps = [
WorkflowStep(
step_id="update_recall_entry",
role="patch",
handler=self._patch_update_recall_entry,
requires={"memory_id", "memory_payload", "ctx", "store", "user"},
produces={"recall_entry", "category_updates"},
capabilities={"db", "llm"},
config={"embed_llm_profile": "embedding"},
),
WorkflowStep(
step_id="persist_index",
role="persist",
handler=self._patch_persist_and_index,
requires={"category_updates", "ctx", "store"},
produces={"categories"},
capabilities={"db", "llm"},
config={"chat_llm_profile": "default"},
),
WorkflowStep(
step_id="build_response",
role="emit",
handler=self._patch_build_response,
requires={"recall_entry", "category_updates", "ctx", "store"},
produces={"response"},
capabilities=set(),
),
]
return steps
@staticmethod
def _list_update_recall_entry_initial_keys() -> set[str]:
return {
"memory_id",
"memory_payload",
"ctx",
"store",
"user",
}
def _build_delete_recall_entry_workflow(self) -> list[WorkflowStep]:
steps = [
WorkflowStep(
step_id="delete_recall_entry",
role="patch",
handler=self._patch_delete_recall_entry,
requires={"memory_id", "ctx", "store", "user"},
produces={"recall_entry", "category_updates"},
capabilities={"db"},
),
WorkflowStep(
step_id="persist_index",
role="persist",
handler=self._patch_persist_and_index,
requires={"category_updates", "ctx", "store"},
produces={"categories"},
capabilities={"db", "llm"},
config={"chat_llm_profile": "default"},
),
WorkflowStep(
step_id="build_response",
role="emit",
handler=self._patch_build_response,
requires={"recall_entry", "category_updates", "ctx", "store"},
produces={"response"},
capabilities=set(),
),
]
return steps
@staticmethod
def _list_delete_recall_entry_initial_keys() -> set[str]:
return {
"memory_id",
"ctx",
"store",
"user",
}
async def _patch_create_recall_entry(self, state: WorkflowState, step_context: Any) -> WorkflowState:
memory_payload = state["memory_payload"]
ctx = state["ctx"]
store = state["store"]
user = state["user"]
propagate = state["propagate"]
category_memory_updates: dict[str, tuple[Any, Any]] = {}
embed_payload = [memory_payload["content"]]
content_embedding = (await self._get_step_embedding_client(step_context).embed(embed_payload))[0]
item = store.recall_entry_repo.create_item(
memory_type=memory_payload["type"],
summary=memory_payload["content"],
embedding=content_embedding,
user_data=dict(user or {}),
)
cat_names = memory_payload["categories"]
mapped_cat_ids = self._map_category_names_to_ids(cat_names, ctx)
for cid in mapped_cat_ids:
store.recall_file_entry_repo.link_item_category(item.id, cid, user_data=dict(user or {}))
if propagate:
category_memory_updates[cid] = (None, memory_payload["content"])
state.update({
"recall_entry": item,
"category_updates": category_memory_updates,
})
return state
async def _patch_update_recall_entry(self, state: WorkflowState, step_context: Any) -> WorkflowState:
memory_id = state["memory_id"]
memory_payload = state["memory_payload"]
ctx = state["ctx"]
store = state["store"]
user = state["user"]
propagate = state["propagate"]
category_memory_updates: dict[str, tuple[Any, Any]] = {}
item = store.recall_entry_repo.get_item(memory_id)
if not item:
msg = f"Memory item with id {memory_id} not found"
raise ValueError(msg)
old_content = item.summary
old_item_categories = store.recall_file_entry_repo.get_item_categories(memory_id)
mapped_old_cat_ids = [cat.category_id for cat in old_item_categories]
if memory_payload["content"]:
embed_payload = [memory_payload["content"]]
content_embedding = (await self._get_step_embedding_client(step_context).embed(embed_payload))[0]
else:
content_embedding = None
if memory_payload["type"] or memory_payload["content"]:
item = store.recall_entry_repo.update_item(
item_id=memory_id,
memory_type=memory_payload["type"],
summary=memory_payload["content"],
embedding=content_embedding,
)
self._reconcile_update_categories(
memory_id=memory_id,
new_cat_names=memory_payload["categories"],
mapped_old_cat_ids=mapped_old_cat_ids,
content_changed=bool(memory_payload["content"]),
old_content=old_content,
new_summary=item.summary,
ctx=ctx,
store=store,
user=user,
propagate=propagate,
category_memory_updates=category_memory_updates,
)
state.update({
"recall_entry": item,
"category_updates": category_memory_updates,
})
return state
def _reconcile_update_categories(
self,
*,
memory_id: str,
new_cat_names: list[str] | None,
mapped_old_cat_ids: list[str],
content_changed: bool,
old_content: Any,
new_summary: Any,
ctx: Any,
store: Database,
user: dict[str, Any] | None,
propagate: bool,
category_memory_updates: dict[str, tuple[Any, Any]],
) -> None:
"""Sync an item's category links for an UPDATE.
``new_cat_names is None`` means the caller omitted categories, so existing
links are left untouched (an empty list, by contrast, clears all links).
"""
if new_cat_names is None:
if propagate and content_changed:
for cid in mapped_old_cat_ids:
category_memory_updates[cid] = (old_content, new_summary)
return
mapped_new_cat_ids = self._map_category_names_to_ids(new_cat_names, ctx)
old_set, new_set = set(mapped_old_cat_ids), set(mapped_new_cat_ids)
for cid in old_set - new_set:
store.recall_file_entry_repo.unlink_item_category(memory_id, cid)
if propagate:
category_memory_updates[cid] = (old_content, None)
for cid in new_set - old_set:
store.recall_file_entry_repo.link_item_category(memory_id, cid, user_data=dict(user or {}))
if propagate:
category_memory_updates[cid] = (None, new_summary)
if propagate and content_changed:
for cid in old_set & new_set:
category_memory_updates[cid] = (old_content, new_summary)
async def _patch_delete_recall_entry(self, state: WorkflowState, step_context: Any) -> WorkflowState:
memory_id = state["memory_id"]
store = state["store"]
propagate = state["propagate"]
category_memory_updates: dict[str, tuple[Any, Any]] = {}
item = store.recall_entry_repo.get_item(memory_id)
if not item:
msg = f"Memory item with id {memory_id} not found"
raise ValueError(msg)
item_categories = store.recall_file_entry_repo.get_item_categories(memory_id)
if propagate:
for cat in item_categories:
category_memory_updates[cat.category_id] = (item.summary, None)
# Remove the item's category relations first so deleting the item never
# leaves orphan edges pointing at a non-existent item.
store.recall_file_entry_repo.unlink_item(memory_id)
store.recall_entry_repo.delete_item(memory_id)
state.update({
"recall_entry": item,
"category_updates": category_memory_updates,
})
return state
async def _patch_persist_and_index(self, state: WorkflowState, step_context: Any) -> WorkflowState:
llm_client = self._get_step_llm_client(step_context)
await self._patch_category_summaries(
state.get("category_updates", {}),
ctx=state["ctx"],
store=state["store"],
llm_client=llm_client,
)
return state
def _patch_build_response(self, state: WorkflowState, step_context: Any) -> WorkflowState:
store = state["store"]
item = self._model_dump_without_embeddings(state["recall_entry"])
category_updates_ids = list(state.get("category_updates", {}).keys())
category_updates = [
self._model_dump_without_embeddings(store.recall_file_repo.categories[c]) for c in category_updates_ids
]
response = {
"recall_entry": item,
"category_updates": category_updates,
}
state["response"] = response
return state
def _map_category_names_to_ids(self, names: list[str], ctx: Context) -> list[str]:
if not names:
return []
mapped: list[str] = []
seen: set[str] = set()
for name in names:
key = name.strip().lower()
cid = ctx.category_name_to_id.get(key)
if cid and cid not in seen:
mapped.append(cid)
seen.add(cid)
return mapped
async def _patch_category_summaries(
self,
updates: dict[str, tuple[str | None, str | None]],
ctx: Context,
store: Database,
llm_client: Any | None = None,
) -> None:
if not updates:
return
tasks = []
target_ids: list[str] = []
client = llm_client or self._get_llm_client()
for cid, (content_before, content_after) in updates.items():
cat = store.recall_file_repo.categories.get(cid)
if not cat or (not content_before and not content_after):
continue
prompt = self._build_category_patch_prompt(
category=cat, content_before=content_before, content_after=content_after
)
tasks.append(client.chat(prompt))
target_ids.append(cid)
if not tasks:
return
patches = await asyncio.gather(*tasks)
for cid, patch in zip(target_ids, patches, strict=True):
need_update, summary = self._parse_category_patch_response(patch)
if not need_update:
continue
cat = store.recall_file_repo.categories.get(cid)
store.recall_file_repo.update_category(
category_id=cid,
content=summary.strip(),
)
def _build_category_patch_prompt(
self, *, category: RecallFile, content_before: str | None, content_after: str | None
) -> str:
if content_before and content_after:
update_content = "\n".join([
"The memory content before:",
content_before,
"The memory content after:",
content_after,
])
elif content_before:
update_content = "\n".join([
"This memory content is discarded:",
content_before,
])
elif content_after:
update_content = "\n".join([
"This memory content is newly added:",
content_after,
])
original_content = category.content or ""
prompt = CATEGORY_PATCH_PROMPT
return prompt.format(
category=self._escape_prompt_value(category.name),
original_content=self._escape_prompt_value(original_content or ""),
update_content=self._escape_prompt_value(update_content or ""),
)
def _parse_category_patch_response(self, response: str) -> tuple[bool, str]:
try:
data = json.loads(response)
except (json.JSONDecodeError, TypeError):
return False, ""
if not isinstance(data, dict):
return False, ""
if not data.get("updated_content"):
return False, ""
need_update = data.get("need_update", False)
updated_content = data["updated_content"].strip()
if updated_content == "empty":
updated_content = ""
return need_update, updated_content
File diff suppressed because it is too large Load Diff
-834
View File
@@ -1,834 +0,0 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import pathlib
from collections.abc import Awaitable, Callable, Mapping
from typing import TYPE_CHECKING, Any, ClassVar, cast
from pydantic import BaseModel
from memu.app.settings import CategoryConfig, CustomPrompt
from memu.blob.folder import diff_folder, load_manifest, manifest_from_scan, save_manifest, scan_folder
from memu.database.models import EntryType, RecallFile, Resource
from memu.preprocess import PreprocessContext, preprocess_resource
from memu.prompts.memory_fs import (
CONTENT_PLACEHOLDER,
DESCRIPTION_PLACEHOLDER,
EXISTING_PLACEHOLDER,
NAME_PLACEHOLDER,
ROUTE_PROMPTS,
SYNTHESIS_PROMPTS,
)
from memu.prompts.memory_type import DEFAULT_MEMORY_TYPES
from memu.workflow.step import WorkflowState, WorkflowStep
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from memu.app.service import Context
from memu.app.settings import MemorizeConfig, MemoryFilesConfig
from memu.blob.local_fs import LocalFS
from memu.database.interfaces import Database
class MemorizeWorkspaceMixin:
if TYPE_CHECKING:
memorize_config: MemorizeConfig
category_configs: list[CategoryConfig]
_category_prompt_str: str
fs: LocalFS
_run_workflow: Callable[..., Awaitable[WorkflowState]]
_get_context: Callable[[], Context]
_get_database: Callable[[], Database]
_get_step_llm_client: Callable[[Mapping[str, Any] | None], Any]
_get_step_embedding_client: Callable[[Mapping[str, Any] | None], Any]
_get_embedding_client: Callable[..., Any]
_get_llm_client: Callable[..., Any]
_get_vlm_client: Callable[..., Any]
_model_dump_without_embeddings: Callable[[BaseModel], dict[str, Any]]
_extract_json_blob: Callable[[str], str]
_escape_prompt_value: Callable[[str], str]
user_model: type[BaseModel]
# Memory file system export (provided by MemoryService).
memory_files_config: MemoryFilesConfig
_build_memory_files: Callable[..., Awaitable[dict[str, Any]]]
# Provided by CRUDMixin (composed onto MemoryService).
async def _patch_category_summaries(
self,
updates: dict[str, tuple[str | None, str | None]],
ctx: Context,
store: Database,
llm_client: Any | None = None,
) -> None: ...
async def memorize_workspace(
self,
*,
folder: str,
user: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Sync a folder of source files into memory by diffing an input manifest.
Scans ``folder`` recursively, infers each file's modality by extension
(unsupported extensions are skipped), and diffs against the sidecar
``.memu_manifest.json`` to find added/modified/deleted files. Modified and
deleted files have their previously extracted memory cascade-deleted (with
affected category summaries recomputed); added and modified files are
(re)memorized by submitting each one through the single-file
:meth:`memorize` workflow. The manifest is then rewritten.
``memorize`` itself is left untouched: this is purely an additive,
directory-oriented entry point built on top of it.
"""
ctx = self._get_context()
store = self._get_database()
user_scope = self.user_model(**user).model_dump() if user is not None else None
await self._ensure_categories_ready(ctx, store, user_scope)
root = pathlib.Path(folder).resolve()
scanned = scan_folder(root)
manifest = load_manifest(root)
diff = diff_folder(scanned, manifest)
# 1. Cascade-delete memory for files that were modified or removed.
stale_urls = {sf.abs_path for sf in diff.modified}
stale_urls.update(str(root / rel) for rel in diff.deleted)
removed_resources = await self._cascade_delete_by_urls(stale_urls, ctx=ctx, store=store, user_scope=user_scope)
# 2. (Re)memorize added and modified files; each file maps to one Resource.
changed_resources: list[Resource] = []
entries: list[dict[str, Any]] = []
files: list[dict[str, Any]] = []
for scanned_file in [*diff.added, *diff.modified]:
result = await self._memorize_one(
resource_url=scanned_file.abs_path,
modality=scanned_file.modality,
user_scope=user_scope,
ctx=ctx,
store=store,
track=self._classify_track(scanned_file.rel_path),
)
changed_resources.extend(cast("list[Resource]", result.get("resources") or []))
# The inner single-file ``memorize`` keeps its legacy response keys
# (``items``/``categories``); translate them to the new vocabulary here.
response = cast("dict[str, Any]", result.get("response") or {})
entries.extend(response.get("items", []))
# Files reflect the cumulative scoped state, so the latest wins.
if response.get("categories"):
files = response["categories"]
# 3. Refresh the memory file tree (full rebuild when anything was removed).
await self._update_memory_files(changed_resources, user_scope, force_full=diff.has_removals)
# 4. Persist the updated input manifest.
save_manifest(root, manifest_from_scan(scanned))
return {
"folder": str(root),
"added": [sf.rel_path for sf in diff.added],
"modified": [sf.rel_path for sf in diff.modified],
"deleted": list(diff.deleted),
"resources": [self._model_dump_without_embeddings(r) for r in changed_resources],
"removed_resources": [self._model_dump_without_embeddings(r) for r in removed_resources],
"entries": entries,
"files": files,
}
async def _memorize_one(
self,
*,
resource_url: str,
modality: str,
user_scope: dict[str, Any] | None,
ctx: Context,
store: Database,
track: str | None = None,
) -> WorkflowState:
"""Run the memorize workflow for a single file (one file -> one Resource).
This mirrors :meth:`memorize` but returns the full workflow state (so the
workspace sync can collect the created resources) and takes an already
resolved ``user_scope``/``ctx``/``store`` to avoid re-resolving them per file.
"""
memory_types = self._resolve_memory_types()
state: WorkflowState = {
"resource_url": resource_url,
"modality": modality,
"memory_types": memory_types,
"categories_prompt_str": self._category_prompt_str,
"ctx": ctx,
"store": store,
"category_ids": list(ctx.category_ids),
"user": user_scope,
# Workspace sync path: let the extractor grow the taxonomy.
"allow_new_categories": True,
# Which workspace track this file belongs to (chat/skill/workspace).
"resource_track": track,
}
# The workspace path runs its own workflow (memorize + per-file skill
# generation); single-file ``memorize`` stays untouched (ADR 0006).
result = await self._run_workflow("memorize_workspace", state)
if result.get("response") is None:
msg = "Memorize workflow failed to produce a response"
raise RuntimeError(msg)
return result
@staticmethod
def _classify_track(rel_path: str) -> str:
"""Classify a workspace file into a track by its top-level folder.
Files under ``chat/`` are the ``"chat"`` track, files under ``agent/`` are
the ``"skill"`` track, and everything else is the ``"workspace"`` track.
``rel_path`` is the posix path relative to the scanned folder root.
"""
top = rel_path.split("/", 1)[0]
if top == "chat":
return "chat"
if top == "agent":
return "skill"
return "workspace"
async def _cascade_delete_by_urls(
self,
urls: set[str],
*,
ctx: Context,
store: Database,
user_scope: dict[str, Any] | None,
) -> list[Resource]:
"""Delete resources (and their items/relations) whose url is in ``urls``.
Affected category summaries are recomputed so the structured memory stays
consistent after a source file is changed or removed.
"""
if not urls:
return []
where = user_scope or None
targets = [res for res in store.resource_repo.list_resources(where=where).values() if res.url in urls]
if not targets:
return []
target_ids = {res.id for res in targets}
# Discarded entry summaries per file, used to recompute summaries. Only the
# legacy entry-plane path (single-file ``memorize``) populates these.
file_discards: dict[str, list[str]] = {}
for entry in store.recall_entry_repo.list_items(where=where).values():
if entry.resource_id not in target_ids:
continue
for relation in store.recall_file_entry_repo.get_item_categories(entry.id):
store.recall_file_entry_repo.unlink_item_category(entry.id, relation.category_id)
file_discards.setdefault(relation.category_id, []).append(entry.summary)
store.recall_entry_repo.delete_item(entry.id)
for res in targets:
# Drop the resource -> file provenance links for the new synthesis path.
# NOTE (ADR 0007 phase 1 open issue): we do not rebuild the affected files
# from their remaining linked resources, so their content may go stale after
# a source change/delete. Tolerated for now.
store.recall_file_resource_repo.unlink_resource(res.id)
store.resource_repo.delete_resource(res.id)
updates: dict[str, tuple[str | None, str | None]] = {
cid: ("\n".join(s for s in summaries if s and s.strip()), None)
for cid, summaries in file_discards.items()
if any(s and s.strip() for s in summaries)
}
if updates:
await self._patch_category_summaries(updates, ctx=ctx, store=store, llm_client=self._get_llm_client())
return targets
async def _update_memory_files(
self,
changed_resources: list[Resource],
user_scope: dict[str, Any] | None,
*,
force_full: bool = False,
) -> None:
"""Refresh the memory file tree after a workspace sync (init or incremental).
Gated behind ``memory_files_config.enabled`` so a sync without the export
feature configured is a no-op. When any file was modified or deleted
(``force_full``), the tree is rebuilt from the full scoped store so stale
skills/entries do not linger; otherwise an incremental update merges the
just-created resources. Best-effort: the structured memory is already
persisted, so an export error must not fail the sync.
"""
if not getattr(self.memory_files_config, "enabled", False):
return
if not changed_resources and not force_full:
return
try:
await self._build_memory_files(user_scope, changed=None if force_full else changed_resources)
except Exception:
logger.exception("Memory file export failed after workspace memorize")
def _build_memorize_workspace_workflow(self) -> list[WorkflowStep]:
"""The workspace memorize pipeline: direct resource -> file synthesis (ADR 0007 phase 1).
Unlike single-file :meth:`memorize` (``resource -> entry -> file``), the
workspace path synthesizes files straight from the preprocessed source and
creates no ``RecallEntry``. After ``preprocess`` it:
- ``create_resource`` — one file maps to one :class:`Resource` (caption/embedding
for INDEX recall), for every track including ``workspace`` (resource-only).
- ``synthesize_files`` — for the ``chat`` and ``skill`` tracks only, route the
source to the files to update/create then synthesize each file's body, upserting
``RecallFile`` and recording ``resource -> file`` provenance. ``workspace`` is a
no-op here. Retrieval over these files is deferred (ADR 0007 phase 2).
"""
synthesis_profile = getattr(self.memory_files_config, "synthesis_llm_profile", "default")
return [
WorkflowStep(
step_id="ingest_resource",
role="ingest",
handler=self._memorize_ingest_resource,
requires={"resource_url", "modality"},
produces={"local_path", "raw_text"},
capabilities={"io"},
),
WorkflowStep(
step_id="preprocess_multimodal",
role="preprocess",
handler=self._memorize_preprocess_multimodal,
requires={"local_path", "modality", "raw_text"},
produces={"preprocessed_resources"},
capabilities={"llm"},
config={"chat_llm_profile": self.memorize_config.preprocess_llm_profile},
),
WorkflowStep(
step_id="create_resource",
role="persist",
handler=self._memorize_ws_create_resource,
requires={
"preprocessed_resources",
"modality",
"local_path",
"resource_url",
"store",
"user",
"resource_track",
},
produces={"resources"},
capabilities={"db", "vector"},
config={"embed_llm_profile": "embedding"},
),
WorkflowStep(
step_id="synthesize_files",
role="synthesize_files",
handler=self._memorize_ws_synthesize_files,
requires={"resources", "preprocessed_resources", "resource_track", "store", "user"},
produces={"files"},
capabilities={"llm", "db", "vector"},
config={"chat_llm_profile": synthesis_profile, "embed_llm_profile": "embedding"},
),
WorkflowStep(
step_id="build_response",
role="emit",
handler=self._memorize_ws_build_response,
requires={"resources", "files"},
produces={"response"},
capabilities=set(),
),
]
@staticmethod
def _list_memorize_initial_keys() -> set[str]:
return {
"resource_url",
"modality",
"memory_types",
"categories_prompt_str",
"ctx",
"store",
"category_ids",
"user",
"allow_new_categories",
"resource_track",
}
async def _memorize_ingest_resource(self, state: WorkflowState, step_context: Any) -> WorkflowState:
local_path, raw_text = await self.fs.fetch(state["resource_url"], state["modality"])
state.update({"local_path": local_path, "raw_text": raw_text})
return state
# Modalities whose preprocessing analyzes media via the VLM (vision) client.
_VISION_MODALITIES = frozenset({"image", "video"})
async def _memorize_preprocess_multimodal(self, state: WorkflowState, step_context: Any) -> WorkflowState:
modality = state["modality"]
client = self._get_step_llm_client(step_context)
if modality in self._VISION_MODALITIES:
with contextlib.suppress(KeyError):
client = self._get_vlm_client(self.memorize_config.vlm_profile, step_context=step_context)
preprocessed = await self._preprocess_resource_url(
local_path=state["local_path"],
text=state.get("raw_text"),
modality=modality,
llm_client=client,
)
if not preprocessed:
preprocessed = [{"text": state.get("raw_text"), "caption": None}]
state["preprocessed_resources"] = preprocessed
return state
@staticmethod
def _format_skill_source_content(preprocessed_resources: list[dict[str, Any]]) -> str:
"""Flatten a source's preprocessed segments into a single text block."""
parts = [
" ".join((prep.get("text") or "").split())
for prep in preprocessed_resources
if (prep.get("text") or "").strip()
]
return "\n\n".join(parts)
# --- Workspace resource -> file path (ADR 0007 phase 1) -------------------
# Maps a workspace ``resource_track`` to the ``RecallFile.track`` it synthesizes
# into. ``workspace`` has no entry (resource-only), so it is absent.
_TRACK_TO_FILE_TRACK: ClassVar[dict[str, str]] = {"chat": "memory", "skill": "skill"}
async def _memorize_ws_create_resource(self, state: WorkflowState, step_context: Any) -> WorkflowState:
"""Create the single ``Resource`` for this file (one file -> one resource).
Runs for every track; the ``workspace`` track stops here (resource-only). The
caption is the joined per-segment captions, embedded for INDEX/resource recall.
"""
embed_client = self._get_step_embedding_client(step_context)
store = state["store"]
preprocessed = state.get("preprocessed_resources") or []
captions = [(prep.get("caption") or "").strip() for prep in preprocessed]
caption = "\n\n".join(c for c in captions if c) or None
res = await self._create_resource_with_caption(
resource_url=state["resource_url"],
modality=state["modality"],
local_path=state["local_path"],
caption=caption,
store=store,
embed_client=embed_client,
user=state.get("user", {}),
track=state.get("resource_track"),
)
state["resources"] = [res]
return state
async def _memorize_ws_synthesize_files(self, state: WorkflowState, step_context: Any) -> WorkflowState:
"""Synthesize this source into ``RecallFile``s for the chat/skill tracks.
Two steps: (a) route the source to the set of files to update/create given the
existing files' names+descriptions, and (b) synthesize each target file's body in
parallel. Persists each file and a ``resource -> file`` provenance link. The
``workspace`` track (and any source with no content) is a no-op.
"""
track = state.get("resource_track")
file_track = self._TRACK_TO_FILE_TRACK.get(track or "")
resources = state.get("resources") or []
content = self._format_skill_source_content(state.get("preprocessed_resources") or [])
if file_track is None or not resources or not content:
state["files"] = []
return state
store = state["store"]
user_scope = dict(state.get("user") or {})
llm_client = self._get_step_llm_client(step_context)
embed_client = self._get_step_embedding_client(step_context)
resource = resources[0]
existing = store.recall_file_repo.list_categories(where={**user_scope, "track": file_track})
ops = await self._route_source_to_files(
file_track=file_track, content=content, existing=existing, llm_client=llm_client
)
touched = await self._synthesize_file_ops(
ops=ops,
file_track=file_track,
content=content,
existing=existing,
resource=resource,
store=store,
user_scope=user_scope,
llm_client=llm_client,
embed_client=embed_client,
)
await self._sync_file_segments(
files=touched,
file_track=file_track,
store=store,
user_scope=user_scope,
embed_client=embed_client,
)
state["files"] = touched
return state
@staticmethod
def _segment_texts_for_file(file: RecallFile, file_track: str) -> list[str]:
"""Compute the searchable segment texts for a synthesized file (ADR 0007 L2 items).
The slicing rule is track-specific:
- ``skill``: a single ``name: ...\\ndescription: ...`` segment for the whole skill.
- ``memory``: one segment per content line, skipping blank lines and markdown
headings (lines starting with one or more ``#``).
Texts are stripped and de-duplicated while preserving order so a repeated line is
embedded only once.
"""
if file_track == "skill":
return [f"name: {file.name}\ndescription: {file.description}"]
texts: list[str] = []
for line in (file.content or "").split("\n"):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
texts.append(stripped)
return list(dict.fromkeys(texts))
async def _sync_file_segments(
self,
*,
files: list[RecallFile],
file_track: str,
store: Database,
user_scope: dict[str, Any],
embed_client: Any,
) -> None:
"""Reconcile each file's stored segments with its freshly computed segment texts.
Diffs the new segment texts against the existing ones and does a drop-and-add on the
difference only: segments whose text disappeared are deleted, and only genuinely new
texts are embedded and inserted. Unchanged lines keep their existing embedding, so an
edit that touches a few lines does not re-embed the whole file.
"""
for file in files:
new_texts = self._segment_texts_for_file(file, file_track)
existing = store.recall_file_segment_repo.list_segments_for_file(file.id)
existing_texts = {seg.text for seg in existing}
new_set = set(new_texts)
for seg in existing:
if seg.text not in new_set:
store.recall_file_segment_repo.delete_segment(seg.id)
to_add = [text for text in new_texts if text not in existing_texts]
if not to_add:
continue
vecs = await embed_client.embed(to_add)
for text, vec in zip(to_add, vecs, strict=True):
store.recall_file_segment_repo.create_segment(
recall_file_id=file.id, track=file_track, text=text, embedding=vec, user_data=dict(user_scope)
)
async def _route_source_to_files(
self,
*,
file_track: str,
content: str,
existing: Mapping[str, RecallFile],
llm_client: Any,
) -> list[dict[str, str]]:
"""Ask the model which existing files to update / what new files to create."""
existing_text = self._format_existing_files(existing) or "(none)"
prompt = (
ROUTE_PROMPTS[file_track]
.replace(EXISTING_PLACEHOLDER, existing_text)
.replace(CONTENT_PLACEHOLDER, self._escape_prompt_value(content))
)
return self._parse_file_ops(await llm_client.chat(prompt), existing)
async def _synthesize_file_ops(
self,
*,
ops: list[dict[str, str]],
file_track: str,
content: str,
existing: Mapping[str, RecallFile],
resource: Resource,
store: Database,
user_scope: dict[str, Any],
llm_client: Any,
embed_client: Any,
) -> list[RecallFile]:
"""Synthesize each routed file's body (in parallel) and persist file + link."""
existing_by_name = {f.name: f for f in existing.values()}
# Resolve ops to unique targets (dedup by name; last op's description wins).
targets: list[dict[str, Any]] = []
by_name: dict[str, dict[str, Any]] = {}
for op in ops:
name = op["name"]
ex = existing_by_name.get(name)
description = (op.get("description") or (ex.description if ex else "") or "").strip()
target = by_name.get(name)
if target is None:
target = {"name": name, "description": description, "existing": ex}
by_name[name] = target
targets.append(target)
elif description:
target["description"] = description
if not targets:
return []
prompts = [
SYNTHESIS_PROMPTS[file_track]
.replace(NAME_PLACEHOLDER, self._escape_prompt_value(t["name"]))
.replace(DESCRIPTION_PLACEHOLDER, self._escape_prompt_value(t["description"]))
.replace(
EXISTING_PLACEHOLDER, self._escape_prompt_value((t["existing"].content if t["existing"] else "") or "")
)
.replace(CONTENT_PLACEHOLDER, self._escape_prompt_value(content))
for t in targets
]
bodies = await asyncio.gather(*[llm_client.chat(prompt) for prompt in prompts])
# Embed name+description for the files being created.
creates = [t for t in targets if t["existing"] is None]
create_vecs: dict[str, list[float]] = {}
if creates:
emb_texts = [f"{t['name']}: {t['description']}" if t["description"] else t["name"] for t in creates]
vecs = await embed_client.embed(emb_texts)
for t, vec in zip(creates, vecs, strict=True):
create_vecs[t["name"]] = vec
touched: list[RecallFile] = []
for target, body in zip(targets, bodies, strict=True):
cleaned = body.replace("```markdown", "").replace("```", "").strip()
file = target["existing"]
if file is None:
file = store.recall_file_repo.get_or_create_category(
name=target["name"],
description=target["description"],
embedding=create_vecs[target["name"]],
user_data=user_scope,
track=file_track,
)
file = store.recall_file_repo.update_category(category_id=file.id, content=cleaned)
store.recall_file_resource_repo.link_resource_category(resource.id, file.id, user_data=dict(user_scope))
touched.append(file)
return touched
@staticmethod
def _format_existing_files(existing: Mapping[str, RecallFile]) -> str:
"""Render existing files as ``- name: description`` lines for the router prompt."""
return "\n".join(
f"- {f.name}: {f.description}" if f.description else f"- {f.name}"
for f in sorted(existing.values(), key=lambda f: f.name)
)
def _parse_file_ops(self, raw: str, existing: Mapping[str, RecallFile]) -> list[dict[str, str]]:
"""Parse the router's JSON array into validated ``{op, name, description}`` dicts.
``update`` ops naming an unknown file are dropped (we never update a file that
does not exist); ``create``/``update`` are otherwise kept with a stripped name.
"""
if not raw:
return []
start = raw.find("[")
end = raw.rfind("]")
if start == -1 or end == -1 or end <= start:
return []
try:
parsed = json.loads(raw[start : end + 1])
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(parsed, list):
return []
existing_names = {f.name for f in existing.values()}
ops: list[dict[str, str]] = []
for entry in parsed:
if not isinstance(entry, dict):
continue
op = entry.get("op")
name = entry.get("name")
if op not in {"update", "create"} or not isinstance(name, str) or not name.strip():
continue
name = name.strip()
if op == "update" and name not in existing_names:
continue
description = entry.get("description")
description = description.strip() if isinstance(description, str) else ""
ops.append({"op": op, "name": name, "description": description})
return ops
def _memorize_ws_build_response(self, state: WorkflowState, step_context: Any) -> WorkflowState:
"""Emit the workspace response (no entries; ``categories`` carries touched files)."""
resources = [self._model_dump_without_embeddings(r) for r in state.get("resources", [])]
files = [self._model_dump_without_embeddings(f) for f in state.get("files", [])]
# Keep the legacy response contract (``items``/``categories``); items is always
# empty on this path since the entry plane is gone.
base: dict[str, Any] = {"items": [], "categories": files, "relations": []}
if len(resources) == 1:
state["response"] = {"resource": resources[0], **base}
else:
state["response"] = {"resources": resources, **base}
return state
async def _create_resource_with_caption(
self,
*,
resource_url: str,
modality: str,
local_path: str,
caption: str | None,
store: Database,
embed_client: Any | None = None,
user: Mapping[str, Any] | None = None,
track: str | None = None,
) -> Resource:
caption_text = caption.strip() if caption else None
if caption_text:
client = embed_client or self._get_embedding_client()
caption_embedding = (await client.embed([caption_text]))[0]
else:
caption_embedding = None
res = store.resource_repo.create_resource(
url=resource_url,
modality=modality,
local_path=local_path,
caption=caption_text,
embedding=caption_embedding,
user_data=dict(user or {}),
track=track,
)
return res
def _resolve_memory_types(self) -> list[EntryType]:
configured_types = self.memorize_config.memory_types or DEFAULT_MEMORY_TYPES
return [cast(EntryType, mtype) for mtype in configured_types]
@staticmethod
def _resolve_custom_prompt(prompt: str | CustomPrompt, templates: Mapping[str, str]) -> str:
if isinstance(prompt, str):
return prompt
valid_blocks = [
(block.ordinal, name, block.prompt or templates.get(name))
for name, block in prompt.items()
if (block.ordinal >= 0 and (block.prompt or templates.get(name)))
]
if not valid_blocks:
# raise ValueError(f"No valid blocks contained in custom prompt: {prompt}")
return ""
sorted_blocks = sorted(valid_blocks)
return "\n\n".join(block for (_, _, block) in sorted_blocks if block is not None)
async def _preprocess_resource_url(
self, *, local_path: str, text: str | None, modality: str, llm_client: Any | None = None
) -> list[dict[str, str | None]]:
"""Preprocess a resource by delegating to the per-format ``preprocess`` package.
Returns a list of preprocessed resources, each with 'text' and 'caption'.
"""
return await preprocess_resource(
modality=modality,
local_path=local_path,
text=text,
ctx=self._build_preprocess_context(),
llm_client=llm_client,
)
def _build_preprocess_context(self) -> PreprocessContext:
"""Bundle the service dependencies the preprocessors need."""
return PreprocessContext(
get_llm_client=self._get_llm_client,
get_vlm_client=lambda: self._get_vlm_client(self.memorize_config.vlm_profile),
escape_prompt_value=self._escape_prompt_value,
extract_json_blob=self._extract_json_blob,
resolve_custom_prompt=self._resolve_custom_prompt,
multimodal_preprocess_prompts=self.memorize_config.multimodal_preprocess_prompts,
)
async def _ensure_categories_ready(
self, ctx: Context, store: Database, user_scope: Mapping[str, Any] | None = None
) -> None:
if ctx.categories_ready:
return
if ctx.category_init_task:
await ctx.category_init_task
ctx.category_init_task = None
return
await self._initialize_categories(ctx, store, user_scope)
@staticmethod
def _classify_categories(
configs: list[CategoryConfig],
existing_by_name: dict[str, RecallFile],
) -> tuple[
list[tuple[int, CategoryConfig]],
list[tuple[int, CategoryConfig, RecallFile]],
dict[int, RecallFile],
]:
to_create: list[tuple[int, CategoryConfig]] = []
to_update: list[tuple[int, CategoryConfig, RecallFile]] = []
ready: dict[int, RecallFile] = {}
for i, cfg in enumerate(configs):
name = cfg.name.strip() or "Untitled"
description = cfg.description.strip()
ex = existing_by_name.get(name)
if ex is None:
to_create.append((i, cfg))
elif ex.embedding is None or (ex.description or "") != description:
to_update.append((i, cfg, ex))
else:
ready[i] = ex
return to_create, to_update, ready
async def _initialize_categories(
self, ctx: Context, store: Database, user: Mapping[str, Any] | None = None
) -> None:
if ctx.categories_ready:
return
if not self.category_configs:
ctx.categories_ready = True
return
user_data = dict(user or {})
existing = store.recall_file_repo.list_categories(where={**user_data, "track": "memory"})
existing_by_name: dict[str, RecallFile] = {c.name: c for c in existing.values()}
to_create, to_update, ready = self._classify_categories(self.category_configs, existing_by_name)
needs_embed: list[tuple[int, CategoryConfig]] = []
needs_embed.extend(to_create)
needs_embed.extend((i, cfg) for i, cfg, _ in to_update)
embed_map: dict[int, list[float]] = {}
if needs_embed:
texts = [self._category_embedding_text(cfg) for _, cfg in needs_embed]
vecs = await self._get_embedding_client("embedding").embed(texts)
for (i, _), vec in zip(needs_embed, vecs, strict=True):
embed_map[i] = vec
cats: dict[int, RecallFile] = dict(ready)
for i, cfg in to_create:
name = cfg.name.strip() or "Untitled"
description = cfg.description.strip()
cat = store.recall_file_repo.get_or_create_category(
name=name, description=description, embedding=embed_map[i], user_data=user_data
)
cats[i] = cat
for i, cfg, ex in to_update:
description = cfg.description.strip()
cat = store.recall_file_repo.update_category(
category_id=ex.id, description=description, embedding=embed_map[i]
)
cats[i] = cat
ctx.category_ids = []
ctx.category_name_to_id = {}
for i in range(len(self.category_configs)):
cat = cats[i]
ctx.category_ids.append(cat.id)
name = self.category_configs[i].name.strip() or "Untitled"
ctx.category_name_to_id[name.lower()] = cat.id
ctx.categories_ready = True
@staticmethod
def _category_embedding_text(cat: CategoryConfig) -> str:
name = cat.name.strip() or "Untitled"
desc = cat.description.strip()
return f"{name}: {desc}" if desc else name
-101
View File
@@ -1,101 +0,0 @@
"""Memory file-system build orchestration, extracted from ``MemoryService``.
This collaborator owns the markdown "memory file system" artifact layer: the
exporter, the LLM synthesizer, and the write lock. ``MemoryService`` keeps a
thin ``_build_memory_files`` delegate so this stays an internal composition
detail, but the init-vs-incremental synthesis policy lives here rather than
bloating the service composition root.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any
from memu.memory_fs import ExistingArtifacts, ExportResult, MemoryFileExporter, MemorySynthesizer
if TYPE_CHECKING:
from collections.abc import Callable
from memu.app.settings import MemoryFilesConfig
from memu.database.interfaces import Database
class MemoryFilesBuilder:
"""Initialize or incrementally update the markdown memory file tree.
Holds the exporter/synthesizer/lock so the build policy is testable and
decoupled from the rest of the service. Writes are serialized through a
single lock so concurrent exports never interleave on the output directory.
"""
def __init__(self, *, config: MemoryFilesConfig) -> None:
self.config = config
self.exporter = MemoryFileExporter(config.output_dir)
self.synthesizer = MemorySynthesizer()
self.lock = asyncio.Lock()
async def build(
self,
database: Database,
where: dict[str, Any] | None,
*,
changed: list[Any] | None,
make_client: Callable[[str], Any],
) -> dict[str, Any]:
"""Initialize or incrementally update the memory file tree.
``changed`` is the list of just-memorized ``Resource`` objects driving an
incremental update. When it is ``None`` (or no prior tree exists), the
tree is (re)initialized from the full scoped store. ``make_client`` builds
the LLM client used for synthesis from a profile name.
LLM work only happens when ``synthesize=True``: the root overview documents
MEMORY.md and SKILL.md are synthesized (MEMORY.md from the per-source
descriptions, SKILL.md from the skill-track files' bodies). Otherwise both are
left as ``None`` so the exporter renders each deterministically as a link index
over its per-file payloads. The ``memory/`` and ``skill/`` trees themselves are
always rendered deterministically from the DB (ADR 0006).
"""
memory_body: str | None = None
skill_body: str | None = None
if self.config.synthesize:
# MEMORY.md trunk: the just-changed sources for an incremental update,
# otherwise the full in-scope store. SKILL.md is synthesized from the full
# skill set regardless (the workflow already merges skills per source).
is_update = changed is not None and self.exporter.artifacts_exist()
if is_update:
descriptions = MemoryFileExporter._build_descriptions(changed) # type: ignore[arg-type]
else:
resources = list(database.resource_repo.list_resources(where=where or None).values())
descriptions = MemoryFileExporter._build_descriptions(resources)
skill_files = list(
database.recall_file_repo.list_categories(where={**(where or {}), "track": "skill"}).values()
)
# An incremental update merges into the prior overviews; a full
# (re)initialization starts from empty existing state.
existing = await asyncio.to_thread(self.exporter.read_existing) if is_update else ExistingArtifacts()
client = make_client(self.config.synthesis_llm_profile)
memory_body, skill_body = await asyncio.gather(
self.synthesizer.synthesize_memory(
descriptions, existing_memory=existing.memory_body, chat=client.chat
),
self.synthesizer.synthesize_skill_overview(
skill_files, existing_skill=existing.skill_body, chat=client.chat
),
)
async with self.lock:
result: ExportResult = await asyncio.to_thread(
self.exporter.export,
database,
where=where,
memory_body=memory_body,
skill_body=skill_body,
)
return result.to_dict()
__all__ = ["MemoryFilesBuilder"]
File diff suppressed because it is too large Load Diff
-276
View File
@@ -1,276 +0,0 @@
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, cast
from pydantic import BaseModel
from memu.vector import cosine_topk
from memu.workflow.step import WorkflowState, WorkflowStep
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from memu.app.settings import RetrieveWorkspaceConfig
from memu.database.interfaces import Database
class RetrieveWorkspaceMixin:
if TYPE_CHECKING:
retrieve_workspace_config: RetrieveWorkspaceConfig
_run_workflow: Callable[..., Awaitable[WorkflowState]]
_get_database: Callable[[], Database]
_get_step_embedding_client: Callable[[Mapping[str, Any] | None], Any]
_model_dump_without_embeddings: Callable[[BaseModel], dict[str, Any]]
user_model: type[BaseModel]
async def retrieve_workspace(
self,
query: str,
where: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Single-shot, LLM-free retrieval over the segment/file/resource layers.
Mirrors the relation between :meth:`memorize` and ``memorize_workspace``:
a simpler entry point built on the same store and workflow machinery. The
query is embedded once and used to rank two layers by vector similarity —
no intention routing, sufficiency checks, or summarization:
* ``segments``: :class:`RecallFileSegment` slices ranked by embedding,
``file.top_k`` of them.
* ``files``: the :class:`RecallFile`\\ s pointed to by those segments — not
a ranked search, just a roll-up. Each file's score is the max score of
the segments that point to it.
* ``resources``: workspace-track resources ranked by embedding,
``resource.top_k`` of them.
The entry layer is disabled here (its config is retained but ignored).
Returns ``segments``, ``files``, and ``resources``.
"""
if not query or not query.strip():
raise ValueError("empty_query")
store = self._get_database()
where_filters = self._normalize_where(where)
config = self.retrieve_workspace_config
state: WorkflowState = {
"query": query,
"store": store,
"where": where_filters,
"retrieve_file": config.file.enabled,
"retrieve_resource": config.resource.enabled,
}
result = await self._run_workflow("retrieve_workspace", state)
response = cast(dict[str, Any] | None, result.get("response"))
if response is None:
msg = "Retrieve workspace workflow failed to produce a response"
raise RuntimeError(msg)
return response
def _normalize_where(self, where: Mapping[str, Any] | None) -> dict[str, Any]:
"""Validate and clean the `where` scope filters against the configured user model."""
if not where:
return {}
valid_fields = set(getattr(self.user_model, "model_fields", {}).keys())
cleaned: dict[str, Any] = {}
for raw_key, value in where.items():
if value is None:
continue
field = raw_key.split("__", 1)[0]
if field not in valid_fields:
msg = f"Unknown filter field '{field}' for current user scope"
raise ValueError(msg)
cleaned[raw_key] = value
return cleaned
def _build_retrieve_workspace_workflow(self) -> list[WorkflowStep]:
"""The simple embedding-only workspace retrieval pipeline.
A segment recall step ranks :class:`RecallFileSegment` slices by embedding;
a file roll-up step gathers the files those segments point to; a resource
recall step ranks workspace-track resources by embedding. A terminal step
assembles the response. None of the routing/sufficiency machinery of
``retrieve_rag`` applies. The query vector is embedded by the first recall
step and reused downstream.
"""
steps = [
WorkflowStep(
step_id="recall_segments",
role="recall_segments",
handler=self._ws_recall_segments,
requires={"retrieve_file", "query", "store", "where"},
produces={"segment_hits", "segment_pool", "query_vector"},
capabilities={"vector"},
config={"embed_llm_profile": "embedding"},
),
WorkflowStep(
step_id="collect_files",
role="collect_files",
handler=self._ws_collect_files,
requires={"retrieve_file", "segment_hits", "segment_pool", "store", "where"},
produces={"file_hits", "file_pool", "file_resource_urls"},
capabilities=set(),
),
WorkflowStep(
step_id="recall_resources",
role="recall_resources",
handler=self._ws_recall_resources,
requires={"retrieve_resource", "query", "store", "where", "query_vector"},
produces={"resource_hits", "resource_pool", "query_vector"},
capabilities={"vector"},
config={"embed_llm_profile": "embedding"},
),
WorkflowStep(
step_id="build_response",
role="build_context",
handler=self._ws_build_response,
requires={
"segment_hits",
"segment_pool",
"file_hits",
"file_pool",
"file_resource_urls",
"resource_hits",
"resource_pool",
},
produces={"response"},
capabilities=set(),
),
]
return steps
@staticmethod
def _list_retrieve_workspace_initial_keys() -> set[str]:
return {"query", "store", "where", "retrieve_file", "retrieve_resource"}
async def _ws_query_vector(self, state: WorkflowState, step_context: Any) -> list[float]:
"""Embed the query once and cache it on the state for reuse across steps."""
cached = state.get("query_vector")
if cached is not None:
return cast(list[float], cached)
embed_client = self._get_step_embedding_client(step_context)
qvec = (await embed_client.embed([state["query"]]))[0]
state["query_vector"] = qvec
return cast(list[float], qvec)
async def _ws_recall_segments(self, state: WorkflowState, step_context: Any) -> WorkflowState:
if not state.get("retrieve_file"):
state["segment_hits"] = []
state["segment_pool"] = {}
state.setdefault("query_vector", None)
return state
store = state["store"]
# The segment repo has no vector search, so rank the stored segment
# embeddings directly, mirroring how files used to be ranked. Optionally
# scope to the requested tracks via the denormalized segment ``track``.
segment_where = dict(state.get("where") or {})
tracks = self.retrieve_workspace_config.file.tracks
if tracks:
segment_where["track__in"] = list(tracks)
segment_pool = {seg.id: seg for seg in store.recall_file_segment_repo.list_segments(segment_where)}
qvec = await self._ws_query_vector(state, step_context)
state["segment_hits"] = cosine_topk(
qvec,
[(sid, seg.embedding) for sid, seg in segment_pool.items()],
k=self.retrieve_workspace_config.file.top_k,
)
state["segment_pool"] = segment_pool
return state
async def _ws_collect_files(self, state: WorkflowState, _: Any) -> WorkflowState:
"""Roll the ranked segments up to their files (no ranked file search).
Every file pointed to by a top segment is returned; a file's score is the
max score across the segments that point to it.
"""
segment_hits = state.get("segment_hits") or []
segment_pool = state.get("segment_pool") or {}
store = state["store"]
where_filters = state.get("where") or {}
file_pool = store.recall_file_repo.list_categories(where_filters)
file_scores: dict[str, float] = {}
for seg_id, score in segment_hits:
seg = segment_pool.get(seg_id)
if seg is None:
continue
fid = seg.recall_file_id
if fid not in file_pool:
continue
score = float(score)
if fid not in file_scores or score > file_scores[fid]:
file_scores[fid] = score
# Preserve descending-score order so the response reads best-first.
state["file_hits"] = sorted(file_scores.items(), key=lambda kv: kv[1], reverse=True)
state["file_pool"] = file_pool
state["file_resource_urls"] = self._ws_collect_file_resource_urls(store, where_filters, file_pool)
return state
@staticmethod
def _ws_collect_file_resource_urls(
store: Database, where_filters: dict[str, Any], file_pool: dict[str, Any]
) -> dict[str, list[str]]:
"""Map each file id to the URLs of the resources linked to it.
Resolves the ``RecallFileResource`` link table (file -> resource) and the
resource records (resource -> url) within the current scope, surfacing url
strings only — the raw resource/link ids are not exposed to callers.
"""
resources = store.resource_repo.list_resources(where_filters)
file_resource_urls: dict[str, list[str]] = {}
for rel in store.recall_file_resource_repo.list_relations(where_filters):
if rel.file_id not in file_pool:
continue
resource = resources.get(rel.resource_id)
if resource is None:
continue
file_resource_urls.setdefault(rel.file_id, []).append(resource.url)
return file_resource_urls
async def _ws_recall_resources(self, state: WorkflowState, step_context: Any) -> WorkflowState:
if not state.get("retrieve_resource"):
state["resource_hits"] = []
state["resource_pool"] = {}
return state
store = state["store"]
# Workspace retrieval only surfaces resources ingested by
# ``memorize_workspace`` (track="workspace"); other tracks are excluded.
resource_where = {**(state.get("where") or {}), "track": "workspace"}
resource_pool = store.resource_repo.list_resources(resource_where)
qvec = await self._ws_query_vector(state, step_context)
state["resource_hits"] = store.resource_repo.vector_search_resources(
qvec, self.retrieve_workspace_config.resource.top_k, where=resource_where
)
state["resource_pool"] = resource_pool
return state
def _ws_build_response(self, state: WorkflowState, _: Any) -> WorkflowState:
files = self._materialize_hits(state.get("file_hits", []), state.get("file_pool", {}))
file_resource_urls = state.get("file_resource_urls", {})
for file in files:
file["resource_urls"] = file_resource_urls.get(file["id"], [])
state["response"] = {
"segments": self._materialize_hits(state.get("segment_hits", []), state.get("segment_pool", {})),
"files": files,
"resources": self._materialize_hits(state.get("resource_hits", []), state.get("resource_pool", {})),
}
return state
def _materialize_hits(self, hits: Sequence[tuple[str, float]], pool: dict[str, Any]) -> list[dict[str, Any]]:
out = []
for _id, score in hits:
obj = pool.get(_id)
if not obj:
continue
data = self._model_dump_without_embeddings(obj)
data["score"] = float(score)
out.append(data)
return out
+35 -435
View File
@@ -1,449 +1,87 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Literal, TypeVar
from collections.abc import Mapping
from typing import Any, TypeVar
from pydantic import BaseModel
from memu.app.agentic import AgenticMixin
from memu.app.client_pool import ClientPool
from memu.app.crud import CRUDMixin
from memu.app.memorize import MemorizeMixin
from memu.app.memorize_workspace import MemorizeWorkspaceMixin
from memu.app.memory_files import MemoryFilesBuilder
from memu.app.retrieve import RetrieveMixin
from memu.app.retrieve_workspace import RetrieveWorkspaceMixin
from memu.app.settings import (
BlobConfig,
CategoryConfig,
DatabaseConfig,
EmbeddingConfig,
EmbeddingProfilesConfig,
LLMConfig,
LLMProfilesConfig,
MemorizeConfig,
MemoryFilesConfig,
RetrieveConfig,
RetrieveWorkspaceConfig,
ProgressiveRetrieveConfig,
UserConfig,
VLMConfig,
embedding_config_from_llm,
vlm_config_from_llm,
)
from memu.blob.local_fs import LocalFS
from memu.database.factory import build_database
from memu.database.interfaces import Database
from memu.embedding.gateway import build_embedding_client
from memu.llm.gateway import build_llm_client
from memu.llm.wrapper import (
LLMCallMetadata,
LLMClientWrapper,
LLMInterceptorHandle,
LLMInterceptorRegistry,
)
from memu.vlm.gateway import build_vlm_client
from memu.workflow.interceptor import WorkflowInterceptorHandle, WorkflowInterceptorRegistry
from memu.workflow.pipeline import PipelineManager
from memu.workflow.runner import WorkflowRunner, resolve_workflow_runner
from memu.workflow.step import WorkflowState, WorkflowStep
TConfigModel = TypeVar("TConfigModel", bound=BaseModel)
@dataclass
class Context:
categories_ready: bool = False
category_ids: list[str] = field(default_factory=list)
category_name_to_id: dict[str, str] = field(default_factory=dict)
category_init_task: asyncio.Task | None = None
class MemoryService(AgenticMixin):
"""Embedding-only memory service.
Exposes the agentic surface — :meth:`~AgenticMixin.list_all_recall_files`,
:meth:`~AgenticMixin.progressive_retrieve`, and
:meth:`~AgenticMixin.commit_results` — over a pluggable store. No LLM is
involved anywhere: the only model calls are embeddings for indexing and
vector search.
"""
class MemoryService(
MemorizeMixin, MemorizeWorkspaceMixin, RetrieveMixin, RetrieveWorkspaceMixin, CRUDMixin, AgenticMixin
):
def __init__(
self,
*,
llm_profiles: LLMProfilesConfig | dict[str, Any] | None = None,
blob_config: BlobConfig | dict[str, Any] | None = None,
database_config: DatabaseConfig | dict[str, Any] | None = None,
memorize_config: MemorizeConfig | dict[str, Any] | None = None,
retrieve_config: RetrieveConfig | dict[str, Any] | None = None,
retrieve_workspace_config: RetrieveWorkspaceConfig | dict[str, Any] | None = None,
workflow_runner: WorkflowRunner | str | None = None,
user_config: UserConfig | dict[str, Any] | None = None,
memory_files_config: MemoryFilesConfig | dict[str, Any] | None = None,
embedding_profiles: EmbeddingProfilesConfig | dict[str, Any] | None = None,
progressive_retrieve_config: ProgressiveRetrieveConfig | dict[str, Any] | None = None,
user_config: UserConfig | dict[str, Any] | None = None,
):
self.llm_profiles = self._validate_config(llm_profiles, LLMProfilesConfig)
self.user_config = self._validate_config(user_config, UserConfig)
self.user_model = self.user_config.model
self.llm_config = self._validate_config(self.llm_profiles.default, LLMConfig)
self.blob_config = self._validate_config(blob_config, BlobConfig)
self.database_config = self._validate_config(database_config, DatabaseConfig)
self.memorize_config = self._validate_config(memorize_config, MemorizeConfig)
self.retrieve_config = self._validate_config(retrieve_config, RetrieveConfig)
self.retrieve_workspace_config = self._validate_config(retrieve_workspace_config, RetrieveWorkspaceConfig)
self.memory_files_config = self._validate_config(memory_files_config, MemoryFilesConfig)
self.fs = LocalFS(self.blob_config.resources_dir)
self.category_configs: list[CategoryConfig] = list(self.memorize_config.recall_files or [])
self.category_config_map: dict[str, CategoryConfig] = {cfg.name: cfg for cfg in self.category_configs}
self._category_prompt_str = self._format_categories_for_prompt(self.category_configs)
self._context = Context(categories_ready=not bool(self.category_configs))
self.progressive_retrieve_config = self._validate_config(progressive_retrieve_config, ProgressiveRetrieveConfig)
self.embedding_profiles: dict[str, EmbeddingConfig] = self._validate_config(
embedding_profiles, EmbeddingProfilesConfig
).profiles
self.database: Database = build_database(
config=self.database_config,
user_model=self.user_model,
)
# We need the concrete user scope (user_id: xxx) to initialize the categories
# self._start_category_initialization(self._context, self.database)
# VLM (vision-language) profiles are derived from the LLM profiles so
# image/video vision reuses the same provider/credentials with a stronger
# multimodal model (see ``vlm_config_from_llm``).
self.vlm_profiles: dict[str, VLMConfig] = {
name: vlm_config_from_llm(cfg) for name, cfg in self.llm_profiles.profiles.items()
}
# Embedding profiles: use explicit profiles when supplied (e.g. to point
# vectorization at a dedicated provider such as jina/voyage), otherwise
# derive them from the LLM profiles so existing configs keep working.
if embedding_profiles is None:
self.embedding_profiles: dict[str, EmbeddingConfig] = {
name: embedding_config_from_llm(cfg) for name, cfg in self.llm_profiles.profiles.items()
}
else:
self.embedding_profiles = self._validate_config(embedding_profiles, EmbeddingProfilesConfig).profiles
# Lazy client pools (one per capability), keyed by profile name. Each
# builds and caches a concrete client on first use, sharing the same
# get/cache/build bookkeeping instead of three hand-rolled caches.
self._llm_pool: ClientPool[LLMConfig, Any] = ClientPool(
profiles=self.llm_profiles.profiles, builder=build_llm_client, label="llm"
)
self._vlm_pool: ClientPool[VLMConfig, Any] = ClientPool(
profiles=self.vlm_profiles, builder=build_vlm_client, label="vlm"
)
self._embedding_pool: ClientPool[EmbeddingConfig, Any] = ClientPool(
profiles=self.embedding_profiles, builder=build_embedding_client, label="embedding"
)
self._llm_interceptors = LLMInterceptorRegistry()
self._workflow_interceptors = WorkflowInterceptorRegistry()
self._workflow_runner = resolve_workflow_runner(workflow_runner)
# Optional markdown "memory file system" artifact layer (additive, read-only).
# The build/synthesis policy lives in MemoryFilesBuilder; the exporter and
# synthesizer are re-exposed as attributes for back-compat with callers/tests.
self._memory_files = MemoryFilesBuilder(config=self.memory_files_config)
self._memory_file_exporter = self._memory_files.exporter
self._memory_synthesizer = self._memory_files.synthesizer
self._pipelines = PipelineManager(
available_capabilities={"llm", "vector", "db", "io", "vision"},
llm_profiles=set(self.llm_profiles.profiles.keys()),
)
self._register_pipelines()
@staticmethod
def _llm_call_metadata(profile: str, step_context: Mapping[str, Any] | None) -> LLMCallMetadata:
if not isinstance(step_context, Mapping):
return LLMCallMetadata(profile)
operation = None
for key in ("operation", "workflow_name"):
value = step_context.get(key)
if isinstance(value, str) and value.strip():
operation = value.strip()
break
step_id = step_context.get("step_id") if isinstance(step_context.get("step_id"), str) else None
trace_id = step_context.get("trace_id") if isinstance(step_context.get("trace_id"), str) else None
tags = step_context.get("tags") if isinstance(step_context.get("tags"), Mapping) else None
return LLMCallMetadata(profile=profile, operation=operation, step_id=step_id, trace_id=trace_id, tags=tags)
def _wrap_llm_client(
self,
client: Any,
*,
profile: str | None = None,
step_context: Mapping[str, Any] | None = None,
) -> Any:
cfg = self._llm_pool.config(profile)
provider = cfg.provider if cfg is not None else None
metadata = self._llm_call_metadata(profile or "default", step_context)
return LLMClientWrapper(
client,
registry=self._llm_interceptors,
metadata=metadata,
provider=provider,
chat_model=getattr(client, "chat_model", None),
)
def _get_llm_client(self, profile: str | None = None, step_context: Mapping[str, Any] | None = None) -> Any:
base_client = self._llm_pool.get(profile)
return self._wrap_llm_client(base_client, profile=profile, step_context=step_context)
def _get_vlm_client(self, profile: str | None = None, step_context: Mapping[str, Any] | None = None) -> Any:
base_client = self._vlm_pool.get(profile)
cfg = self._vlm_pool.config(profile)
provider = cfg.provider if cfg is not None else None
metadata = self._llm_call_metadata(profile or "default", step_context)
return LLMClientWrapper(
base_client,
registry=self._llm_interceptors,
metadata=metadata,
provider=provider,
chat_model=getattr(base_client, "vlm_model", None),
)
def _get_embedding_client(self, profile: str | None = None, step_context: Mapping[str, Any] | None = None) -> Any:
base_client = self._embedding_pool.get(profile)
cfg = self._embedding_pool.config(profile)
provider = cfg.provider if cfg is not None else None
metadata = self._llm_call_metadata(profile or "default", step_context)
return LLMClientWrapper(
base_client,
registry=self._llm_interceptors,
metadata=metadata,
provider=provider,
embed_model=getattr(base_client, "embed_model", None),
)
@property
def llm_client(self) -> Any:
"""Default LLM client (lazy)."""
return self._get_llm_client()
@property
def workflow_runner(self) -> WorkflowRunner:
"""Current workflow runner backend."""
return self._workflow_runner
@staticmethod
def _llm_profile_from_context(
step_context: Mapping[str, Any] | None, task: Literal["chat", "embedding"] = "chat"
) -> str | None:
if not isinstance(step_context, Mapping):
return None
step_cfg = step_context.get("step_config")
if not isinstance(step_cfg, Mapping):
return None
if task == "chat":
profile = step_cfg.get("chat_llm_profile", step_cfg.get("llm_profile"))
elif task == "embedding":
profile = step_cfg.get("embed_llm_profile", step_cfg.get("llm_profile"))
else:
raise ValueError(task)
if isinstance(profile, str) and profile.strip():
return profile.strip()
return None
def _get_step_llm_client(self, step_context: Mapping[str, Any] | None) -> Any:
profile = self._llm_profile_from_context(step_context, task="chat") or "default"
return self._get_llm_client(profile, step_context=step_context)
def _get_step_embedding_client(self, step_context: Mapping[str, Any] | None) -> Any:
profile = self._llm_profile_from_context(step_context, task="embedding") or "embedding"
return self._get_embedding_client(profile, step_context=step_context)
def intercept_before_llm_call(
self,
fn: Callable[..., Any],
*,
name: str | None = None,
priority: int = 0,
where: Mapping[str, Any] | Callable[..., Any] | None = None,
) -> LLMInterceptorHandle:
return self._llm_interceptors.register_before(fn, name=name, priority=priority, where=where)
def intercept_after_llm_call(
self,
fn: Callable[..., Any],
*,
name: str | None = None,
priority: int = 0,
where: Mapping[str, Any] | Callable[..., Any] | None = None,
) -> LLMInterceptorHandle:
return self._llm_interceptors.register_after(fn, name=name, priority=priority, where=where)
def intercept_on_error_llm_call(
self,
fn: Callable[..., Any],
*,
name: str | None = None,
priority: int = 0,
where: Mapping[str, Any] | Callable[..., Any] | None = None,
) -> LLMInterceptorHandle:
return self._llm_interceptors.register_on_error(fn, name=name, priority=priority, where=where)
def intercept_before_workflow_step(
self,
fn: Callable[..., Any],
*,
name: str | None = None,
) -> WorkflowInterceptorHandle:
"""
Register an interceptor to be called before each workflow step.
The interceptor receives (step_context: WorkflowStepContext, state: WorkflowState).
"""
return self._workflow_interceptors.register_before(fn, name=name)
def intercept_after_workflow_step(
self,
fn: Callable[..., Any],
*,
name: str | None = None,
) -> WorkflowInterceptorHandle:
"""
Register an interceptor to be called after each workflow step.
The interceptor receives (step_context: WorkflowStepContext, state: WorkflowState).
"""
return self._workflow_interceptors.register_after(fn, name=name)
def intercept_on_error_workflow_step(
self,
fn: Callable[..., Any],
*,
name: str | None = None,
) -> WorkflowInterceptorHandle:
"""
Register an interceptor to be called when a workflow step raises an exception.
The interceptor receives (step_context: WorkflowStepContext, state: WorkflowState, error: Exception).
"""
return self._workflow_interceptors.register_on_error(fn, name=name)
def _get_context(self) -> Context:
return self._context
def _get_database(self) -> Database:
return self.database
def _provider_summary(self) -> dict[str, Any]:
vector_provider = None
if self.database_config.vector_index:
vector_provider = self.database_config.vector_index.provider
return {
"llm_profiles": list(self.llm_profiles.profiles.keys()),
"storage": {
"metadata_store": self.database_config.metadata_store.provider,
"vector_index": vector_provider,
},
}
def _get_embedding_client(self, profile: str | None = None) -> Any:
return self._embedding_pool.get(profile)
def _register_pipelines(self) -> None:
memo_workflow = self._build_memorize_workflow()
memo_initial_keys = self._list_memorize_initial_keys()
self._pipelines.register("memorize", memo_workflow, initial_state_keys=memo_initial_keys)
# Workspace memorize: memory steps + per-file skill generation (ADR 0006).
workspace_workflow = self._build_memorize_workspace_workflow()
self._pipelines.register("memorize_workspace", workspace_workflow, initial_state_keys=memo_initial_keys)
rag_workflow = self._build_rag_retrieve_workflow()
retrieve_initial_keys = self._list_retrieve_initial_keys()
self._pipelines.register("retrieve_rag", rag_workflow, initial_state_keys=retrieve_initial_keys)
llm_workflow = self._build_llm_retrieve_workflow()
self._pipelines.register("retrieve_llm", llm_workflow, initial_state_keys=retrieve_initial_keys)
# Simple embedding-only workspace retrieval: segment recall + file roll-up + resource recall.
workspace_retrieve_workflow = self._build_retrieve_workspace_workflow()
self._pipelines.register(
"retrieve_workspace",
workspace_retrieve_workflow,
initial_state_keys=self._list_retrieve_workspace_initial_keys(),
)
patch_create_workflow = self._build_create_recall_entry_workflow()
patch_create_initial_keys = CRUDMixin._list_create_recall_entry_initial_keys()
self._pipelines.register("patch_create", patch_create_workflow, initial_state_keys=patch_create_initial_keys)
patch_update_workflow = self._build_update_recall_entry_workflow()
patch_update_initial_keys = CRUDMixin._list_update_recall_entry_initial_keys()
self._pipelines.register("patch_update", patch_update_workflow, initial_state_keys=patch_update_initial_keys)
patch_delete_workflow = self._build_delete_recall_entry_workflow()
patch_delete_initial_keys = CRUDMixin._list_delete_recall_entry_initial_keys()
self._pipelines.register("patch_delete", patch_delete_workflow, initial_state_keys=patch_delete_initial_keys)
crud_list_items_workflow = self._build_list_recall_entries_workflow()
crud_list_memories_initial_keys = CRUDMixin._list_list_memories_initial_keys()
self._pipelines.register(
"crud_list_recall_entries", crud_list_items_workflow, initial_state_keys=crud_list_memories_initial_keys
)
crud_list_categories_workflow = self._build_list_recall_files_workflow()
self._pipelines.register(
"crud_list_recall_files",
crud_list_categories_workflow,
initial_state_keys=crud_list_memories_initial_keys,
)
crud_clear_memory_workflow = self._build_clear_memory_workflow()
crud_clear_memory_initial_keys = CRUDMixin._list_clear_memories_initial_keys()
self._pipelines.register(
"crud_clear_memory", crud_clear_memory_workflow, initial_state_keys=crud_clear_memory_initial_keys
)
def _normalize_where(self, where: Mapping[str, Any] | None) -> dict[str, Any]:
"""Validate and clean the `where` scope filters against the configured user model."""
if not where:
return {}
async def _run_workflow(self, workflow_name: str, initial_state: WorkflowState) -> WorkflowState:
"""Execute a workflow through the configured runner backend."""
steps = self._pipelines.build(workflow_name)
runner_context = {"workflow_name": workflow_name}
return await self._workflow_runner.run(
workflow_name,
steps,
initial_state,
runner_context,
interceptor_registry=self._workflow_interceptors,
)
valid_fields = set(getattr(self.user_model, "model_fields", {}).keys())
cleaned: dict[str, Any] = {}
async def export_memory_files(self, *, user: dict[str, Any] | None = None) -> dict[str, Any]:
"""Render the (optionally scoped) memory store into browsable markdown files.
for raw_key, value in where.items():
if value is None:
continue
field = raw_key.split("__", 1)[0]
if field not in valid_fields:
msg = f"Unknown filter field '{field}' for current user scope"
raise ValueError(msg)
cleaned[raw_key] = value
Read-only against the database; only artifacts whose rendered content
changed since the last export are rewritten (diff detection via a sidecar
manifest). Returns a summary of written/unchanged/removed relative paths.
Requires ``memory_files_config.enabled=True``.
"""
if not self.memory_files_config.enabled:
msg = "Memory files are disabled; set memory_files_config.enabled=True to use export_memory_files()."
raise RuntimeError(msg)
where = self.user_model(**user).model_dump() if user is not None else None
# No changed set => full (re)initialization of the tree.
return await self._build_memory_files(where, changed=None)
async def _build_memory_files(
self,
where: dict[str, Any] | None,
*,
changed: list[Any] | None,
) -> dict[str, Any]:
"""Initialize or incrementally update the memory file tree.
Thin delegate to :class:`MemoryFilesBuilder`; ``changed`` is the list of
just-memorized ``Resource`` objects driving an incremental update (``None``
forces a full (re)initialization from the scoped store).
"""
return await self._memory_files.build(
self.database,
where,
changed=changed,
make_client=self._get_llm_client,
)
@staticmethod
def _extract_json_blob(raw: str) -> str:
start = raw.find("{")
end = raw.rfind("}")
if start == -1 or end == -1 or end <= start:
msg = "No JSON object found"
raise ValueError(msg)
return raw[start : end + 1]
@staticmethod
def _escape_prompt_value(value: str) -> str:
return value.replace("{", "{{").replace("}", "}}")
return cleaned
def _model_dump_without_embeddings(self, obj: BaseModel) -> dict[str, Any]:
data = obj.model_dump(exclude={"embedding"})
return data
return obj.model_dump(exclude={"embedding"})
@staticmethod
def _validate_config(
@@ -455,41 +93,3 @@ class MemoryService(
if config is None:
return model_type()
return model_type.model_validate(config)
def configure_pipeline(self, *, step_id: str, configs: Mapping[str, Any], pipeline: str = "memorize") -> int:
revision = self._pipelines.config_step(pipeline, step_id, dict(configs))
return revision
def insert_step_after(
self,
*,
target_step_id: str,
new_step: WorkflowStep,
pipeline: str = "memorize",
) -> int:
revision = self._pipelines.insert_after(pipeline, target_step_id, new_step)
return revision
def insert_step_before(
self,
*,
target_step_id: str,
new_step: WorkflowStep,
pipeline: str = "memorize",
) -> int:
revision = self._pipelines.insert_before(pipeline, target_step_id, new_step)
return revision
def replace_step(
self,
*,
target_step_id: str,
new_step: WorkflowStep,
pipeline: str = "memorize",
) -> int:
revision = self._pipelines.replace_step(pipeline, target_step_id, new_step)
return revision
def remove_step(self, *, target_step_id: str, pipeline: str = "memorize") -> int:
revision = self._pipelines.remove_step(pipeline, target_step_id)
return revision
+24 -450
View File
@@ -1,21 +1,6 @@
from collections.abc import Mapping
from typing import Annotated, Any, Literal
from pydantic import AfterValidator, BaseModel, BeforeValidator, Field, RootModel, StringConstraints, model_validator
from memu.prompts.category_summary import (
DEFAULT_CATEGORY_SUMMARY_PROMPT_ORDINAL,
)
from memu.prompts.category_summary import (
PROMPT as CATEGORY_SUMMARY_PROMPT,
)
from memu.prompts.memory_type import (
DEFAULT_MEMORY_CUSTOM_PROMPT_ORDINAL,
DEFAULT_MEMORY_TYPES,
)
from memu.prompts.memory_type import (
PROMPTS as DEFAULT_MEMORY_TYPE_PROMPTS,
)
from pydantic import BaseModel, BeforeValidator, Field, RootModel, StringConstraints, model_validator
def normalize_value(v: str) -> str:
@@ -27,208 +12,12 @@ def normalize_value(v: str) -> str:
Normalize = BeforeValidator(normalize_value)
def _default_memory_types() -> list[str]:
return list(DEFAULT_MEMORY_TYPES)
def _default_memory_type_prompts() -> "dict[str, str | CustomPrompt]":
return dict(DEFAULT_MEMORY_TYPE_PROMPTS)
class PromptBlock(BaseModel):
label: str | None = None
ordinal: int = Field(default=0)
prompt: str | None = None
class CustomPrompt(RootModel[dict[str, PromptBlock]]):
root: dict[str, PromptBlock] = Field(default_factory=dict)
def get(self, key: str, default: PromptBlock | None = None) -> PromptBlock | None:
return self.root.get(key, default)
def items(self) -> list[tuple[str, PromptBlock]]:
return list(self.root.items())
def complete_prompt_blocks(prompt: CustomPrompt, default_blocks: Mapping[str, int]) -> CustomPrompt:
for key, ordinal in default_blocks.items():
if key not in prompt.root:
prompt.root[key] = PromptBlock(ordinal=ordinal)
return prompt
CompleteEntryTypePrompt = AfterValidator(lambda v: complete_prompt_blocks(v, DEFAULT_MEMORY_CUSTOM_PROMPT_ORDINAL))
CompleteCategoryPrompt = AfterValidator(lambda v: complete_prompt_blocks(v, DEFAULT_CATEGORY_SUMMARY_PROMPT_ORDINAL))
class CategoryConfig(BaseModel):
name: str
description: str = ""
target_length: int | None = None
summary_prompt: str | Annotated[CustomPrompt, CompleteCategoryPrompt] | None = None
class LazyLLMSource(BaseModel):
source: str | None = Field(default=None, description="default source for lazyllm client backend")
llm_source: str | None = Field(default=None, description="LLM source for lazyllm client backend")
embed_source: str | None = Field(default=None, description="Embedding source for lazyllm client backend")
vlm_source: str | None = Field(default=None, description="VLM source for lazyllm client backend")
stt_source: str | None = Field(default=None, description="STT source for lazyllm client backend")
vlm_model: str = Field(default="qwen-vl-plus", description="Vision language model for lazyllm client backend")
stt_model: str = Field(default="qwen-audio-turbo", description="Speech-to-text model for lazyllm client backend")
# Per-provider defaults: provider -> (base_url, api_key_env_or_value, chat_model).
# Used by ``LLMConfig.set_provider_defaults`` to swap OpenAI defaults when a provider is selected.
# Each provider defaults to its latest small/fast model (verified June 2026).
_PROVIDER_DEFAULTS: dict[str, tuple[str, str, str]] = {
"grok": ("https://api.x.ai/v1", "XAI_API_KEY", "grok-4-1-fast"),
"claude": ("https://api.anthropic.com", "ANTHROPIC_API_KEY", "claude-haiku-4-5"),
"deepseek": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", "deepseek-v4-flash"),
"kimi": ("https://api.moonshot.cn/v1", "MOONSHOT_API_KEY", "kimi-k2.7-code-highspeed"),
"minimax": ("https://api.minimax.io/v1", "MINIMAX_API_KEY", "MiniMax-M3"),
"doubao": ("https://ark.cn-beijing.volces.com", "ARK_API_KEY", "doubao-seed-2.0-lite"),
"openrouter": ("https://openrouter.ai", "OPENROUTER_API_KEY", "openai/gpt-5.4-mini"),
}
class LLMConfig(BaseModel):
provider: str = Field(
default="openai",
description="Identifier for the LLM provider implementation (used by HTTP client backend).",
)
base_url: str = Field(default="https://api.openai.com/v1")
api_key: str = Field(default="OPENAI_API_KEY")
chat_model: str = Field(default="gpt-5.4-mini")
client_backend: str = Field(
default="sdk",
description=(
"Which LLM client backend to use: 'sdk' (official OpenAI SDK), "
"'anthropic' (official Anthropic/Claude SDK), 'httpx' (raw HTTP, supports "
"all providers in memu.llm.backends), or 'lazyllm_backend' (Qwen, Doubao, "
"SiliconFlow, etc.)."
),
)
lazyllm_source: LazyLLMSource = Field(default=LazyLLMSource())
endpoint_overrides: dict[str, str] = Field(
default_factory=dict,
description="Optional overrides for HTTP endpoints (keys: 'chat'/'summary').",
)
# Backward-compat bridge: embedding is owned by the dedicated
# ``memu.embedding`` clients (``EmbeddingConfig``/``embedding_profiles``). The
# LLM/chat clients no longer embed. These fields are only consumed by
# ``embedding_config_from_llm`` to derive an embedding profile from an LLM
# profile when no explicit ``embedding_profiles`` is supplied. Prefer setting
# ``embedding_profiles`` directly.
embed_model: str = Field(
default="text-embedding-3-small",
description="Embedding model used to derive an embedding profile from this LLM profile (bridge only).",
)
embed_batch_size: int = Field(
default=1,
description="Embedding batch size used to derive an embedding profile from this LLM profile (bridge only).",
)
@model_validator(mode="after")
def set_provider_defaults(self) -> "LLMConfig":
# Per-provider defaults for the HTTP client backend. Each entry only
# overrides a field when it still holds the OpenAI default, so explicit
# user values are always preserved.
defaults = _PROVIDER_DEFAULTS.get(self.provider)
if defaults is not None:
base_url, api_key, chat_model = defaults
if self.base_url == "https://api.openai.com/v1":
self.base_url = base_url
if self.api_key == "OPENAI_API_KEY":
self.api_key = api_key
if self.chat_model == "gpt-5.4-mini":
self.chat_model = chat_model
return self
class VLMConfig(BaseModel):
"""Configuration for a vision-language (multimodal) model client.
Sibling to :class:`LLMConfig` but scoped to the ``vision`` capability used by
image/video preprocessing. Defaults to each provider's latest VLM model (see
``memu.vlm.VLM_PROVIDER_DEFAULTS``) instead of the small/fast chat default.
"""
provider: str = Field(
default="openai",
description="Identifier for the VLM provider implementation (used by HTTP client backend).",
)
base_url: str = Field(default="https://api.openai.com/v1")
api_key: str = Field(default="OPENAI_API_KEY")
vlm_model: str = Field(default="gpt-5.4", description="Vision-language model used for image/video understanding.")
client_backend: str = Field(
default="sdk",
description=(
"Which VLM client backend to use: 'sdk' (official OpenAI SDK), "
"'anthropic' (official Anthropic/Claude SDK), or 'httpx' (raw HTTP, "
"supports all providers in memu.vlm.backends)."
),
)
endpoint_overrides: dict[str, str] = Field(
default_factory=dict,
description="Optional overrides for HTTP endpoints (key: 'vision').",
)
@model_validator(mode="after")
def set_provider_defaults(self) -> "VLMConfig":
# base_url/api_key reuse the shared per-provider HTTP defaults; vlm_model
# comes from the VLM-specific defaults. Each field is only overridden
# while it still holds the OpenAI default, so explicit values survive.
from memu.vlm.defaults import default_vlm_model
defaults = _PROVIDER_DEFAULTS.get(self.provider)
if defaults is not None:
base_url, api_key, _chat_model = defaults
if self.base_url == "https://api.openai.com/v1":
self.base_url = base_url
if self.api_key == "OPENAI_API_KEY":
self.api_key = api_key
if self.vlm_model == "gpt-5.4":
resolved = default_vlm_model(self.provider)
if resolved is not None:
self.vlm_model = resolved
return self
def vlm_config_from_llm(llm: "LLMConfig") -> "VLMConfig":
"""Derive a :class:`VLMConfig` from an :class:`LLMConfig`.
Reuses the LLM provider/credentials/transport so vision steps work with zero
extra configuration, swapping only the model for the provider's latest VLM
(falling back to the LLM chat model when the provider has no known VLM).
"""
from memu.vlm.defaults import default_vlm_model
# The anthropic SDK backend leaves ``provider`` at its generic default, so
# map it explicitly to resolve the right VLM model.
provider = "claude" if llm.client_backend == "anthropic" else llm.provider
vlm_model = default_vlm_model(provider) or llm.chat_model
return VLMConfig(
provider=provider,
base_url=llm.base_url,
api_key=llm.api_key,
vlm_model=vlm_model,
client_backend=llm.client_backend,
endpoint_overrides=dict(llm.endpoint_overrides),
)
class EmbeddingConfig(BaseModel):
"""Configuration for an embedding (vectorization) model client.
Sibling to :class:`LLMConfig`/:class:`VLMConfig` but scoped to the embedding
capability used by vector search. Defaults to OpenAI's
``text-embedding-3-small``; embedding-only providers (Jina, Voyage) bring
their own ``base_url``/``api_key`` via provider defaults (see
``memu.embedding.defaults``).
Defaults to OpenAI's ``text-embedding-3-small``; embedding-only providers
(Jina, Voyage) bring their own ``base_url``/``api_key`` via provider defaults
(see ``memu.embedding.defaults``).
"""
provider: str = Field(
@@ -248,12 +37,11 @@ class EmbeddingConfig(BaseModel):
client_backend: str = Field(
default="sdk",
description=(
"Which embedding client backend to use: 'sdk' (official OpenAI SDK), "
"Which embedding client backend to use: 'sdk' (official OpenAI SDK) or "
"'httpx' (raw HTTP, supports all providers in memu.embedding.backends, "
"e.g. openai/jina/voyage/doubao/openrouter), or 'lazyllm_backend'."
"e.g. openai/jina/voyage/doubao/openrouter)."
),
)
lazyllm_source: LazyLLMSource = Field(default=LazyLLMSource())
endpoint_overrides: dict[str, str] = Field(
default_factory=dict,
description="Optional overrides for HTTP endpoints (key: 'embeddings').",
@@ -261,24 +49,17 @@ class EmbeddingConfig(BaseModel):
@model_validator(mode="after")
def set_provider_defaults(self) -> "EmbeddingConfig":
# Each field is only overridden while it still holds the OpenAI default,
# so explicit values survive.
from memu.embedding.defaults import EMBEDDING_PROVIDER_ENDPOINTS, default_embedding_model
# base_url/api_key: reuse the shared chat per-provider defaults when the
# provider is also a chat provider; otherwise fall back to the
# embedding-only endpoint table (Jina, Voyage). Each field is only
# overridden while it still holds the OpenAI default, so explicit values
# survive.
endpoint = _PROVIDER_DEFAULTS.get(self.provider)
base_url = endpoint[0] if endpoint is not None else None
api_key = endpoint[1] if endpoint is not None else None
if base_url is None:
embed_endpoint = EMBEDDING_PROVIDER_ENDPOINTS.get(self.provider)
if embed_endpoint is not None:
base_url, api_key = embed_endpoint
if base_url is not None and self.base_url == "https://api.openai.com/v1":
self.base_url = base_url
if api_key is not None and self.api_key == "OPENAI_API_KEY":
self.api_key = api_key
endpoint = EMBEDDING_PROVIDER_ENDPOINTS.get(self.provider)
if endpoint is not None:
base_url, api_key = endpoint
if self.base_url == "https://api.openai.com/v1":
self.base_url = base_url
if self.api_key == "OPENAI_API_KEY":
self.api_key = api_key
if self.embed_model == "text-embedding-3-small":
resolved = default_embedding_model(self.provider)
if resolved is not None:
@@ -286,87 +67,6 @@ class EmbeddingConfig(BaseModel):
return self
def embedding_config_from_llm(llm: "LLMConfig") -> "EmbeddingConfig":
"""Derive an :class:`EmbeddingConfig` from an :class:`LLMConfig`.
Reuses the LLM provider/credentials/transport/embed model so vectorization
works with zero extra configuration when no dedicated embedding profile is
supplied. The ``anthropic`` transport has no embeddings API; the derived
config preserves that so the embedding gateway raises a clear error.
"""
return EmbeddingConfig(
provider=llm.provider,
base_url=llm.base_url,
api_key=llm.api_key,
embed_model=llm.embed_model,
embed_batch_size=llm.embed_batch_size,
client_backend=llm.client_backend,
lazyllm_source=llm.lazyllm_source,
endpoint_overrides=dict(llm.endpoint_overrides),
)
class BlobConfig(BaseModel):
provider: str = Field(default="local")
resources_dir: str = Field(default="./data/resources")
class MemoryFilesConfig(BaseModel):
"""Render structured memory into a browsable markdown "memory file system".
Purely additive and read-only against the store; disabled by default so it
never changes existing memorize/retrieve behavior. When enabled, the tree is
refreshed by ``memorize_workspace`` (and on demand via ``export_memory_files``).
"""
enabled: bool = Field(
default=False,
description="Enable rendering structured memory into browsable markdown files.",
)
output_dir: str = Field(
default="./data/memory",
description=(
"Directory where the memory markdown tree (the INDEX.md/MEMORY.md/SKILL.md root "
"indexes plus the resource/, memory/, and skill/ directories) is written."
),
)
synthesize: bool = Field(
default=False,
description=(
"Synthesize MEMORY.md and skill docs from per-source descriptions via the LLM "
"instead of rendering already-extracted records. INDEX.md stays deterministic."
),
)
synthesis_llm_profile: str = Field(
default="default",
description="LLM profile used when synthesize=True.",
)
class RetrieveCategoryConfig(BaseModel):
enabled: bool = Field(default=True, description="Whether to enable category retrieval.")
top_k: int = Field(default=5, description="Total number of categories to retrieve.")
class RetrieveItemConfig(BaseModel):
enabled: bool = Field(default=True, description="Whether to enable item retrieval.")
top_k: int = Field(default=5, description="Total number of items to retrieve.")
# Reference-aware retrieval
use_category_references: bool = Field(
default=False,
description="When category retrieval is insufficient, follow [ref:ITEM_ID] citations to fetch referenced items.",
)
# Salience-aware retrieval settings
ranking: Literal["similarity", "salience"] = Field(
default="similarity",
description="Ranking strategy: 'similarity' (cosine only) or 'salience' (weighted by reinforcement + recency).",
)
recency_decay_days: float = Field(
default=30.0,
description="Half-life in days for recency decay in salience scoring. After this many days, recency factor is ~0.5.",
)
class RetrieveResourceConfig(BaseModel):
enabled: bool = Field(default=True, description="Whether to enable resource retrieval.")
top_k: int = Field(default=5, description="Total number of resources to retrieve.")
@@ -381,112 +81,22 @@ class RetrieveFileConfig(BaseModel):
)
class RetrieveEntryConfig(BaseModel):
enabled: bool = Field(default=True, description="Whether to enable entry retrieval.")
top_k: int = Field(default=5, description="Total number of entries to retrieve.")
class ProgressiveRetrieveConfig(BaseModel):
"""Configure the single-shot, LLM-free retrieval (``progressive_retrieve``).
class RetrieveWorkspaceConfig(BaseModel):
"""Configure the simple embedding-only workspace retrieval (``retrieve_workspace``).
Unlike :class:`RetrieveConfig`, this drives a single-shot, LLM-free path that
embeds the query once and ranks each of the file/entry/resource layers by
vector similarity. No routing, sufficiency check, or summarization is involved.
The query is embedded once and the file-segment and resource layers are each
ranked by vector similarity. No routing, sufficiency check, or summarization
is involved.
"""
method: Annotated[Literal["rag"], Normalize] = "rag"
file: RetrieveFileConfig = Field(default=RetrieveFileConfig())
entry: RetrieveEntryConfig = Field(default=RetrieveEntryConfig())
resource: RetrieveResourceConfig = Field(default=RetrieveResourceConfig())
class RetrieveConfig(BaseModel):
"""Configure retrieval behavior for `MemoryUser.retrieve`.
Attributes:
method: Retrieval strategy. Use "rag" for embedding-based vector search or
"llm" to delegate ranking to the LLM.
top_k: Maximum number of results to return per category (and per stage),
controlling breadth of the retrieved context.
"""
method: Annotated[Literal["rag", "llm"], Normalize] = "rag"
# top_k: int = Field(
# default=5,
# description="Maximum number of results to return per category.",
# )
route_intention: bool = Field(
default=True, description="Whether to route intention (judge needs retrieval & rewrite query)."
)
# route_intention_prompt: str = Field(default="", description="User prompt for route intention.")
# route_intention_llm_profile: str = Field(default="default", description="LLM profile for route intention.")
category: RetrieveCategoryConfig = Field(default=RetrieveCategoryConfig())
item: RetrieveItemConfig = Field(default=RetrieveItemConfig())
resource: RetrieveResourceConfig = Field(default=RetrieveResourceConfig())
sufficiency_check: bool = Field(default=True, description="Whether to check sufficiency after each tier.")
sufficiency_check_prompt: str = Field(default="", description="User prompt for sufficiency check.")
sufficiency_check_llm_profile: str = Field(default="default", description="LLM profile for sufficiency check.")
llm_ranking_llm_profile: str = Field(default="default", description="LLM profile for LLM ranking.")
class MemorizeConfig(BaseModel):
category_assign_threshold: float = Field(default=0.25)
multimodal_preprocess_prompts: dict[str, str | CustomPrompt] = Field(
default_factory=dict,
description="Optional mapping of modality -> preprocess system prompt.",
)
preprocess_llm_profile: str = Field(default="default", description="LLM profile for preprocess.")
vlm_profile: str = Field(
default="default",
description="LLM profile whose provider/credentials back the VLM client used for image/video vision.",
)
memory_types: list[str] = Field(
default_factory=_default_memory_types,
description="Ordered list of memory types (profile/event/knowledge/behavior by default).",
)
memory_type_prompts: dict[str, str | Annotated[CustomPrompt, CompleteEntryTypePrompt]] = Field(
default_factory=_default_memory_type_prompts,
description="User prompt overrides for each memory type extraction.",
)
memory_extract_llm_profile: str = Field(default="default", description="LLM profile for memory extract.")
recall_files: list[CategoryConfig] = Field(
default_factory=list,
description=(
"Optional seed categories. The kernel presets no taxonomy: categories are "
"discovered adaptively from ingested content. Provide seeds only to guide "
"(not constrain) the taxonomy; an empty list means fully open/adaptive."
),
)
# default_category_summary_prompt: str | CustomPrompt = Field(
default_category_summary_prompt: str | Annotated[CustomPrompt, CompleteCategoryPrompt] = Field(
default=CATEGORY_SUMMARY_PROMPT,
description="Default system prompt for auto-generated category summaries.",
)
default_category_summary_target_length: int = Field(
default=400,
description="Target max length for auto-generated category summaries.",
)
category_update_llm_profile: str = Field(default="default", description="LLM profile for category summary.")
# Reference tracking for category summaries
enable_item_references: bool = Field(
default=False,
description="Enable inline [ref:ITEM_ID] citations in category summaries linking to source memory items.",
)
enable_item_reinforcement: bool = Field(
default=False,
description="Enable reinforcement tracking for memory items.",
)
class PatchConfig(BaseModel):
pass
class DefaultUserModel(BaseModel):
user_id: str | None = None
# Agent/session scoping for multi-agent and multi-session memory filtering
agent_id: str | None = None
# session_id: str | None = None
class UserConfig(BaseModel):
@@ -496,48 +106,12 @@ class UserConfig(BaseModel):
Key = Annotated[str, StringConstraints(min_length=1)]
class LLMProfilesConfig(RootModel[dict[Key, LLMConfig]]):
root: dict[str, LLMConfig] = Field(default_factory=lambda: {"default": LLMConfig()})
def get(self, key: str, default: LLMConfig | None = None) -> LLMConfig | None:
return self.root.get(key, default)
@model_validator(mode="before")
@classmethod
def ensure_default(cls, data: Any) -> Any:
# if data is None:
# return {"default": LLMConfig()}
# if isinstance(data, dict) and "default" not in data:
# data = dict(data)
# data["default"] = LLMConfig()
# return data
if data is None:
data = {}
elif isinstance(data, dict):
data = dict(data)
else:
return data
if "default" not in data:
data["default"] = LLMConfig()
if "embedding" not in data:
data["embedding"] = data["default"]
return data
@property
def profiles(self) -> dict[str, LLMConfig]:
return self.root
@property
def default(self) -> LLMConfig:
return self.root.get("default", LLMConfig())
class EmbeddingProfilesConfig(RootModel[dict[Key, EmbeddingConfig]]):
"""Named embedding profiles, mirroring :class:`LLMProfilesConfig`.
"""Named embedding profiles keyed by profile name.
When no explicit embedding profiles are supplied, the service derives them
from the LLM profiles (see ``embedding_config_from_llm``) so existing
configs keep vectorizing through the same provider/credentials.
A ``default`` profile is always present, and an ``embedding`` alias falls
back to ``default`` so callers can address the vectorization profile
explicitly.
"""
root: dict[str, EmbeddingConfig] = Field(default_factory=lambda: {"default": EmbeddingConfig()})
View File
-65
View File
@@ -1,65 +0,0 @@
"""Document text extraction for the ``document`` modality.
Plain-text documents (``.txt``/``.md``/``.json``/...) are read directly as
UTF-8. Rich office/binary formats (PDF, Word, PowerPoint, Excel, HTML, EPub,
...) are converted to Markdown via Microsoft's `MarkItDown
<https://github.com/microsoft/markitdown>`_ so downstream memory extraction
receives clean, structure-preserving text instead of raw bytes.
MarkItDown is an optional dependency (``pip install 'memu-py[document]'``); it is
imported lazily so installations that never ingest rich documents stay slim.
"""
from __future__ import annotations
import logging
import pathlib
logger = logging.getLogger(__name__)
# Rich formats that MarkItDown converts to Markdown. Anything not listed here is
# read directly as UTF-8 text.
MARKITDOWN_EXTENSIONS: frozenset[str] = frozenset({
".pdf",
".docx",
".doc",
".pptx",
".ppt",
".xlsx",
".xls",
".html",
".htm",
".epub",
})
def is_rich_document(path: str | pathlib.Path) -> bool:
"""Whether ``path`` is a rich format that requires MarkItDown conversion."""
return pathlib.Path(path).suffix.lower() in MARKITDOWN_EXTENSIONS
def extract_document_text(path: str | pathlib.Path) -> str:
"""Extract document text, converting rich formats to Markdown via MarkItDown.
Plain-text files are read directly as UTF-8. PDF/Office/HTML/EPub files are
routed through MarkItDown's local-file converter (the narrowest API, per its
security guidance). Raises ``RuntimeError`` with an actionable message when a
rich format is encountered but MarkItDown is not installed.
"""
p = pathlib.Path(path)
if is_rich_document(p):
return _convert_with_markitdown(p)
return p.read_text(encoding="utf-8")
def _convert_with_markitdown(path: pathlib.Path) -> str:
try:
from markitdown import MarkItDown
except ImportError as exc: # pragma: no cover - exercised via tests with monkeypatch
msg = f"Converting '{path.name}' requires MarkItDown. Install it with: pip install 'memu-py[document]'"
raise RuntimeError(msg) from exc
# ``convert_local`` is the narrowest converter for trusted local files.
converter = MarkItDown(enable_plugins=False)
result = converter.convert_local(str(path))
return result.text_content or ""
-185
View File
@@ -1,185 +0,0 @@
"""Folder scanning, modality inference, and input-side manifest diffing.
The folder-based ``memorize`` entry point treats a directory as the unit of
ingestion. This module is responsible for the *input* side of the sync:
- recursively scan a folder for ingestible files,
- infer each file's modality from its extension (skipping unknown types),
- maintain a sidecar ``.memu_manifest.json`` (``relative path -> content hash``)
so each call can tell which files were added, modified, or deleted.
This is independent of the *output* side manifest used by ``memu.memory_fs``
(which hashes the rendered markdown artifacts).
"""
from __future__ import annotations
import hashlib
import json
import logging
import pathlib
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
MANIFEST_FILENAME = ".memu_manifest.json"
# Extension -> modality. Ambiguous extensions (.json, .webm) are mapped to a
# single sensible default and can be made configurable later if needed.
EXT_MODALITY: dict[str, str] = {
".json": "conversation",
".txt": "document",
".md": "document",
".text": "document",
# Rich document formats converted to Markdown via MarkItDown on ingest.
".pdf": "document",
".docx": "document",
".doc": "document",
".pptx": "document",
".ppt": "document",
".xlsx": "document",
".xls": "document",
".html": "document",
".htm": "document",
".epub": "document",
".jpg": "image",
".jpeg": "image",
".png": "image",
".gif": "image",
".webp": "image",
".mp4": "video",
".mov": "video",
".mkv": "video",
".avi": "video",
".mp3": "audio",
".wav": "audio",
".m4a": "audio",
".mpeg": "audio",
".mpga": "audio",
}
@dataclass(frozen=True)
class ScannedFile:
"""A single ingestible file discovered in the scanned folder."""
rel_path: str
abs_path: str
modality: str
content_hash: str
@dataclass
class FolderDiff:
"""The added/modified/deleted sets between a scan and the prior manifest."""
added: list[ScannedFile] = field(default_factory=list)
modified: list[ScannedFile] = field(default_factory=list)
deleted: list[str] = field(default_factory=list)
@property
def has_changes(self) -> bool:
return bool(self.added or self.modified or self.deleted)
@property
def has_removals(self) -> bool:
"""Whether any file was modified or deleted (i.e. stale memory exists)."""
return bool(self.modified or self.deleted)
def infer_modality(path: str | pathlib.Path) -> str | None:
"""Infer modality from a file extension, or None if unsupported."""
return EXT_MODALITY.get(pathlib.Path(path).suffix.lower())
def compute_file_hash(path: str | pathlib.Path) -> str:
"""Content hash (sha256) of a file, streamed to stay memory-friendly."""
digest = hashlib.sha256()
with pathlib.Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def scan_folder(folder: str | pathlib.Path) -> dict[str, ScannedFile]:
"""Recursively scan a folder for ingestible files keyed by relative path.
Hidden files/dirs (dot-prefixed), the manifest itself, and files with an
unknown extension are skipped (the latter is logged).
"""
root = pathlib.Path(folder).resolve()
scanned: dict[str, ScannedFile] = {}
if not root.is_dir():
msg = f"memorize() expects an existing folder, got: {folder}"
raise NotADirectoryError(msg)
for path in sorted(root.rglob("*")):
if not path.is_file() or path.name == MANIFEST_FILENAME:
continue
rel = path.relative_to(root)
if any(part.startswith(".") for part in rel.parts):
continue
modality = infer_modality(path)
if modality is None:
logger.warning("Skipping file with unsupported extension: %s", rel)
continue
rel_str = rel.as_posix()
scanned[rel_str] = ScannedFile(
rel_path=rel_str,
abs_path=str(path),
modality=modality,
content_hash=compute_file_hash(path),
)
return scanned
def load_manifest(folder: str | pathlib.Path) -> dict[str, str]:
"""Load the sidecar input manifest (``relative path -> content hash``)."""
manifest_path = pathlib.Path(folder).resolve() / MANIFEST_FILENAME
if not manifest_path.exists():
return {}
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
if not isinstance(data, dict):
return {}
return {str(key): str(value) for key, value in data.items()}
def save_manifest(folder: str | pathlib.Path, manifest: dict[str, str]) -> None:
"""Persist the input manifest into the scanned folder."""
manifest_path = pathlib.Path(folder).resolve() / MANIFEST_FILENAME
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
def diff_folder(scanned: dict[str, ScannedFile], manifest: dict[str, str]) -> FolderDiff:
"""Compute added/modified/deleted relative to a prior manifest."""
diff = FolderDiff()
for rel, scanned_file in scanned.items():
prior_hash = manifest.get(rel)
if prior_hash is None:
diff.added.append(scanned_file)
elif prior_hash != scanned_file.content_hash:
diff.modified.append(scanned_file)
diff.deleted = [rel for rel in manifest if rel not in scanned]
return diff
def manifest_from_scan(scanned: dict[str, ScannedFile]) -> dict[str, str]:
"""Build the manifest payload to persist from a fresh scan."""
return {rel: scanned_file.content_hash for rel, scanned_file in scanned.items()}
__all__ = [
"EXT_MODALITY",
"MANIFEST_FILENAME",
"FolderDiff",
"ScannedFile",
"compute_file_hash",
"diff_folder",
"infer_modality",
"load_manifest",
"manifest_from_scan",
"save_manifest",
]
-97
View File
@@ -1,97 +0,0 @@
from __future__ import annotations
import asyncio
import pathlib
import shutil
from urllib.parse import parse_qs, urlparse
import httpx
from memu.blob.document_text import extract_document_text
# Modalities whose ingested text is read straight from the file as UTF-8.
_PLAIN_TEXT_MODALITIES = frozenset({"conversation", "text"})
class LocalFS:
def __init__(self, base_dir: str):
self.base = pathlib.Path(base_dir)
self.base.mkdir(parents=True, exist_ok=True)
def _get_filename_from_url(self, url: str, modality: str) -> str:
"""
Extract a clean filename from URL, handling query parameters.
Args:
url: The URL to parse
modality: The resource modality (for extension inference)
Returns:
A clean filename without query parameters
"""
parsed = urlparse(url)
path = parsed.path
# Get base filename from path
filename = pathlib.Path(path).name
# If filename has no extension or is just a script name (like grab.php),
# try to get the real extension from query parameters or use modality
if not filename or "." not in filename or filename.endswith(".php"):
# Check for 'type' parameter in query string (e.g., ?type=mp3)
query_params = parse_qs(parsed.query)
if "type" in query_params:
ext = query_params["type"][0]
# Generate a filename based on the ID if available
filename = f"audio_{query_params['id'][0]}.{ext}" if "id" in query_params else f"resource.{ext}"
else:
# Use modality to infer extension
ext_map = {
"audio": "mp3",
"video": "mp4",
"image": "jpg",
"document": "txt",
}
ext = ext_map.get(modality, "bin")
filename = f"resource.{ext}"
# Remove any remaining query parameters from filename
filename = filename.split("?")[0]
return filename
async def fetch(self, url: str, modality: str) -> tuple[str, str | None]:
# Local path
p = pathlib.Path(url)
if p.exists():
dst = self.base / p.name
if str(p.resolve()) != str(dst.resolve()):
shutil.copyfile(p, dst)
text = await self._read_text(dst, modality)
return str(dst), text
# HTTP - get clean filename
filename = self._get_filename_from_url(url, modality)
dst = self.base / filename
async with httpx.AsyncClient(timeout=60) as client:
r = await client.get(url)
r.raise_for_status()
dst.write_bytes(r.content)
text = await self._read_text(dst, modality)
return str(dst), text
@staticmethod
async def _read_text(path: pathlib.Path, modality: str) -> str | None:
"""Resolve the ingested text for a fetched file based on its modality.
- ``document``: extract via MarkItDown for rich formats (PDF/Office/...),
or read plain text directly; runs off-loop since extraction is blocking.
- ``conversation``/``text``: read the file as UTF-8.
- everything else (image/audio/video): no inline text.
"""
if modality == "document":
return await asyncio.to_thread(extract_document_text, path)
if modality in _PLAIN_TEXT_MODALITIES:
return path.read_text(encoding="utf-8")
return None
+68 -130
View File
@@ -1,20 +1,18 @@
"""Command-line interface for memU.
A thin wrapper over :class:`memu.app.MemoryService` exposing the four public
entry points (``memorize``, ``memorize-workspace``, ``retrieve``,
``retrieve-workspace``) plus the memory-file export. State persists across
invocations through a SQLite database (``--db``, default ``./data/memu.sqlite3``),
so the CLI composes like the library: memorize in one call, retrieve in the next.
A thin wrapper over :class:`memu.app.MemoryService` exposing the agentic
surface: ``retrieve`` (progressive, LLM-free), ``list-files``, and ``commit``.
State persists across invocations through a SQLite database (``--db``, default
``./data/memu.sqlite3``), so the CLI composes like the library: commit in one
call, retrieve in the next.
Configuration mirrors the library defaults; every flag also reads a ``MEMU_*``
environment variable so CI/agents can configure once and pass only the command.
Usage:
memu memorize notes/meeting.md
memu memorize-workspace ./workspace
memu retrieve "What are this user's launch preferences?"
memu retrieve-workspace "deploy checklist"
memu export
memu list-files
memu commit results.json
"""
from __future__ import annotations
@@ -28,11 +26,8 @@ import sys
from collections.abc import Callable, Coroutine
from typing import Any
from memu.blob.folder import EXT_MODALITY, infer_modality
from memu.env import database_config, env
MODALITIES = ("conversation", "document", "image", "video", "audio")
def _env(name: str, default: str) -> str:
# Falls through to ~/.memu/config.env before the default, so the CLI and the
@@ -45,12 +40,12 @@ def _add_common_options(parser: argparse.ArgumentParser) -> None:
group.add_argument(
"--provider",
default=_env("MEMU_LLM_PROVIDER", "openai"),
help="LLM provider id, e.g. openai, anthropic, deepseek (MEMU_LLM_PROVIDER)",
help="Embedding provider id, e.g. openai, jina, voyage (MEMU_LLM_PROVIDER)",
)
group.add_argument(
"--model",
default=env("MEMU_CHAT_MODEL"),
help="Chat model override; defaults to the provider's default (MEMU_CHAT_MODEL)",
"--embed-model",
default=env("MEMU_EMBED_MODEL"),
help="Embedding model override; defaults to the provider's default (MEMU_EMBED_MODEL)",
)
group.add_argument(
"--base-url",
@@ -67,46 +62,24 @@ def _add_common_options(parser: argparse.ArgumentParser) -> None:
default=_env("MEMU_DB", "./data/memu.sqlite3"),
help="SQLite file path, postgres:// DSN, or ':memory:' (MEMU_DB)",
)
group.add_argument(
"--resources-dir",
default=_env("MEMU_RESOURCES_DIR", "./data/resources"),
help="Directory where ingested source files are copied (MEMU_RESOURCES_DIR)",
)
group.add_argument(
"--memory-dir",
default=_env("MEMU_MEMORY_DIR", "./data/memory"),
help="Directory for the INDEX.md/MEMORY.md/SKILL.md markdown tree (MEMU_MEMORY_DIR)",
)
group.add_argument(
"--synthesize",
action="store_true",
default=_env("MEMU_SYNTHESIZE", "") == "1",
help="LLM-synthesize MEMORY.md/SKILL.md overviews instead of deterministic indexes (MEMU_SYNTHESIZE=1)",
)
parser.add_argument("--json", action="store_true", help="Print the raw JSON response")
def _build_service(args: argparse.Namespace, *, memory_files: bool) -> Any:
def _build_service(args: argparse.Namespace) -> Any:
# Imported lazily so `memu --help` stays fast and dependency errors surface
# only when a command actually runs.
from memu.app import MemoryService
llm: dict[str, Any] = {"provider": args.provider}
if args.model:
llm["chat_model"] = args.model
profile: dict[str, Any] = {"provider": args.provider}
if args.embed_model:
profile["embed_model"] = args.embed_model
if args.base_url:
llm["base_url"] = args.base_url
profile["base_url"] = args.base_url
if args.api_key:
llm["api_key"] = args.api_key
profile["api_key"] = args.api_key
return MemoryService(
llm_profiles={"default": llm},
embedding_profiles={"default": profile},
database_config=database_config(args.db),
blob_config={"resources_dir": args.resources_dir},
memory_files_config={
"enabled": memory_files,
"output_dir": args.memory_dir,
"synthesize": args.synthesize,
},
)
@@ -114,116 +87,81 @@ def _print_json(payload: Any) -> None:
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
async def _cmd_memorize(args: argparse.Namespace) -> int:
path = pathlib.Path(args.path).expanduser()
if not path.exists():
print(f"error: no such file: {path}", file=sys.stderr)
return 2
modality = args.modality or infer_modality(path)
if modality is None:
supported = ", ".join(sorted(EXT_MODALITY))
print(
f"error: cannot infer modality for '{path.suffix}'; pass --modality (supported extensions: {supported})",
file=sys.stderr,
)
return 2
service = _build_service(args, memory_files=False)
result = await service.memorize(resource_url=str(path), modality=modality)
if args.json:
_print_json(result)
return 0
entries = result.get("items", [])
files = result.get("categories", [])
print(f"memorized {path} ({modality}): {len(entries)} entries across {len(files)} files")
for f in files:
print(f" - {f.get('name')}")
return 0
async def _cmd_memorize_workspace(args: argparse.Namespace) -> int:
folder = pathlib.Path(args.folder).expanduser()
if not folder.is_dir():
print(f"error: no such folder: {folder}", file=sys.stderr)
return 2
service = _build_service(args, memory_files=not args.no_export)
result = await service.memorize_workspace(folder=str(folder))
if args.json:
_print_json(result)
return 0
added, modified, deleted = result.get("added", []), result.get("modified", []), result.get("deleted", [])
print(f"synced {folder}: +{len(added)} added, ~{len(modified)} modified, -{len(deleted)} deleted")
for label, names in (("+", added), ("~", modified), ("-", deleted)):
for name in names:
print(f" {label} {name}")
if not args.no_export:
print(f"memory files written to {args.memory_dir}")
return 0
async def _cmd_retrieve(args: argparse.Namespace) -> int:
service = _build_service(args, memory_files=False)
result = await service.retrieve(queries=[{"role": "user", "content": {"text": args.query}}])
service = _build_service(args)
result = await service.progressive_retrieve(args.query)
_print_json(result)
return 0
async def _cmd_retrieve_workspace(args: argparse.Namespace) -> int:
service = _build_service(args, memory_files=False)
result = await service.retrieve_workspace(args.query)
_print_json(result)
return 0
async def _cmd_export(args: argparse.Namespace) -> int:
service = _build_service(args, memory_files=True)
result = await service.export_memory_files()
async def _cmd_list_files(args: argparse.Namespace) -> int:
service = _build_service(args)
result = await service.list_all_recall_files()
if args.json:
_print_json(result)
return 0
files = result.get("categories", [])
print(f"{len(files)} recall file(s)")
for f in files:
print(f" - {f.get('track')}/{f.get('name')}: {f.get('description') or ''}")
return 0
async def _cmd_commit(args: argparse.Namespace) -> int:
if args.payload == "-":
payload = json.load(sys.stdin)
else:
print(f"memory files written to {args.memory_dir}")
path = pathlib.Path(args.payload).expanduser()
if not path.exists():
print(f"error: no such file: {path}", file=sys.stderr)
return 2
payload = json.loads(path.read_text(encoding="utf-8"))
service = _build_service(args)
result = await service.commit_results(
recall_files=payload.get("recall_files"),
resource=payload.get("resource"),
)
if args.json:
_print_json(result)
return 0
recall_files = result.get("recall_files", [])
resources = result.get("resources", [])
print(f"committed {len(recall_files)} recall file(s) and {len(resources)} resource(s)")
for f in recall_files:
print(f" - {f.get('track')}/{f.get('name')}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="memu",
description="memU — personal memory as files. Memorize sources, retrieve context.",
description="memU — personal memory as files. Commit prepared memory, retrieve context.",
)
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("memorize", help="Memorize a single file (conversation, document, image, audio, video)")
p.add_argument("path", help="Path to the source file")
p.add_argument("--modality", choices=MODALITIES, help="Override the extension-inferred modality")
_add_common_options(p)
p.set_defaults(handler=_cmd_memorize)
p = sub.add_parser(
"memorize-workspace",
aliases=["sync"],
help="Diff-sync a folder into memory (chat/ -> memory, agent/ -> skills, other -> index)",
)
p.add_argument("folder", help="Workspace folder to scan and sync")
p.add_argument("--no-export", action="store_true", help="Skip rebuilding the markdown memory tree")
_add_common_options(p)
p.set_defaults(handler=_cmd_memorize_workspace)
p = sub.add_parser("retrieve", help="LLM-routed retrieval over memorized entries (heavy, high quality)")
p.add_argument("query", help="Natural-language query")
_add_common_options(p)
p.set_defaults(handler=_cmd_retrieve)
p = sub.add_parser(
"retrieve-workspace",
"retrieve",
aliases=["search"],
help="Single-shot embedding retrieval over segments/files/resources (LLM-free, fast)",
)
p.add_argument("query", help="Natural-language query")
_add_common_options(p)
p.set_defaults(handler=_cmd_retrieve_workspace)
p.set_defaults(handler=_cmd_retrieve)
p = sub.add_parser("export", help="Rebuild the INDEX.md/MEMORY.md/SKILL.md markdown tree from the store")
p = sub.add_parser("list-files", help="List every recall file across the memory and skill tracks")
_add_common_options(p)
p.set_defaults(handler=_cmd_export)
p.set_defaults(handler=_cmd_list_files)
p = sub.add_parser(
"commit",
help="Persist externally-prepared recall files and resources from a JSON payload",
)
p.add_argument(
"payload",
help='JSON file (or "-" for stdin) shaped {"recall_files": [...], "resource": [...]}',
)
_add_common_options(p)
p.set_defaults(handler=_cmd_commit)
return parser
-8
View File
@@ -3,16 +3,12 @@
from memu.database.factory import build_database
from memu.database.interfaces import (
Database,
RecallEntryRecord,
RecallFileEntryRecord,
RecallFileRecord,
RecallFileResourceRecord,
RecallFileSegmentRecord,
ResourceRecord,
)
from memu.database.repositories import (
RecallEntryRepo,
RecallFileEntryRepo,
RecallFileRepo,
RecallFileResourceRepo,
RecallFileSegmentRepo,
@@ -21,10 +17,6 @@ from memu.database.repositories import (
__all__ = [
"Database",
"RecallEntryRecord",
"RecallEntryRepo",
"RecallFileEntryRecord",
"RecallFileEntryRepo",
"RecallFileRecord",
"RecallFileRepo",
"RecallFileResourceRecord",
-4
View File
@@ -15,17 +15,13 @@ def build_inmemory_database(
(
resource_model,
recall_file_model,
recall_entry_model,
recall_file_entry_model,
recall_file_resource_model,
recall_file_segment_model,
) = build_inmemory_models(user_model)
return InMemoryStore(
scope_model=user_model,
resource_model=resource_model,
recall_entry_model=recall_entry_model,
recall_file_model=recall_file_model,
recall_file_entry_model=recall_file_entry_model,
recall_file_resource_model=recall_file_resource_model,
recall_file_segment_model=recall_file_segment_model,
)
-18
View File
@@ -3,9 +3,7 @@ from __future__ import annotations
from pydantic import BaseModel
from memu.database.models import (
RecallEntry,
RecallFile,
RecallFileEntry,
RecallFileResource,
RecallFileSegment,
Resource,
@@ -17,18 +15,10 @@ class InMemoryResource(Resource):
"""Concrete in-memory resource model."""
class InMemoryRecallEntry(RecallEntry):
"""Concrete in-memory memory item model."""
class InMemoryRecallFile(RecallFile):
"""Concrete in-memory memory category model."""
class InMemoryFileEntry(RecallFileEntry):
"""Concrete in-memory relation model."""
class InMemoryFileResource(RecallFileResource):
"""Concrete in-memory resource-category relation model."""
@@ -42,8 +32,6 @@ def build_inmemory_models(
) -> tuple[
type[InMemoryResource],
type[InMemoryRecallFile],
type[InMemoryRecallEntry],
type[InMemoryFileEntry],
type[InMemoryFileResource],
type[InMemoryFileSegment],
]:
@@ -52,25 +40,19 @@ def build_inmemory_models(
"""
resource_model = merge_scope_model(user_model, InMemoryResource, name_suffix="Resource")
recall_file_model = merge_scope_model(user_model, InMemoryRecallFile, name_suffix="RecallFile")
recall_entry_model = merge_scope_model(user_model, InMemoryRecallEntry, name_suffix="RecallEntry")
recall_file_entry_model = merge_scope_model(user_model, InMemoryFileEntry, name_suffix="RecallFileEntry")
recall_file_resource_model = merge_scope_model(user_model, InMemoryFileResource, name_suffix="RecallFileResource")
recall_file_segment_model = merge_scope_model(user_model, InMemoryFileSegment, name_suffix="RecallFileSegment")
return (
resource_model,
recall_file_model,
recall_entry_model,
recall_file_entry_model,
recall_file_resource_model,
recall_file_segment_model,
)
__all__ = [
"InMemoryFileEntry",
"InMemoryFileResource",
"InMemoryFileSegment",
"InMemoryRecallEntry",
"InMemoryRecallFile",
"InMemoryResource",
"build_inmemory_models",
-16
View File
@@ -6,19 +6,15 @@ from pydantic import BaseModel
from memu.database.inmemory.models import build_inmemory_models
from memu.database.inmemory.repositories import (
InMemoryFileEntryRepository,
InMemoryFileResourceRepository,
InMemoryFileSegmentRepository,
InMemoryRecallEntryRepository,
InMemoryRecallFileRepository,
InMemoryResourceRepository,
)
from memu.database.inmemory.state import InMemoryState
from memu.database.interfaces import Database
from memu.database.models import (
RecallEntry,
RecallFile,
RecallFileEntry,
RecallFileResource,
RecallFileSegment,
Resource,
@@ -32,9 +28,7 @@ class InMemoryStore(Database):
*,
scope_model: type[BaseModel] | None = None,
resource_model: type[Any] | None = None,
recall_entry_model: type[Any] | None = None,
recall_file_model: type[Any] | None = None,
recall_file_entry_model: type[Any] | None = None,
recall_file_resource_model: type[Any] | None = None,
recall_file_segment_model: type[Any] | None = None,
state: InMemoryState | None = None,
@@ -43,24 +37,18 @@ class InMemoryStore(Database):
(
default_resource_model,
default_recall_file_model,
default_recall_entry_model,
default_recall_file_entry_model,
default_recall_file_resource_model,
default_recall_file_segment_model,
) = build_inmemory_models(self.scope_model)
self.state = state or InMemoryState()
self.resources: dict[str, Resource] = self.state.resources
self.items: dict[str, RecallEntry] = self.state.items
self.categories: dict[str, RecallFile] = self.state.categories
self.relations: list[RecallFileEntry] = self.state.relations
self.resource_relations: list[RecallFileResource] = self.state.resource_relations
self.segments: list[RecallFileSegment] = self.state.segments
resource_model = resource_model or default_resource_model or Resource
recall_entry_model = recall_entry_model or default_recall_entry_model or RecallEntry
recall_file_model = recall_file_model or default_recall_file_model or RecallFile
recall_file_entry_model = recall_file_entry_model or default_recall_file_entry_model or RecallFileEntry
recall_file_resource_model = (
recall_file_resource_model or default_recall_file_resource_model or RecallFileResource
)
@@ -70,10 +58,6 @@ class InMemoryStore(Database):
self.recall_file_repo: RecallFileRepo = InMemoryRecallFileRepository(
state=self.state, recall_file_model=recall_file_model
)
self.recall_entry_repo = InMemoryRecallEntryRepository(state=self.state, recall_entry_model=recall_entry_model)
self.recall_file_entry_repo = InMemoryFileEntryRepository(
state=self.state, recall_file_entry_model=recall_file_entry_model
)
self.recall_file_resource_repo = InMemoryFileResourceRepository(
state=self.state, recall_file_resource_model=recall_file_resource_model
)
@@ -1,8 +1,3 @@
from memu.database.inmemory.repositories.recall_entry_repo import InMemoryRecallEntryRepository, RecallEntryRepo
from memu.database.inmemory.repositories.recall_file_entry_repo import (
InMemoryFileEntryRepository,
RecallFileEntryRepo,
)
from memu.database.inmemory.repositories.recall_file_repo import (
InMemoryRecallFileRepository,
RecallFileRepo,
@@ -18,14 +13,10 @@ from memu.database.inmemory.repositories.recall_file_segment_repo import (
from memu.database.inmemory.repositories.resource_repo import InMemoryResourceRepository, ResourceRepo
__all__ = [
"InMemoryFileEntryRepository",
"InMemoryFileResourceRepository",
"InMemoryFileSegmentRepository",
"InMemoryRecallEntryRepository",
"InMemoryRecallFileRepository",
"InMemoryResourceRepository",
"RecallEntryRepo",
"RecallFileEntryRepo",
"RecallFileRepo",
"RecallFileResourceRepo",
"RecallFileSegmentRepo",
@@ -1,263 +0,0 @@
from __future__ import annotations
import uuid
from collections.abc import Mapping
from typing import Any, override
import pendulum
from memu.database.inmemory.repositories.filter import matches_where
from memu.database.inmemory.state import InMemoryState
from memu.database.models import EntryType, RecallEntry, compute_content_hash
from memu.database.repositories.recall_entry import RecallEntryRepo
from memu.vector import cosine_topk, cosine_topk_salience
class InMemoryRecallEntryRepository(RecallEntryRepo):
def __init__(self, *, state: InMemoryState, recall_entry_model: type[RecallEntry]) -> None:
self._state = state
self.recall_entry_model = recall_entry_model
self.items: dict[str, RecallEntry] = self._state.items
def list_items(self, where: Mapping[str, Any] | None = None) -> dict[str, RecallEntry]:
if not where:
return dict(self.items)
return {mid: item for mid, item in self.items.items() if matches_where(item, where)}
def list_items_by_ref_ids(
self, ref_ids: list[str], where: Mapping[str, Any] | None = None
) -> dict[str, RecallEntry]:
"""List items by their ref_id in the extra column.
Args:
ref_ids: List of ref_ids to query.
where: Additional filter conditions.
Returns:
Dict mapping item_id -> RecallEntry for items whose extra.ref_id is in ref_ids.
"""
if not ref_ids:
return {}
ref_id_set = set(ref_ids)
result: dict[str, RecallEntry] = {}
for mid, item in self.items.items():
# Check where filter first
if where and not matches_where(item, where):
continue
# Check if ref_id is in the requested set
item_ref_id = (item.extra or {}).get("ref_id")
if item_ref_id and item_ref_id in ref_id_set:
result[mid] = item
return result
def clear_items(self, where: Mapping[str, Any] | None = None) -> dict[str, RecallEntry]:
if not where:
matches = self.items.copy()
self.items.clear()
return matches
matches = {mid: item for mid, item in self.items.items() if matches_where(item, where)}
for mid in matches:
self.items.pop(mid, None)
return matches
def _find_by_hash(self, content_hash: str, user_data: dict[str, Any]) -> RecallEntry | None:
"""
Find existing item by content hash within the same user scope.
This enables deduplication: if the same content exists for the same user,
we reinforce it instead of creating a duplicate.
"""
for item in self.items.values():
# Read content_hash from extra dict
item_hash = (item.extra or {}).get("content_hash")
if item_hash != content_hash:
continue
# Check scope match (user_id, agent_id, etc.)
if matches_where(item, user_data):
return item
return None
def create_item(
self,
*,
resource_id: str,
memory_type: EntryType,
summary: str,
embedding: list[float],
user_data: dict[str, Any],
reinforce: bool = False,
tool_record: dict[str, Any] | None = None,
) -> RecallEntry:
if reinforce and memory_type != "tool":
return self.create_item_reinforce(
resource_id=resource_id,
memory_type=memory_type,
summary=summary,
embedding=embedding,
user_data=user_data,
)
# Build extra dict with tool_record fields at top level
extra: dict[str, Any] = {}
if tool_record:
if tool_record.get("when_to_use") is not None:
extra["when_to_use"] = tool_record["when_to_use"]
if tool_record.get("metadata") is not None:
extra["metadata"] = tool_record["metadata"]
if tool_record.get("tool_calls") is not None:
extra["tool_calls"] = tool_record["tool_calls"]
mid = str(uuid.uuid4())
it = self.recall_entry_model(
id=mid,
resource_id=resource_id,
memory_type=memory_type,
summary=summary,
embedding=embedding,
extra=extra if extra else {},
**user_data,
)
self.items[mid] = it
return it
def create_item_reinforce(
self,
*,
resource_id: str,
memory_type: EntryType,
summary: str,
embedding: list[float],
user_data: dict[str, Any],
reinforce: bool = False,
) -> RecallEntry:
content_hash = compute_content_hash(summary, memory_type)
# Check for existing item with same hash in same scope (deduplication)
existing = self._find_by_hash(content_hash, user_data)
if existing:
# Reinforce existing memory instead of creating duplicate
current_extra = existing.extra or {}
current_count = current_extra.get("reinforcement_count", 1)
existing.extra = {
**current_extra,
"reinforcement_count": current_count + 1,
"last_reinforced_at": pendulum.now("UTC").isoformat(),
}
existing.updated_at = pendulum.now("UTC")
return existing
# Create new item with salience tracking in extra
mid = str(uuid.uuid4())
now = pendulum.now("UTC")
item_extra = user_data.pop("extra", {}) if "extra" in user_data else {}
item_extra.update({
"content_hash": content_hash,
"reinforcement_count": 1,
"last_reinforced_at": now.isoformat(),
})
it = self.recall_entry_model(
id=mid,
resource_id=resource_id,
memory_type=memory_type,
summary=summary,
embedding=embedding,
extra=item_extra,
**user_data,
)
self.items[mid] = it
return it
def vector_search_items(
self,
query_vec: list[float],
top_k: int,
where: Mapping[str, Any] | None = None,
*,
ranking: str = "similarity",
recency_decay_days: float = 30.0,
) -> list[tuple[str, float]]:
pool = self.list_items(where)
if ranking == "salience":
# Salience-aware ranking: similarity x reinforcement x recency
# Read values from extra dict
corpus = [
(
i.id,
i.embedding,
(i.extra or {}).get("reinforcement_count", 1),
self._parse_datetime((i.extra or {}).get("last_reinforced_at")),
)
for i in pool.values()
]
return cosine_topk_salience(query_vec, corpus, k=top_k, recency_decay_days=recency_decay_days)
# Default: pure cosine similarity (backward compatible)
hits = cosine_topk(query_vec, [(i.id, i.embedding) for i in pool.values()], k=top_k)
return hits
def load_existing(self) -> None:
return None
def get_item(self, item_id: str) -> RecallEntry | None:
return self.items.get(item_id)
@staticmethod
def _parse_datetime(dt_str: str | None) -> pendulum.DateTime | None:
"""Parse ISO datetime string from extra dict."""
if dt_str is None:
return None
try:
parsed = pendulum.parse(dt_str)
except (ValueError, TypeError):
return None
else:
if isinstance(parsed, pendulum.DateTime):
return parsed
return None
@override
def delete_item(self, item_id: str) -> None:
if item_id in self.items:
del self.items[item_id]
@override
def update_item(
self,
*,
item_id: str,
memory_type: EntryType | None = None,
summary: str | None = None,
embedding: list[float] | None = None,
extra: dict[str, Any] | None = None,
tool_record: dict[str, Any] | None = None,
) -> RecallEntry:
item = self.items.get(item_id)
if item is None:
msg = f"Item with id {item_id} not found"
raise KeyError(msg)
if memory_type is not None:
item.memory_type = memory_type
if summary is not None:
item.summary = summary
if embedding is not None:
item.embedding = embedding
# Merge extra and tool_record into existing extra dict
current_extra = item.extra or {}
if extra is not None:
current_extra = {**current_extra, **extra}
if tool_record is not None:
# Merge tool_record fields at top level
for key in ("when_to_use", "metadata", "tool_calls"):
if tool_record.get(key) is not None:
current_extra[key] = tool_record[key]
if extra is not None or tool_record is not None:
item.extra = current_extra
self.items[item_id] = item
return item
__all__ = ["InMemoryRecallEntryRepository"]
@@ -1,65 +0,0 @@
from __future__ import annotations
import uuid
from collections.abc import Mapping
from typing import Any, override
from memu.database.inmemory.repositories.filter import matches_where
from memu.database.inmemory.state import InMemoryState
from memu.database.models import RecallFileEntry
from memu.database.repositories.recall_file_entry import RecallFileEntryRepo
class InMemoryFileEntryRepository(RecallFileEntryRepo):
def __init__(self, *, state: InMemoryState, recall_file_entry_model: type[RecallFileEntry]) -> None:
self._state = state
self.recall_file_entry_model = recall_file_entry_model
self.relations: list[RecallFileEntry] = self._state.relations
def list_relations(self, where: Mapping[str, Any] | None = None) -> list[RecallFileEntry]:
if not where:
return list(self.relations)
return [rel for rel in self.relations if matches_where(rel, where)]
def link_item_category(self, item_id: str, cat_id: str, user_data: dict[str, Any]) -> RecallFileEntry:
_ = item_id # enforced by caller via existing state
for rel in self.relations:
if rel.item_id == item_id and rel.category_id == cat_id:
return rel
rel = self.recall_file_entry_model(id=str(uuid.uuid4()), item_id=item_id, category_id=cat_id, **user_data)
self.relations.append(rel)
return rel
def load_existing(self) -> None:
return None
@override
def get_item_categories(self, item_id: str) -> list[RecallFileEntry]:
return [rel for rel in self.relations if rel.item_id == item_id]
@override
def unlink_item_category(self, item_id: str, cat_id: str) -> None:
# Mutate the shared state list in place so the DatabaseState reference and
# this repo's view never diverge (rebinding self.relations would orphan the
# shared state.relations list).
self.relations[:] = [
rel for rel in self.relations if not (rel.item_id == item_id and rel.category_id == cat_id)
]
def unlink_item(self, item_id: str) -> list[RecallFileEntry]:
removed = [rel for rel in self.relations if rel.item_id == item_id]
self.relations[:] = [rel for rel in self.relations if rel.item_id != item_id]
return removed
def clear_relations(self, where: Mapping[str, Any] | None = None) -> list[RecallFileEntry]:
if not where:
removed = list(self.relations)
self.relations.clear()
return removed
removed = [rel for rel in self.relations if matches_where(rel, where)]
removed_ids = {rel.id for rel in removed}
self.relations[:] = [rel for rel in self.relations if rel.id not in removed_ids]
return removed
__all__ = ["InMemoryFileEntryRepository"]
@@ -39,7 +39,6 @@ class InMemoryResourceRepository(ResourceRepoProtocol):
self,
*,
url: str,
modality: str,
local_path: str,
caption: str | None,
embedding: list[float] | None,
@@ -50,7 +49,6 @@ class InMemoryResourceRepository(ResourceRepoProtocol):
res = self.resource_model(
id=rid,
url=url,
modality=modality,
local_path=local_path,
caption=caption,
embedding=embedding,
+3 -3
View File
@@ -1,6 +1,6 @@
"""Backward-compatible re-export of the storage-neutral vector helpers.
The actual cosine/salience math now lives in :mod:`memu.vector` so that neither
The actual cosine math now lives in :mod:`memu.vector` so that neither
the app retrieval layer nor any database backend has to depend on the in-memory
backend's internals. This shim is kept so existing imports
(``memu.database.inmemory.vector``) keep working.
@@ -8,6 +8,6 @@ backend's internals. This shim is kept so existing imports
from __future__ import annotations
from memu.vector import _cosine, cosine_topk, cosine_topk_salience, salience_score
from memu.vector import _cosine, cosine_topk
__all__ = ["_cosine", "cosine_topk", "cosine_topk_salience", "salience_score"]
__all__ = ["_cosine", "cosine_topk"]
-10
View File
@@ -2,15 +2,11 @@ from __future__ import annotations
from typing import Protocol, runtime_checkable
from memu.database.models import RecallEntry as RecallEntryRecord
from memu.database.models import RecallFile as RecallFileRecord
from memu.database.models import RecallFileEntry as RecallFileEntryRecord
from memu.database.models import RecallFileResource as RecallFileResourceRecord
from memu.database.models import RecallFileSegment as RecallFileSegmentRecord
from memu.database.models import Resource as ResourceRecord
from memu.database.repositories import (
RecallEntryRepo,
RecallFileEntryRepo,
RecallFileRepo,
RecallFileResourceRepo,
RecallFileSegmentRepo,
@@ -24,15 +20,11 @@ class Database(Protocol):
resource_repo: ResourceRepo
recall_file_repo: RecallFileRepo
recall_entry_repo: RecallEntryRepo
recall_file_entry_repo: RecallFileEntryRepo
recall_file_resource_repo: RecallFileResourceRepo
recall_file_segment_repo: RecallFileSegmentRepo
resources: dict[str, ResourceRecord]
items: dict[str, RecallEntryRecord]
categories: dict[str, RecallFileRecord]
relations: list[RecallFileEntryRecord]
resource_relations: list[RecallFileResourceRecord]
segments: list[RecallFileSegmentRecord]
@@ -41,8 +33,6 @@ class Database(Protocol):
__all__ = [
"Database",
"RecallEntryRecord",
"RecallFileEntryRecord",
"RecallFileRecord",
"RecallFileResourceRecord",
"RecallFileSegmentRecord",
+2 -89
View File
@@ -1,36 +1,11 @@
from __future__ import annotations
import hashlib
import json
import uuid
from datetime import datetime
from typing import Any, Literal
import pendulum
from pydantic import BaseModel, ConfigDict, Field
EntryType = Literal["profile", "event", "knowledge", "behavior", "skill", "tool"]
def compute_content_hash(summary: str, memory_type: str) -> str:
"""
Generate unique hash for memory deduplication.
Operates on post-summary content. Normalizes whitespace to handle
minor formatting differences like "I love coffee" vs "I love coffee".
Args:
summary: The memory summary text
memory_type: The type of memory (profile, event, etc.)
Returns:
A 16-character hex hash string
"""
# Normalize: lowercase, strip, collapse whitespace
normalized = " ".join(summary.lower().split())
content = f"{memory_type}:{normalized}"
return hashlib.sha256(content.encode()).hexdigest()[:16]
class BaseRecord(BaseModel):
"""Backend-agnostic record interface."""
@@ -40,62 +15,16 @@ class BaseRecord(BaseModel):
updated_at: datetime = Field(default_factory=lambda: pendulum.now("UTC"))
class ToolCallResult(BaseModel):
"""Represents the result of a tool invocation for Tool Memory."""
tool_name: str = Field(..., description="Name of the tool that was called")
input: dict[str, Any] | str = Field(default="", description="Tool input parameters")
output: str = Field(default="", description="Tool output result")
success: bool = Field(default=True, description="Whether the tool invocation succeeded")
time_cost: float = Field(default=0.0, description="Time consumed by the tool invocation in seconds")
token_cost: int = Field(default=-1, description="Token consumption of the tool (-1 if unknown)")
score: float = Field(default=0.0, description="Quality score from 0.0 to 1.0")
call_hash: str = Field(default="", description="Hash of input+output for deduplication")
created_at: datetime = Field(default_factory=lambda: pendulum.now("UTC"))
def generate_hash(self) -> str:
"""Generate MD5 hash from tool input and output for deduplication."""
input_str = json.dumps(self.input, sort_keys=True) if isinstance(self.input, dict) else str(self.input)
combined = f"{self.tool_name}|{input_str}|{self.output}"
return hashlib.md5(combined.encode("utf-8"), usedforsecurity=False).hexdigest()
def ensure_hash(self) -> None:
"""Ensure call_hash is set, generate if empty."""
if not self.call_hash:
self.call_hash = self.generate_hash()
class Resource(BaseRecord):
url: str
modality: str
local_path: str
caption: str | None = None
embedding: list[float] | None = None
# Which workspace track this resource came from: "chat", "skill", or
# "workspace" (set by ``memorize_workspace``). None for legacy ``memorize``.
# Which workspace track this resource came from. ``commit_results`` writes
# "workspace", which is the track ``progressive_retrieve`` surfaces.
track: str | None = None
class RecallEntry(BaseRecord):
resource_id: str | None
memory_type: str
summary: str
embedding: list[float] | None = None
happened_at: datetime | None = None
extra: dict[str, Any] = {}
# extra may contain:
# # reinforcement tracking fields
# - content_hash: str
# - reinforcement_count: int
# - last_reinforced_at: str (isoformat)
# # Reference tracking field
# - ref_id: str
# # Tool memory fields
# - when_to_use: str - Hint for when this memory should be retrieved
# - metadata: dict - Type-specific metadata (e.g., tool_name, avg_success_rate)
# - tool_calls: list[dict] - Tool call history for tool memories (serialized ToolCallResult)
class RecallFile(BaseRecord):
name: str
# Which track this file belongs to: "memory" (a memory category) or "skill"
@@ -106,11 +35,6 @@ class RecallFile(BaseRecord):
content: str | None = None
class RecallFileEntry(BaseRecord):
item_id: str
category_id: str
class RecallFileResource(BaseRecord):
resource_id: str
file_id: str
@@ -157,8 +81,6 @@ def build_scoped_models(
) -> tuple[
type[Resource],
type[RecallFile],
type[RecallEntry],
type[RecallFileEntry],
type[RecallFileResource],
type[RecallFileSegment],
]:
@@ -167,15 +89,11 @@ def build_scoped_models(
"""
resource_model = merge_scope_model(user_model, Resource, name_suffix="Resource")
recall_file_model = merge_scope_model(user_model, RecallFile, name_suffix="RecallFile")
recall_entry_model = merge_scope_model(user_model, RecallEntry, name_suffix="RecallEntry")
recall_file_entry_model = merge_scope_model(user_model, RecallFileEntry, name_suffix="RecallFileEntry")
recall_file_resource_model = merge_scope_model(user_model, RecallFileResource, name_suffix="RecallFileResource")
recall_file_segment_model = merge_scope_model(user_model, RecallFileSegment, name_suffix="RecallFileSegment")
return (
resource_model,
recall_file_model,
recall_entry_model,
recall_file_entry_model,
recall_file_resource_model,
recall_file_segment_model,
)
@@ -183,15 +101,10 @@ def build_scoped_models(
__all__ = [
"BaseRecord",
"EntryType",
"RecallEntry",
"RecallFile",
"RecallFileEntry",
"RecallFileResource",
"RecallFileSegment",
"Resource",
"ToolCallResult",
"build_scoped_models",
"compute_content_hash",
"merge_scope_model",
]
-2
View File
@@ -27,8 +27,6 @@ def build_postgres_database(
scope_model=user_model,
resource_model=sqla_models.Resource,
recall_file_model=sqla_models.RecallFile,
recall_entry_model=sqla_models.RecallEntry,
recall_file_entry_model=sqla_models.RecallFileEntry,
sqla_models=sqla_models,
)
@@ -13,7 +13,6 @@ from collections.abc import Sequence
import pgvector.sqlalchemy
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260703_0001"
@@ -49,7 +48,6 @@ def upgrade() -> None:
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("url", sa.String(), nullable=False),
sa.Column("modality", sa.String(), nullable=False),
sa.Column("local_path", sa.String(), nullable=False),
sa.Column("caption", sa.Text(), nullable=True),
sa.Column("embedding", pgvector.sqlalchemy.VECTOR(), nullable=True),
@@ -60,24 +58,6 @@ def upgrade() -> None:
)
op.create_index("ix_resources__scope", "resources", ["user_id", "agent_id"], unique=False)
op.create_index(op.f("ix_resources_id"), "resources", ["id"], unique=False)
op.create_table(
"recall_entries",
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("resource_id", sa.String(), nullable=True),
sa.Column("memory_type", sa.String(), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("embedding", pgvector.sqlalchemy.VECTOR(), nullable=True),
sa.Column("happened_at", sa.DateTime(), nullable=True),
sa.Column("extra", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("agent_id", sa.String(), nullable=True),
sa.ForeignKeyConstraint(["resource_id"], ["resources.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_recall_entries__scope", "recall_entries", ["user_id", "agent_id"], unique=False)
op.create_index(op.f("ix_recall_entries_id"), "recall_entries", ["id"], unique=False)
op.create_table(
"recall_file_resources",
sa.Column("id", sa.String(), nullable=False),
@@ -115,31 +95,11 @@ def upgrade() -> None:
op.create_index(
op.f("ix_recall_file_segments_recall_file_id"), "recall_file_segments", ["recall_file_id"], unique=False
)
op.create_table(
"recall_file_entries",
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("item_id", sa.String(), nullable=False),
sa.Column("category_id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("agent_id", sa.String(), nullable=True),
sa.ForeignKeyConstraint(["category_id"], ["recall_files.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["item_id"], ["recall_entries.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_recall_file_entries_unique", "recall_file_entries", ["item_id", "category_id"], unique=True)
op.create_index("ix_recall_file_entries__scope", "recall_file_entries", ["user_id", "agent_id"], unique=False)
op.create_index(op.f("ix_recall_file_entries_id"), "recall_file_entries", ["id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_recall_file_entries_id"), table_name="recall_file_entries")
op.drop_index("ix_recall_file_entries__scope", table_name="recall_file_entries")
op.drop_index("idx_recall_file_entries_unique", table_name="recall_file_entries")
op.drop_table("recall_file_entries")
op.drop_index(op.f("ix_recall_file_segments_recall_file_id"), table_name="recall_file_segments")
op.drop_index(op.f("ix_recall_file_segments_id"), table_name="recall_file_segments")
op.drop_index("ix_recall_file_segments__scope", table_name="recall_file_segments")
@@ -148,9 +108,6 @@ def downgrade() -> None:
op.drop_index("ix_recall_file_resources__scope", table_name="recall_file_resources")
op.drop_index("idx_recall_file_resources_unique", table_name="recall_file_resources")
op.drop_table("recall_file_resources")
op.drop_index(op.f("ix_recall_entries_id"), table_name="recall_entries")
op.drop_index("ix_recall_entries__scope", table_name="recall_entries")
op.drop_table("recall_entries")
op.drop_index(op.f("ix_resources_id"), table_name="resources")
op.drop_index("ix_resources__scope", table_name="resources")
op.drop_table("resources")
+2 -29
View File
@@ -14,14 +14,10 @@ except ImportError as exc:
from pydantic import BaseModel
from sqlalchemy import ForeignKey, MetaData, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Column, DateTime, Field, Index, SQLModel, func
from memu.database.models import (
EntryType,
RecallEntry,
RecallFile,
RecallFileEntry,
RecallFileResource,
RecallFileSegment,
Resource,
@@ -53,22 +49,12 @@ class BaseModelMixin(SQLModel):
class ResourceModel(BaseModelMixin, Resource):
url: str = Field(sa_column=Column(String, nullable=False))
modality: str = Field(sa_column=Column(String, nullable=False))
local_path: str = Field(sa_column=Column(String, nullable=False))
caption: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
embedding: list[float] | None = Field(default=None, sa_column=Column(Vector(), nullable=True))
track: str | None = Field(default=None, sa_column=Column(String, nullable=True))
class RecallEntryModel(BaseModelMixin, RecallEntry):
resource_id: str | None = Field(sa_column=Column(ForeignKey("resources.id", ondelete="CASCADE"), nullable=True))
memory_type: EntryType = Field(sa_column=Column(String, nullable=False))
summary: str = Field(sa_column=Column(Text, nullable=False))
embedding: list[float] | None = Field(default=None, sa_column=Column(Vector(), nullable=True))
happened_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
extra: dict[str, Any] = Field(default={}, sa_column=Column(JSONB, nullable=True))
class RecallFileModel(BaseModelMixin, RecallFile):
name: str = Field(sa_column=Column(String, nullable=False, index=True))
description: str = Field(sa_column=Column(Text, nullable=False))
@@ -77,13 +63,6 @@ class RecallFileModel(BaseModelMixin, RecallFile):
track: str = Field(default="memory", sa_column=Column(String, nullable=False, server_default="memory"))
class RecallFileEntryModel(BaseModelMixin, RecallFileEntry):
item_id: str = Field(sa_column=Column(ForeignKey("recall_entries.id", ondelete="CASCADE"), nullable=False))
category_id: str = Field(sa_column=Column(ForeignKey("recall_files.id", ondelete="CASCADE"), nullable=False))
__table_args__ = (Index("idx_recall_file_entries_unique", "item_id", "category_id", unique=True),)
class RecallFileResourceModel(BaseModelMixin, RecallFileResource):
resource_id: str = Field(sa_column=Column(ForeignKey("resources.id", ondelete="CASCADE"), nullable=False))
file_id: str = Field(sa_column=Column(ForeignKey("recall_files.id", ondelete="CASCADE"), nullable=False))
@@ -182,16 +161,14 @@ def build_table_model(
def build_scoped_models(
user_model: type[BaseModel],
) -> tuple[type[SQLModel], type[SQLModel], type[SQLModel], type[SQLModel], type[SQLModel], type[SQLModel]]:
) -> tuple[type[SQLModel], type[SQLModel], type[SQLModel], type[SQLModel]]:
"""
Build scoped SQLModel tables for each entity (resource, category, item, relation, segment).
Build scoped SQLModel tables for each entity (resource, category, relation, segment).
"""
resource_model = build_table_model(user_model, ResourceModel, tablename="resources")
recall_file_model = build_table_model(
user_model, RecallFileModel, tablename="recall_files", unique_with_scope=["name"]
)
recall_entry_model = build_table_model(user_model, RecallEntryModel, tablename="recall_entries")
recall_file_entry_model = build_table_model(user_model, RecallFileEntryModel, tablename="recall_file_entries")
recall_file_resource_model = build_table_model(
user_model, RecallFileResourceModel, tablename="recall_file_resources"
)
@@ -199,8 +176,6 @@ def build_scoped_models(
return (
resource_model,
recall_file_model,
recall_entry_model,
recall_file_entry_model,
recall_file_resource_model,
recall_file_segment_model,
)
@@ -208,8 +183,6 @@ def build_scoped_models(
__all__ = [
"BaseModelMixin",
"RecallEntryModel",
"RecallFileEntryModel",
"RecallFileModel",
"RecallFileResourceModel",
"RecallFileSegmentModel",
-33
View File
@@ -7,16 +7,12 @@ from pydantic import BaseModel
from memu.database.interfaces import Database
from memu.database.models import (
RecallEntry,
RecallFile,
RecallFileEntry,
RecallFileResource,
RecallFileSegment,
Resource,
)
from memu.database.postgres.migration import DDLMode, run_migrations
from memu.database.postgres.repositories.recall_entry_repo import PostgresRecallEntryRepo
from memu.database.postgres.repositories.recall_file_entry_repo import PostgresRecallFileEntryRepo
from memu.database.postgres.repositories.recall_file_repo import PostgresRecallFileRepo
from memu.database.postgres.repositories.recall_file_resource_repo import PostgresRecallFileResourceRepo
from memu.database.postgres.repositories.recall_file_segment_repo import PostgresRecallFileSegmentRepo
@@ -24,8 +20,6 @@ from memu.database.postgres.repositories.resource_repo import PostgresResourceRe
from memu.database.postgres.schema import SQLAModels, get_sqlalchemy_models, require_sqlalchemy
from memu.database.postgres.session import SessionManager
from memu.database.repositories import (
RecallEntryRepo,
RecallFileEntryRepo,
RecallFileRepo,
RecallFileResourceRepo,
RecallFileSegmentRepo,
@@ -39,14 +33,10 @@ logger = logging.getLogger(__name__)
class PostgresStore(Database):
resource_repo: ResourceRepo
recall_file_repo: RecallFileRepo
recall_entry_repo: RecallEntryRepo
recall_file_entry_repo: RecallFileEntryRepo
recall_file_resource_repo: RecallFileResourceRepo
recall_file_segment_repo: RecallFileSegmentRepo
resources: dict[str, Resource]
items: dict[str, RecallEntry]
categories: dict[str, RecallFile]
relations: list[RecallFileEntry]
resource_relations: list[RecallFileResource]
segments: list[RecallFileSegment]
@@ -60,8 +50,6 @@ class PostgresStore(Database):
base_model: type[BaseModel] | None = None,
resource_model: type[Any] | None = None,
recall_file_model: type[Any] | None = None,
recall_entry_model: type[Any] | None = None,
recall_file_entry_model: type[Any] | None = None,
recall_file_resource_model: type[Any] | None = None,
recall_file_segment_model: type[Any] | None = None,
sqla_models: SQLAModels | None = None,
@@ -80,8 +68,6 @@ class PostgresStore(Database):
resource_model = resource_model or self._sqla_models.Resource
recall_file_model = recall_file_model or self._sqla_models.RecallFile
recall_entry_model = recall_entry_model or self._sqla_models.RecallEntry
recall_file_entry_model = recall_file_entry_model or self._sqla_models.RecallFileEntry
recall_file_resource_model = recall_file_resource_model or self._sqla_models.RecallFileResource
recall_file_segment_model = recall_file_segment_model or self._sqla_models.RecallFileSegment
@@ -99,21 +85,6 @@ class PostgresStore(Database):
sessions=self._sessions,
scope_fields=self._scope_fields,
)
self.recall_entry_repo = PostgresRecallEntryRepo(
state=self._state,
recall_entry_model=recall_entry_model,
sqla_models=self._sqla_models,
sessions=self._sessions,
scope_fields=self._scope_fields,
use_vector=self._use_vector_type,
)
self.recall_file_entry_repo = PostgresRecallFileEntryRepo(
state=self._state,
recall_file_entry_model=recall_file_entry_model,
sqla_models=self._sqla_models,
sessions=self._sessions,
scope_fields=self._scope_fields,
)
self.recall_file_resource_repo = PostgresRecallFileResourceRepo(
state=self._state,
recall_file_resource_model=recall_file_resource_model,
@@ -130,9 +101,7 @@ class PostgresStore(Database):
)
self.resources = self._state.resources
self.items = self._state.items
self.categories = self._state.categories
self.relations = self._state.relations
self.resource_relations = self._state.resource_relations
self.segments = self._state.segments
@@ -144,7 +113,5 @@ class PostgresStore(Database):
def _load_existing(self) -> None:
self.resource_repo.load_existing()
self.recall_file_repo.load_existing()
self.recall_entry_repo.load_existing()
self.recall_file_entry_repo.load_existing()
self.recall_file_resource_repo.load_existing()
self.recall_file_segment_repo.load_existing()
@@ -1,13 +1,9 @@
from memu.database.postgres.repositories.recall_entry_repo import PostgresRecallEntryRepo
from memu.database.postgres.repositories.recall_file_entry_repo import PostgresRecallFileEntryRepo
from memu.database.postgres.repositories.recall_file_repo import PostgresRecallFileRepo
from memu.database.postgres.repositories.recall_file_resource_repo import PostgresRecallFileResourceRepo
from memu.database.postgres.repositories.recall_file_segment_repo import PostgresRecallFileSegmentRepo
from memu.database.postgres.repositories.resource_repo import PostgresResourceRepo
__all__ = [
"PostgresRecallEntryRepo",
"PostgresRecallFileEntryRepo",
"PostgresRecallFileRepo",
"PostgresRecallFileResourceRepo",
"PostgresRecallFileSegmentRepo",
@@ -1,401 +0,0 @@
from __future__ import annotations
import math
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from memu.database.models import EntryType, RecallEntry, compute_content_hash
from memu.database.postgres.repositories.base import PostgresRepoBase
from memu.database.postgres.session import SessionManager
from memu.database.state import DatabaseState
class PostgresRecallEntryRepo(PostgresRepoBase):
def __init__(
self,
*,
state: DatabaseState,
recall_entry_model: type[RecallEntry],
sqla_models: Any,
sessions: SessionManager,
scope_fields: list[str],
use_vector: bool,
) -> None:
super().__init__(
state=state, sqla_models=sqla_models, sessions=sessions, scope_fields=scope_fields, use_vector=use_vector
)
self._recall_entry_model = recall_entry_model
self.items: dict[str, RecallEntry] = self._state.items
def get_item(self, memory_id: str) -> RecallEntry | None:
from sqlmodel import select
with self._sessions.session() as session:
row = session.scalar(
select(self._sqla_models.RecallEntry).where(self._sqla_models.RecallEntry.id == memory_id)
)
if row:
row.embedding = self._normalize_embedding(row.embedding)
return self._cache_item(row)
return None
def list_items(self, where: Mapping[str, Any] | None = None) -> dict[str, RecallEntry]:
from sqlmodel import select
filters = self._build_filters(self._sqla_models.RecallEntry, where)
with self._sessions.session() as session:
rows = session.scalars(select(self._sqla_models.RecallEntry).where(*filters)).all()
result: dict[str, RecallEntry] = {}
for row in rows:
row.embedding = self._normalize_embedding(row.embedding)
item = self._cache_item(row)
result[item.id] = item
return result
def list_items_by_ref_ids(
self, ref_ids: list[str], where: Mapping[str, Any] | None = None
) -> dict[str, RecallEntry]:
"""List items by their ref_id in the extra column.
Args:
ref_ids: List of ref_ids to query.
where: Additional filter conditions.
Returns:
Dict mapping item_id -> RecallEntry for items whose extra->>'ref_id' is in ref_ids.
"""
if not ref_ids:
return {}
from sqlmodel import select
filters = self._build_filters(self._sqla_models.RecallEntry, where)
# Add filter for extra->>'ref_id' IN ref_ids (only rows with ref_id key)
ref_id_col = self._sqla_models.RecallEntry.extra["ref_id"].astext
filters.append(ref_id_col.isnot(None))
filters.append(ref_id_col.in_(ref_ids))
with self._sessions.session() as session:
rows = session.scalars(select(self._sqla_models.RecallEntry).where(*filters)).all()
result: dict[str, RecallEntry] = {}
for row in rows:
row.embedding = self._normalize_embedding(row.embedding)
item = self._cache_item(row)
result[item.id] = item
return result
def clear_items(self, where: Mapping[str, Any] | None = None) -> dict[str, RecallEntry]:
from sqlmodel import delete, select
filters = self._build_filters(self._sqla_models.RecallEntry, where)
with self._sessions.session() as session:
# First get the objects to delete
rows = session.scalars(select(self._sqla_models.RecallEntry).where(*filters)).all()
deleted: dict[str, RecallEntry] = {}
for row in rows:
row.embedding = self._normalize_embedding(row.embedding)
deleted[row.id] = row
if not deleted:
return {}
# Delete from database
session.exec(delete(self._sqla_models.RecallEntry).where(*filters))
session.commit()
# Clean up cache
for item_id in deleted:
self.items.pop(item_id, None)
return deleted
def create_item(
self,
*,
resource_id: str | None = None,
memory_type: EntryType,
summary: str,
embedding: list[float],
user_data: dict[str, Any],
reinforce: bool = False,
tool_record: dict[str, Any] | None = None,
) -> RecallEntry:
if reinforce and memory_type != "tool":
return self.create_item_reinforce(
resource_id=resource_id,
memory_type=memory_type,
summary=summary,
embedding=embedding,
user_data=user_data,
)
# Build extra dict with tool_record fields at top level
extra: dict[str, Any] = {}
if tool_record:
if tool_record.get("when_to_use") is not None:
extra["when_to_use"] = tool_record["when_to_use"]
if tool_record.get("metadata") is not None:
extra["metadata"] = tool_record["metadata"]
if tool_record.get("tool_calls") is not None:
extra["tool_calls"] = tool_record["tool_calls"]
item = self._recall_entry_model(
resource_id=resource_id,
memory_type=memory_type,
summary=summary,
embedding=self._prepare_embedding(embedding),
extra=extra if extra else {},
**user_data,
created_at=self._now(),
updated_at=self._now(),
)
with self._sessions.session() as session:
session.add(item)
session.commit()
session.refresh(item)
self.items[item.id] = item
return item
def create_item_reinforce(
self,
*,
resource_id: str | None = None,
memory_type: EntryType,
summary: str,
embedding: list[float],
user_data: dict[str, Any],
) -> RecallEntry:
from sqlmodel import select
content_hash = compute_content_hash(summary, memory_type)
with self._sessions.session() as session:
# Check for existing item with same hash in same scope (deduplication)
# Use extra->>'content_hash' for query performance
content_hash_col = self._sqla_models.RecallEntry.extra["content_hash"].astext
filters = [content_hash_col == content_hash]
filters.extend(self._build_filters(self._sqla_models.RecallEntry, user_data))
existing = session.scalar(select(self._sqla_models.RecallEntry).where(*filters))
if existing:
# Reinforce existing memory instead of creating duplicate
current_extra = existing.extra or {}
current_count = current_extra.get("reinforcement_count", 1)
existing.extra = {
**current_extra,
"reinforcement_count": current_count + 1,
"last_reinforced_at": self._now().isoformat(),
}
existing.updated_at = self._now()
session.add(existing)
session.commit()
session.refresh(existing)
existing.embedding = self._normalize_embedding(existing.embedding)
return self._cache_item(existing)
# Create new item with salience tracking in extra
now = self._now()
item = self._recall_entry_model(
resource_id=resource_id,
memory_type=memory_type,
summary=summary,
embedding=self._prepare_embedding(embedding),
**user_data,
created_at=now,
updated_at=now,
extra={
"content_hash": content_hash,
"reinforcement_count": 1,
"last_reinforced_at": now.isoformat(),
},
)
session.add(item)
session.commit()
session.refresh(item)
self.items[item.id] = item
return item
def update_item(
self,
*,
item_id: str,
memory_type: EntryType | None = None,
summary: str | None = None,
embedding: list[float] | None = None,
extra: dict[str, Any] | None = None,
tool_record: dict[str, Any] | None = None,
) -> RecallEntry:
from sqlmodel import select
now = self._now()
with self._sessions.session() as session:
item = session.scalar(
select(self._sqla_models.RecallEntry).where(self._sqla_models.RecallEntry.id == item_id)
)
if item is None:
msg = f"Item with id {item_id} not found"
raise KeyError(msg)
if memory_type is not None:
item.memory_type = memory_type
if summary is not None:
item.summary = summary
if embedding is not None:
item.embedding = self._prepare_embedding(embedding)
# Merge extra and tool_record into existing extra dict
current_extra = item.extra or {}
if extra is not None:
current_extra = {**current_extra, **extra}
if tool_record is not None:
# Merge tool_record fields at top level
for key in ("when_to_use", "metadata", "tool_calls"):
if tool_record.get(key) is not None:
current_extra[key] = tool_record[key]
if extra is not None or tool_record is not None:
item.extra = current_extra
item.updated_at = now
session.add(item)
session.commit()
session.refresh(item)
item.embedding = self._normalize_embedding(item.embedding)
return self._cache_item(item)
def delete_item(self, item_id: str) -> None:
from sqlmodel import delete
with self._sessions.session() as session:
session.exec(delete(self._sqla_models.RecallEntry).where(self._sqla_models.RecallEntry.id == item_id))
session.commit()
def vector_search_items(
self,
query_vec: list[float],
top_k: int,
where: Mapping[str, Any] | None = None,
*,
ranking: str = "similarity",
recency_decay_days: float = 30.0,
) -> list[tuple[str, float]]:
if not self._use_vector or ranking == "salience":
# For salience ranking or when pgvector is not available, use local search
return self._vector_search_local(
query_vec, top_k, where=where, ranking=ranking, recency_decay_days=recency_decay_days
)
from sqlmodel import select
distance = self._sqla_models.RecallEntry.embedding.cosine_distance(query_vec)
filters = [self._sqla_models.RecallEntry.embedding.isnot(None)]
filters.extend(self._build_filters(self._sqla_models.RecallEntry, where))
stmt = (
select(self._sqla_models.RecallEntry.id, (1 - distance).label("score"))
.where(*filters)
.order_by(distance)
.limit(top_k)
)
with self._sessions.session() as session:
rows = session.execute(stmt).all()
return [(rid, float(score)) for rid, score in rows]
def load_existing(self) -> None:
from sqlmodel import select
with self._sessions.session() as session:
rows = session.scalars(select(self._sqla_models.RecallEntry)).all()
for row in rows:
row.embedding = self._normalize_embedding(row.embedding)
self._cache_item(row)
def _vector_search_local(
self,
query_vec: list[float],
top_k: int,
where: Mapping[str, Any] | None = None,
*,
ranking: str = "similarity",
recency_decay_days: float = 30.0,
) -> list[tuple[str, float]]:
scored: list[tuple[str, float]] = []
for item in self.items.values():
if item.embedding is None:
continue
if not self._matches_where(item, where):
continue
similarity = self._cosine(query_vec, item.embedding)
if ranking == "salience":
# Salience-aware scoring - read from extra dict
extra = item.extra or {}
reinforcement_count = extra.get("reinforcement_count", 1)
last_reinforced_at = self._parse_datetime(extra.get("last_reinforced_at"))
score = self._salience_score(
similarity,
reinforcement_count,
last_reinforced_at,
recency_decay_days,
)
else:
score = similarity
scored.append((item.id, score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
@staticmethod
def _salience_score(
similarity: float,
reinforcement_count: int,
last_reinforced_at: datetime | None,
recency_decay_days: float,
) -> float:
"""Compute salience score: similarity * reinforcement * recency."""
reinforcement_factor = math.log(reinforcement_count + 1)
if last_reinforced_at is None:
recency_factor = 0.5
else:
now = datetime.now(last_reinforced_at.tzinfo) if last_reinforced_at.tzinfo else datetime.utcnow()
days_ago = (now - last_reinforced_at).total_seconds() / 86400
recency_factor = math.exp(-0.693 * days_ago / recency_decay_days)
return similarity * reinforcement_factor * recency_factor
def _cache_item(self, item: RecallEntry) -> RecallEntry:
self.items[item.id] = item
return item
@staticmethod
def _parse_datetime(dt_str: str | None) -> datetime | None:
"""Parse ISO datetime string from extra dict."""
if dt_str is None:
return None
try:
import pendulum
parsed = pendulum.parse(dt_str)
except (ValueError, TypeError):
return None
else:
if isinstance(parsed, datetime):
return parsed
return None
@staticmethod
def _cosine(a: list[float], b: list[float]) -> float:
denom = (sum(x * x for x in a) ** 0.5) * (sum(y * y for y in b) ** 0.5) + 1e-9
return float(sum(x * y for x, y in zip(a, b, strict=True)) / denom)
__all__ = ["PostgresRecallEntryRepo"]
@@ -1,145 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from memu.database.models import RecallFileEntry
from memu.database.postgres.repositories.base import PostgresRepoBase
from memu.database.postgres.session import SessionManager
from memu.database.repositories.recall_file_entry import RecallFileEntryRepo
from memu.database.state import DatabaseState
class PostgresRecallFileEntryRepo(PostgresRepoBase, RecallFileEntryRepo):
def __init__(
self,
*,
state: DatabaseState,
recall_file_entry_model: type[RecallFileEntry],
sqla_models: Any,
sessions: SessionManager,
scope_fields: list[str],
) -> None:
super().__init__(state=state, sqla_models=sqla_models, sessions=sessions, scope_fields=scope_fields)
self._recall_file_entry_model = recall_file_entry_model
self.relations: list[RecallFileEntry] = self._state.relations
def list_relations(self, where: Mapping[str, Any] | None = None) -> list[RecallFileEntry]:
from sqlmodel import select
filters = self._build_filters(self._sqla_models.RecallFileEntry, where)
with self._sessions.session() as session:
rows = session.scalars(select(self._sqla_models.RecallFileEntry).where(*filters)).all()
return [self._cache_relation(row) for row in rows]
def link_item_category(self, item_id: str, cat_id: str, user_data: dict[str, Any]) -> RecallFileEntry:
from sqlmodel import select
# Avoid duplicate inserts using local cache
for rel in self.relations:
if rel.item_id == item_id and rel.category_id == cat_id:
return rel
now = self._now()
new_rel = self._recall_file_entry_model(
item_id=item_id,
category_id=cat_id,
**user_data,
created_at=now,
updated_at=now,
)
with self._sessions.session() as session:
existing = session.scalar(
select(self._sqla_models.RecallFileEntry).where(
self._sqla_models.RecallFileEntry.item_id == item_id,
self._sqla_models.RecallFileEntry.category_id == cat_id,
)
)
if existing:
return self._cache_relation(existing)
session.add(new_rel)
session.commit()
session.refresh(new_rel)
return self._cache_relation(new_rel)
def unlink_item_category(self, item_id: str, cat_id: str) -> None:
from sqlmodel import delete
with self._sessions.session() as session:
session.exec(
delete(self._sqla_models.RecallFileEntry).where(
self._sqla_models.RecallFileEntry.item_id == item_id,
self._sqla_models.RecallFileEntry.category_id == cat_id,
)
)
session.commit()
self.relations[:] = [r for r in self.relations if not (r.item_id == item_id and r.category_id == cat_id)]
def _row_to_record(self, row: Any) -> RecallFileEntry:
return RecallFileEntry(
id=row.id,
item_id=row.item_id,
category_id=row.category_id,
created_at=row.created_at,
updated_at=row.updated_at,
**self._scope_kwargs_from(row),
)
def unlink_item(self, item_id: str) -> list[RecallFileEntry]:
from sqlmodel import delete, select
with self._sessions.session() as session:
rows = session.scalars(
select(self._sqla_models.RecallFileEntry).where(self._sqla_models.RecallFileEntry.item_id == item_id)
).all()
removed = [self._row_to_record(row) for row in rows]
if removed:
session.exec(
delete(self._sqla_models.RecallFileEntry).where(
self._sqla_models.RecallFileEntry.item_id == item_id
)
)
session.commit()
self.relations[:] = [r for r in self.relations if r.item_id != item_id]
return removed
def clear_relations(self, where: Mapping[str, Any] | None = None) -> list[RecallFileEntry]:
from sqlmodel import delete, select
filters = self._build_filters(self._sqla_models.RecallFileEntry, where)
with self._sessions.session() as session:
rows = session.scalars(select(self._sqla_models.RecallFileEntry).where(*filters)).all()
removed = [self._row_to_record(row) for row in rows]
if removed:
session.exec(delete(self._sqla_models.RecallFileEntry).where(*filters))
session.commit()
removed_ids = {rel.id for rel in removed}
self.relations[:] = [r for r in self.relations if r.id not in removed_ids]
return removed
def get_item_categories(self, item_id: str) -> list[RecallFileEntry]:
from sqlmodel import select
with self._sessions.session() as session:
rows = session.scalars(
select(self._sqla_models.RecallFileEntry).where(self._sqla_models.RecallFileEntry.item_id == item_id)
).all()
return [self._cache_relation(row) for row in rows]
def load_existing(self) -> None:
from sqlmodel import select
with self._sessions.session() as session:
rows = session.scalars(select(self._sqla_models.RecallFileEntry)).all()
for row in rows:
self._cache_relation(row)
def _cache_relation(self, rel: RecallFileEntry) -> RecallFileEntry:
self.relations.append(rel)
return rel
__all__ = ["PostgresRecallFileEntryRepo"]

Some files were not shown because too many files have changed in this diff Show More