diff --git a/Cargo.lock b/Cargo.lock
index 945837f..af4c284 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -129,6 +129,21 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063"
+[[package]]
+name = "assert_cmd"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
+dependencies = [
+ "anstyle",
+ "bstr",
+ "libc",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -316,6 +331,17 @@ dependencies = [
"objc2",
]
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
[[package]]
name = "bumpalo"
version = "3.19.0"
@@ -574,17 +600,20 @@ dependencies = [
[[package]]
name = "cortex-mem-cli"
-version = "2.5.0"
+version = "2.7.0"
dependencies = [
"anyhow",
+ "assert_cmd",
"chrono",
"clap",
"colored",
"cortex-mem-config",
"cortex-mem-core",
"cortex-mem-tools",
+ "predicates",
"serde",
"serde_json",
+ "tempfile",
"tokio",
"tracing",
"tracing-subscriber",
@@ -592,7 +621,7 @@ dependencies = [
[[package]]
name = "cortex-mem-config"
-version = "2.5.0"
+version = "2.7.0"
dependencies = [
"anyhow",
"directories 5.0.1",
@@ -603,7 +632,7 @@ dependencies = [
[[package]]
name = "cortex-mem-core"
-version = "2.5.0"
+version = "2.7.0"
dependencies = [
"anyhow",
"async-trait",
@@ -611,7 +640,6 @@ dependencies = [
"cortex-mem-config",
"dyn-clone",
"futures",
- "log",
"qdrant-client",
"regex",
"reqwest 0.12.24",
@@ -632,7 +660,7 @@ dependencies = [
[[package]]
name = "cortex-mem-mcp"
-version = "2.5.0"
+version = "2.7.0"
dependencies = [
"anyhow",
"async-trait",
@@ -655,7 +683,7 @@ dependencies = [
[[package]]
name = "cortex-mem-rig"
-version = "2.5.0"
+version = "2.7.0"
dependencies = [
"anyhow",
"async-trait",
@@ -671,7 +699,7 @@ dependencies = [
[[package]]
name = "cortex-mem-service"
-version = "2.5.0"
+version = "2.7.0"
dependencies = [
"anyhow",
"axum",
@@ -694,7 +722,7 @@ dependencies = [
[[package]]
name = "cortex-mem-tars"
-version = "2.5.0"
+version = "2.7.0"
dependencies = [
"anyhow",
"async-stream",
@@ -709,7 +737,6 @@ dependencies = [
"cpal",
"crossterm",
"directories 6.0.0",
- "env_logger",
"futures",
"libc",
"log",
@@ -724,6 +751,7 @@ dependencies = [
"tokio",
"toml",
"tracing",
+ "tracing-log",
"tracing-subscriber",
"tui-markdown",
"tui-textarea",
@@ -734,14 +762,13 @@ dependencies = [
[[package]]
name = "cortex-mem-tools"
-version = "2.5.0"
+version = "2.7.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"cortex-mem-core",
"futures",
- "log",
"serde",
"serde_json",
"tempfile",
@@ -955,6 +982,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -1064,29 +1097,6 @@ dependencies = [
"cfg-if",
]
-[[package]]
-name = "env_filter"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
-dependencies = [
- "log",
- "regex",
-]
-
-[[package]]
-name = "env_logger"
-version = "0.11.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
-dependencies = [
- "anstream",
- "anstyle",
- "env_filter",
- "jiff",
- "log",
-]
-
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1136,6 +1146,15 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "float-cmp"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1745,30 +1764,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
-[[package]]
-name = "jiff"
-version = "0.2.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50"
-dependencies = [
- "jiff-static",
- "log",
- "portable-atomic",
- "portable-atomic-util",
- "serde_core",
-]
-
-[[package]]
-name = "jiff-static"
-version = "0.2.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
[[package]]
name = "jni"
version = "0.21.1"
@@ -2073,6 +2068,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2451,21 +2452,6 @@ dependencies = [
"time",
]
-[[package]]
-name = "portable-atomic"
-version = "1.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
-
-[[package]]
-name = "portable-atomic-util"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
-dependencies = [
- "portable-atomic",
-]
-
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2490,6 +2476,36 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "predicates"
+version = "3.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "float-cmp",
+ "normalize-line-endings",
+ "predicates-core",
+ "regex",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@@ -3712,6 +3728,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4303,6 +4325,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "walkdir"
version = "2.5.0"
diff --git a/Cargo.toml b/Cargo.toml
index 93176a4..f6d57aa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
-version = "2.5.1"
+version = "2.7.0"
edition = "2024"
rust-version = "1.86"
authors = ["Sopaco"]
diff --git a/README.md b/README.md
index 990ca16..100cffd 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,8 @@ cortex://resources/{resource_name}/
- Multi-Tenancy Support: Isolated memory spaces for different users and agents within a single deployment via tenant-aware collection naming.
- Event-Driven Automation: File watchers and auto-indexers for background processing, synchronization, and profile enrichment.
- LLM Result Caching: Intelligent caching with LRU eviction and TTL expiration reduces redundant LLM API calls by 50-75%, with cascade layer debouncing for 70-90% reduction in layer updates.
+- Incremental Memory Updates: Introduced an event-driven incremental update system (`MemoryEventCoordinator`, `CascadeLayerUpdater`) that keeps L0/L1 layers in sync automatically as memories change.
+- Memory Forgetting Mechanism: Introduced `MemoryCleanupService` based on the Ebbinghaus forgetting curve — automatically archives or deletes low-strength memories to control storage growth in long-running agents.
- Agent Framework Integration: Built-in support for Rig framework and Model Context Protocol (MCP).
- Web Dashboard: Svelte 5 SPA (Insights) for monitoring, tenant management, and semantic search visualization.
@@ -232,9 +234,9 @@ graph TD
Core --> LLM
```
-- `cortex-mem-core`: The heart of the system. Contains business logic for filesystem abstraction (`cortex://` URI), LLM client wrappers, embedding generation, Qdrant integration, session management, layer generation (L0/L1/L2), extraction engine, search engine, and automation orchestrator.
-- `cortex-mem-service`: High-performance REST API server (Axum-based) exposing all memory operations via `/api/v2/*` endpoints.
-- `cortex-mem-cli`: Command-line tool for developers and administrators to interact with the memory store directly.
+- `cortex-mem-core`: The heart of the system. Contains business logic for filesystem abstraction (`cortex://` URI), LLM client wrappers, embedding generation, Qdrant integration, session management, layer generation (L0/L1/L2), extraction engine, search engine, automation orchestrator, and incremental update system (`MemoryEventCoordinator`, `CascadeLayerUpdater`, `LlmResultCache`, `IncrementalMemoryUpdater`) as well as forgetting mechanism (`MemoryCleanupService`).
+- `cortex-mem-service`: High-performance REST API server (Axum-based) exposing all memory operations via `/api/v2/*` endpoints. Runs on port 8085 by default.
+- `cortex-mem-cli`: Command-line tool (`cortex-mem` binary) for developers and administrators to interact with the memory store directly.
- `cortex-mem-insights`: Pure frontend Svelte 5 SPA for monitoring, analytics, and memory management through a web interface.
- `cortex-mem-mcp`: Model Context Protocol server for integration with AI assistants (Claude Desktop, Cursor, etc.).
- `cortex-mem-rig`: Integration layer with the rig-core agent framework for tool registration.
@@ -448,15 +450,6 @@ timeout_secs = 30 # Timeout for embedding requests
# -----------------------------------------------------------------------------
[cortex]
data_dir = "./cortex-data" # Directory for storing memory files and sessions
-
-# -----------------------------------------------------------------------------
-# Automation Configuration
-# -----------------------------------------------------------------------------
-[automation]
-auto_index = true # Enable automatic indexing on file changes
-auto_extract = true # Enable automatic extraction on session close
-index_interval_secs = 5 # Polling interval for file watcher
-batch_delay_secs = 2 # Delay before processing batched changes
```
# 🚀 Usage
@@ -469,47 +462,48 @@ The CLI provides a powerful interface for direct interaction with the memory sys
Adds a new message to a session thread, automatically storing it in the memory system.
```sh
-cortex-mem-cli --config config.toml --tenant acme add --thread thread-123 --role user --content "The user is interested in Rust programming."
+cortex-mem --config config.toml --tenant acme add --thread thread-123 --role user "The user is interested in Rust programming."
```
- `--thread `: (Required) The thread/session ID.
- `--role `: Message role (user/assistant/system). Default: "user"
-- `--content `: The text content of the message.
+- `content`: The text content of the message (positional argument).
#### Search for Memories
Performs a semantic vector search across the memory store with weighted L0/L1/L2 scoring.
```sh
-cortex-mem-cli --config config.toml --tenant acme search "what are the user's hobbies?" --thread thread-123 --limit 10
+cortex-mem --config config.toml --tenant acme search "what are the user's hobbies?" --thread thread-123 --limit 10
```
- `query`: The natural language query for the search.
- `--thread `: Filter memories by thread ID.
-- `--limit `: Maximum number of results. Default: 10
-- `--min-score `: Minimum relevance score (0.0-1.0). Default: 0.3
+- `--limit ` / `-n`: Maximum number of results. Default: 10
+- `--min-score ` / `-s`: Minimum relevance score (0.0-1.0). Default: 0.4
- `--scope `: Search scope: "session", "user", or "agent". Default: "session"
#### List Memories
Retrieves a list of memories from a specific URI path.
```sh
-cortex-mem-cli --config config.toml --tenant acme list --uri "cortex://session" --include-abstracts
+cortex-mem --config config.toml --tenant acme list --uri "cortex://session" --include-abstracts
```
-- `--uri `: URI path to list (e.g., "cortex://session" or "cortex://user/preferences").
+- `--uri ` / `-u`: URI path to list (e.g., "cortex://session" or "cortex://user/preferences"). Default: `cortex://session`
- `--include-abstracts`: Include L0 abstracts in results.
#### Get a Specific Memory
Retrieves a specific memory by its URI.
```sh
-cortex-mem-cli --config config.toml --tenant acme get "cortex://session/thread-123/memory-456.md"
+cortex-mem --config config.toml --tenant acme get "cortex://session/thread-123/memory-456.md"
```
- `uri`: The memory URI.
-- `--abstract-only`: Show L0 abstract instead of full content.
+- `--abstract-only` / `-a`: Show L0 abstract instead of full content.
+- `--overview` / `-o`: Show L1 overview instead of full content.
#### Delete a Memory
Removes a memory from the store by its URI.
```sh
-cortex-mem-cli --config config.toml --tenant acme delete "cortex://session/thread-123/memory-456.md"
+cortex-mem --config config.toml --tenant acme delete "cortex://session/thread-123/memory-456.md"
```
#### Session Management
@@ -517,30 +511,33 @@ Manage conversation sessions.
```sh
# List all sessions
-cortex-mem-cli --config config.toml --tenant acme session list
+cortex-mem --config config.toml --tenant acme session list
# Create a new session
-cortex-mem-cli --config config.toml --tenant acme session create thread-456 --title "My Session"
+cortex-mem --config config.toml --tenant acme session create thread-456 --title "My Session"
-# Close a session (triggers extraction)
-cortex-mem-cli --config config.toml --tenant acme session close thread-456
+# Close a session (triggers extraction, layer generation, and vector indexing)
+cortex-mem --config config.toml --tenant acme session close thread-456
```
-#### Sync, Layers, and Stats
-Synchronize filesystem with vector store, manage layer files, and display system statistics.
+#### Layers and Stats
+Manage layer files and display system statistics.
```sh
# Display system statistics
-cortex-mem-cli --config config.toml --tenant acme stats
+cortex-mem --config config.toml --tenant acme stats
# List available tenants
-cortex-mem-cli --config config.toml tenant list
+cortex-mem --config config.toml tenant list
# Show L0/L1 layer file coverage status
-cortex-mem-cli --config config.toml --tenant acme layers status
+cortex-mem --config config.toml --tenant acme layers status
# Generate missing L0/L1 layer files
-cortex-mem-cli --config config.toml --tenant acme layers ensure-all
+cortex-mem --config config.toml --tenant acme layers ensure-all
+
+# Regenerate oversized L0 abstract files (> 2K characters)
+cortex-mem --config config.toml --tenant acme layers regenerate-oversized
```
### REST API (`cortex-mem-service`)
@@ -549,11 +546,11 @@ The REST API allows you to integrate Cortex Memory into any application, regardl
#### Starting the Service
```sh
-# Start the API server with default settings
-cortex-mem-service --data-dir ./cortex-data --host 127.0.0.1 --port 8085
+# Start the API server with default settings (port 8085)
+cortex-mem-service --config config.toml --host 127.0.0.1 --port 8085
# Enable verbose logging
-cortex-mem-service -d ./cortex-data -h 127.0.0.1 -p 8085 --verbose
+cortex-mem-service --config config.toml -h 127.0.0.1 -p 8085 --verbose
```
#### API Endpoints
diff --git a/README_zh.md b/README_zh.md
index ce9fcf5..d6b8165 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -112,6 +112,8 @@ cortex://resources/{resource_name}/
- 多租户支持: 通过租户感知集合命名,在单个部署中为不同用户和代理提供隔离的内存空间。
- 事件驱动自动化: 文件监视器和自动索引器用于后台处理、同步和配置文件丰富。
- LLM结果缓存: 智能缓存采用LRU淘汰和TTL过期机制,减少50-75%的冗余LLM API调用,级联层防抖可减少70-90%的层更新调用。
+- 增量记忆更新: 引入了事件驱动的增量更新系统(`MemoryEventCoordinator`、`CascadeLayerUpdater`),在记忆变更时自动保持 L0/L1 层级同步。
+- 记忆遗忘机制: 引入了基于 Ebbinghaus 遗忘曲线的 `MemoryCleanupService`——自动归档或删除低强度记忆,控制长期运行 Agent 的存储空间膨胀。
- 代理框架集成: 内置支持Rig框架和模型上下文协议(MCP)。
- Web仪表板: Svelte 5 SPA(Insights)用于监控、租户管理和语义搜索可视化。
@@ -233,9 +235,9 @@ graph TD
Core --> LLM
```
-- `cortex-mem-core`:系统的核心。包含文件系统抽象(`cortex://` URI)、LLM客户端包装器、嵌入生成、Qdrant集成、会话管理、层生成(L0/L1/L2)、提取引擎、搜索引擎和自动化编排器的业务逻辑。
-- `cortex-mem-service`:高性能REST API服务器(基于Axum),通过`/api/v2/*`端点暴露所有内存操作。
-- `cortex-mem-cli`:供开发和管理员直接与内存存储交互的命令行工具。
+- `cortex-mem-core`:系统的核心。包含文件系统抽象(`cortex://` URI)、LLM客户端包装器、嵌入生成、Qdrant集成、会话管理、层生成(L0/L1/L2)、提取引擎、搜索引擎和自动化编排器的业务逻辑,以及 增量更新系统(`MemoryEventCoordinator`、`CascadeLayerUpdater`、`LlmResultCache`、`IncrementalMemoryUpdater`)和 遗忘机制(`MemoryCleanupService`)。
+- `cortex-mem-service`:高性能REST API服务器(基于Axum),通过`/api/v2/*`端点暴露所有内存操作,默认端口 8085。
+- `cortex-mem-cli`:二进制名为 `cortex-mem` 的命令行工具,供开发人员和管理员直接与内存存储交互。
- `cortex-mem-insights`:纯前端Svelte 5 SPA,用于通过Web界面进行监控、分析和内存管理。
- `cortex-mem-mcp`:模型上下文协议服务器,用于与AI助手(Claude Desktop、Cursor等)集成。
- `cortex-mem-rig`:与rig-core代理框架的集成层,用于工具注册。
@@ -450,68 +452,60 @@ timeout_secs = 30 # 嵌入请求的超时时间
# -----------------------------------------------------------------------------
[cortex]
data_dir = "./cortex-data" # 用于存储内存文件和会话的目录
-
-# -----------------------------------------------------------------------------
-# 自动化配置
-# -----------------------------------------------------------------------------
-[automation]
-auto_index = true # 在文件更改时启用自动索引
-auto_extract = true # 在会话关闭时启用自动提取
-index_interval_secs = 5 # 文件监视器的轮询间隔
-batch_delay_secs = 2 # 处理批量更改前的延迟
```
# 🚀 使用方法
### CLI (`cortex-mem-cli`)
-CLI提供了直接与内存系统交互的强大界面。所有命令都需要`config.toml`文件,可以使用`--config `指定。`--tenant`标志允许多租户隔离。
+CLI提供了直接与内存系统交互的强大界面,二进制名称为 `cortex-mem`。所有命令都需要`config.toml`文件,可以使用`--config `(短选项 `-c`)指定。`--tenant`标志允许多租户隔离。
#### 添加内存
向会话线程添加新消息,自动存储在内存系统中。
```sh
-cortex-mem-cli --config config.toml --tenant acme add --thread thread-123 --role user --content "用户对Rust编程感兴趣。"
+cortex-mem --config config.toml --tenant acme add --thread thread-123 --role user "用户对Rust编程感兴趣。"
```
-- `--thread `:(必需)线程/会话ID。
-- `--role `:消息角色(user/assistant/system)。默认:"user"
-- `--content `:消息的文本内容。
+- `--thread ` / `-t`:(必需)线程/会话ID。
+- `--role ` / `-r`:消息角色(user/assistant/system)。默认:"user"
+- `content`:消息的文本内容(位置参数)。
#### 搜索内存
在内存存储中执行带有加权L0/L1/L2评分的语义向量搜索。
```sh
-cortex-mem-cli --config config.toml --tenant acme search "用户的爱好是什么?" --thread thread-123 --limit 10
+cortex-mem --config config.toml --tenant acme search "用户的爱好是什么?" --thread thread-123 --limit 10
```
- `query`:搜索的自然语言查询。
- `--thread `:按线程ID过滤内存。
-- `--limit `:最大结果数。默认:10
-- `--min-score `:最小相关性分数(0.0-1.0)。默认:0.3
+- `--limit ` / `-n`:最大结果数。默认:10
+- `--min-score ` / `-s`:最小相关性分数(0.0-1.0)。默认:0.4
- `--scope `:搜索范围:"session"、"user"或"agent"。默认:"session"
#### 列出内存
从特定URI路径检索内存列表。
```sh
-cortex-mem-cli --config config.toml --tenant acme list --uri "cortex://session" --include-abstracts
+cortex-mem --config config.toml --tenant acme list --uri "cortex://session" --include-abstracts
```
-- `--uri `:要列出的URI路径(例如,"cortex://session"或"cortex://user/preferences")。
+- `--uri ` / `-u`:要列出的URI路径(例如,"cortex://session"或"cortex://user/preferences")。默认:`cortex://session`
- `--include-abstracts`:在结果中包含L0摘要。
#### 获取特定内存
按其URI检索特定内存。
```sh
-cortex-mem-cli --config config.toml --tenant acme get "cortex://session/thread-123/memory-456.md"
+cortex-mem --config config.toml --tenant acme get "cortex://session/thread-123/memory-456.md"
```
- `uri`:内存URI。
-- `--abstract-only`:显示L0摘要而不是完整内容。
+- `--abstract-only` / `-a`:显示L0摘要而不是完整内容。
+- `--overview` / `-o`:显示L1概览而不是完整内容。
#### 删除内存
按其URI从存储中删除内存。
```sh
-cortex-mem-cli --config config.toml --tenant acme delete "cortex://session/thread-123/memory-456.md"
+cortex-mem --config config.toml --tenant acme delete "cortex://session/thread-123/memory-456.md"
```
#### 会话管理
@@ -519,13 +513,13 @@ cortex-mem-cli --config config.toml --tenant acme delete "cortex://session/threa
```sh
# 列出所有会话
-cortex-mem-cli --config config.toml --tenant acme session list
+cortex-mem --config config.toml --tenant acme session list
# 创建新会话
-cortex-mem-cli --config config.toml --tenant acme session create thread-456 --title "我的会话"
+cortex-mem --config config.toml --tenant acme session create thread-456 --title "我的会话"
-# 关闭会话(触发提取)
-cortex-mem-cli --config config.toml --tenant acme session close thread-456
+# 关闭会话(触发记忆提取、层级生成和向量索引)
+cortex-mem --config config.toml --tenant acme session close thread-456
```
#### 层级管理和统计
@@ -533,16 +527,19 @@ cortex-mem-cli --config config.toml --tenant acme session close thread-456
```sh
# 显示系统统计信息
-cortex-mem-cli --config config.toml --tenant acme stats
+cortex-mem --config config.toml --tenant acme stats
# 列出可用租户
-cortex-mem-cli --config config.toml tenant list
+cortex-mem --config config.toml tenant list
# 显示L0/L1层级文件覆盖状态
-cortex-mem-cli --config config.toml --tenant acme layers status
+cortex-mem --config config.toml --tenant acme layers status
# 生成缺失的L0/L1层级文件
-cortex-mem-cli --config config.toml --tenant acme layers ensure-all
+cortex-mem --config config.toml --tenant acme layers ensure-all
+
+# 重新生成超大 L0 摘要文件(> 2K 字符)
+cortex-mem --config config.toml --tenant acme layers regenerate-oversized
```
### REST API (`cortex-mem-service`)
@@ -551,11 +548,11 @@ REST API允许您将Cortex Memory集成到任何应用程序中,无论编程
#### 启动服务
```sh
-# 使用默认设置启动API服务器
-cortex-mem-service --data-dir ./cortex-data --host 127.0.0.1 --port 8085
+# 使用默认设置启动API服务器(默认端口 8085)
+cortex-mem-service --config config.toml --host 127.0.0.1 --port 8085
# 启用详细日志记录
-cortex-mem-service -d ./cortex-data -h 127.0.0.1 -p 8085 --verbose
+cortex-mem-service --config config.toml -h 127.0.0.1 -p 8085 --verbose
```
#### API端点
diff --git a/cortex-mem-cli/Cargo.toml b/cortex-mem-cli/Cargo.toml
index e65d97f..edad562 100644
--- a/cortex-mem-cli/Cargo.toml
+++ b/cortex-mem-cli/Cargo.toml
@@ -26,3 +26,8 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
clap = { workspace = true }
colored = { workspace = true }
+
+[dev-dependencies]
+assert_cmd = "2.0"
+predicates = "3.0"
+tempfile = "3.0"
diff --git a/cortex-mem-cli/README.md b/cortex-mem-cli/README.md
index 9409f96..759a8a5 100644
--- a/cortex-mem-cli/README.md
+++ b/cortex-mem-cli/README.md
@@ -126,17 +126,19 @@ cortex-mem list --include-abstracts
#### Get Memory
```bash
-cortex-mem get [--abstract-only]
+cortex-mem get [--abstract-only] [--overview]
# Examples
cortex-mem get cortex://session/tech-support/timeline/2024/01/15/14_30_00_abc123.md
cortex-mem get cortex://session/tech-support/timeline/2024/01/15/14_30_00_abc123.md --abstract-only
+cortex-mem get cortex://session/tech-support/timeline/2024/01/15/14_30_00_abc123.md --overview
```
| Argument | Short | Default | Description |
|----------|-------|---------|-------------|
| `uri` | | (required) | Memory URI to retrieve |
-| `--abstract-only` | `-a` | false | Show L0 abstract instead of full content |
+| `--abstract-only` | `-a` | false | Show L0 abstract (~100 tokens) instead of full content |
+| `--overview` | `-o` | false | Show L1 overview (structured summary) instead of full content |
#### Delete Memory
@@ -147,6 +149,22 @@ cortex-mem delete
cortex-mem delete cortex://session/tech-support/timeline/2024/01/15/14_30_00_abc123.md
```
+#### Session Close
+
+Close a session and trigger memory extraction, L0/L1 layer generation, and vector indexing.
+
+```bash
+cortex-mem session close
+
+# Example
+cortex-mem session close customer-support
+```
+
+This is the key command for finalizing a conversation — it runs the full processing pipeline:
+1. **Memory Extraction**: LLM analyzes the conversation and extracts structured facts, decisions, and entities.
+2. **Layer Generation**: L0 (abstract) and L1 (overview) files are generated or updated.
+3. **Vector Indexing**: All layers are embedded and indexed in Qdrant for future semantic search.
+
### Layer Commands
#### Ensure All Layers
@@ -319,6 +337,18 @@ cortex-mem --verbose session create debug-test
RUST_BACKTRACE=1 cortex-mem search "test"
```
+## 🧪 Testing
+
+```bash
+# Run basic tests (no external services needed)
+cargo test -p cortex-mem-cli
+
+# Run all tests including integration tests (requires Qdrant + LLM + Embedding)
+CONFIG_PATH=./config.toml TENANT_ID=testcase_user cargo test -p cortex-mem-cli -- --include-ignored
+```
+
+Tests are automatically run in single-threaded mode (configured in `.cargo/config.toml`) to avoid Qdrant collection creation race conditions.
+
## 📚 Related Resources
- [Cortex Memory Main Project](../README.md)
diff --git a/cortex-mem-cli/src/commands/session.rs b/cortex-mem-cli/src/commands/session.rs
index 9487c50..0b6d868 100644
--- a/cortex-mem-cli/src/commands/session.rs
+++ b/cortex-mem-cli/src/commands/session.rs
@@ -52,27 +52,22 @@ pub async fn create(
Ok(())
}
-/// Close a session and trigger memory extraction, layer generation, and indexing
+/// Close a session and synchronously wait for memory extraction, L0/L1 generation,
+/// and vector indexing to complete before returning.
pub async fn close(operations: Arc, thread: &str) -> Result<()> {
println!("{} Closing session: {}", "🔒".bold(), thread.cyan());
-
- // Close the session (triggers SessionClosed event → MemoryEventCoordinator)
- operations.close_session(thread).await?;
-
- println!("{} Session closed successfully", "✓".green().bold());
- println!(" {}: {}", "Thread ID".cyan(), thread);
- println!();
println!("{} Waiting for memory extraction, L0/L1 generation, and indexing to complete...", "⏳".yellow().bold());
- // Wait for background tasks to complete (max 60 seconds)
- // This ensures memory extraction, layer generation, and vector indexing finish before CLI exits
- let completed = operations.flush_and_wait(Some(1)).await;
+ // close_session_sync blocks until the full pipeline completes:
+ // 1. Session metadata → marked closed
+ // 2. LLM memory extraction from session timeline
+ // 3. user/agent memory files written
+ // 4. L0/L1 layer files generated for all affected directories
+ // 5. Session timeline synced to vector store
+ operations.close_session_sync(thread).await?;
- if completed {
- println!("{} All background tasks completed successfully", "✓".green().bold());
- } else {
- println!("{} Background tasks timed out (some may still be processing)", "⚠".yellow().bold());
- }
+ println!("{} Session closed and all processing completed", "✓".green().bold());
+ println!(" {}: {}", "Thread ID".cyan(), thread);
Ok(())
-}
\ No newline at end of file
+}
diff --git a/cortex-mem-cli/test_cli.sh b/cortex-mem-cli/test_cli.sh
deleted file mode 100755
index cc5b020..0000000
--- a/cortex-mem-cli/test_cli.sh
+++ /dev/null
@@ -1,208 +0,0 @@
-#!/bin/bash
-# test_cli.sh - Automated CLI test script for Cortex-Mem
-
-# Don't use set -e so the script continues on test failures
-
-# Get the directory where this script is located
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
-
-# Configuration - modify these for your environment
-CONFIG_PATH="${CONFIG_PATH:-/Users/jiangmeng/Library/Application Support/com.cortex-mem.tars/config.toml}"
-TENANT_ID="${TENANT_ID:-bf323233-1f53-4337-a8e7-2ebe9b0080d0}"
-CLI="${CLI:-$PROJECT_ROOT/target/release/cortex-mem}"
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-# Test counters
-pass=0
-fail=0
-total=0
-
-# Test function
-test_case() {
- local id="$1"
- local name="$2"
- local cmd="$3"
- local expected="$4"
- local should_fail="${5:-false}"
-
- ((total++))
- echo -ne "${BLUE}[$id]${NC} $name... "
-
- # Use eval to properly handle quoted arguments with spaces
- if output=$(eval "$cmd" 2>&1); then
- if echo "$output" | grep -q "$expected"; then
- echo -e "${GREEN}PASS${NC}"
- ((pass++))
- else
- echo -e "${RED}FAIL${NC}"
- echo " Expected to contain: $expected"
- echo " Got: ${output:0:200}..."
- ((fail++))
- fi
- else
- if [ "$should_fail" = "true" ]; then
- if echo "$output" | grep -q "$expected"; then
- echo -e "${GREEN}PASS (expected error)${NC}"
- ((pass++))
- else
- echo -e "${RED}FAIL${NC}"
- echo " Expected error containing: $expected"
- echo " Got: $output"
- ((fail++))
- fi
- else
- echo -e "${RED}FAIL (unexpected error)${NC}"
- echo " Error: $output"
- ((fail++))
- fi
- fi
-}
-
-echo "============================================"
-echo " Cortex-Mem CLI Automated Test Suite"
-echo "============================================"
-echo ""
-echo "Configuration:"
-echo " CLI: $CLI"
-echo " Config: $CONFIG_PATH"
-echo " Tenant: $TENANT_ID"
-echo ""
-
-# Check if CLI exists
-if [ ! -f "$CLI" ]; then
- echo -e "${RED}Error: CLI binary not found at $CLI${NC}"
- echo "Please build it first: cargo build --release --bin cortex-mem"
- exit 1
-fi
-
-# Check if config exists
-if [ ! -f "$CONFIG_PATH" ]; then
- echo -e "${RED}Error: Config file not found at $CONFIG_PATH${NC}"
- exit 1
-fi
-
-echo "============================================"
-echo " 1. Basic Commands"
-echo "============================================"
-
-test_case "B01" "Help command" \
- "$CLI --help" \
- "Cortex-Mem CLI"
-
-test_case "B02" "Version command" \
- "$CLI --version" \
- "cortex-mem"
-
-echo ""
-echo "============================================"
-echo " 2. Tenant Management"
-echo "============================================"
-
-test_case "T01" "List tenants" \
- "$CLI -c \"$CONFIG_PATH\" tenant list" \
- "Found"
-
-echo ""
-echo "============================================"
-echo " 3. Session Management"
-echo "============================================"
-
-test_case "S01" "List sessions" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" session list" \
- "sessions"
-
-echo ""
-echo "============================================"
-echo " 4. Statistics"
-echo "============================================"
-
-test_case "ST01" "Show statistics" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" stats" \
- "Statistics"
-
-echo ""
-echo "============================================"
-echo " 5. Memory Listing"
-echo "============================================"
-
-test_case "L01" "List session root" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" list" \
- "Found"
-
-test_case "L02" "List user dimension" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" list --uri cortex://user" \
- "Found"
-
-echo ""
-echo "============================================"
-echo " 6. Layer Management"
-echo "============================================"
-
-test_case "Y01" "Layer status (English output)" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" layers status" \
- "Layer file status"
-
-test_case "Y02" "Layer status shows correct command name" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" layers status" \
- "cortex-mem layers ensure-all"
-
-echo ""
-echo "============================================"
-echo " 7. Error Handling"
-echo "============================================"
-
-# Note: R07 (negative min_score) is skipped because clap rejects negative numbers
-# as options before our validation code runs. This is expected clap behavior.
-
-test_case "R08" "Invalid min_score (> 1.0)" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" search test -s 2.0" \
- "min_score must be between" \
- "true"
-
-test_case "G06" "Invalid URI scheme" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" get invalid-uri" \
- "Invalid URI scheme" \
- "true"
-
-echo ""
-echo "============================================"
-echo " 8. Directory Abstract (Bug #1 Fix)"
-echo "============================================"
-
-test_case "G03" "Get directory abstract" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" get cortex://user/tars_user/entities --abstract-only" \
- "Abstract"
-
-echo ""
-echo "============================================"
-echo " 9. Add Message (Bug #4 Fix)"
-echo "============================================"
-
-test_case "M05" "Add message URI format" \
- "$CLI -c \"$CONFIG_PATH\" --tenant \"$TENANT_ID\" add --thread test-auto --role user \"Automated test message\"" \
- "cortex://session/test-auto/timeline"
-
-echo ""
-echo "============================================"
-echo " Test Summary"
-echo "============================================"
-echo ""
-echo -e "Total: $total"
-echo -e "Passed: ${GREEN}$pass${NC}"
-echo -e "Failed: ${RED}$fail${NC}"
-echo ""
-
-if [ $fail -gt 0 ]; then
- echo -e "${RED}Some tests failed!${NC}"
- exit 1
-else
- echo -e "${GREEN}All tests passed!${NC}"
- exit 0
-fi
diff --git a/cortex-mem-cli/tests/cli_commands_test.rs b/cortex-mem-cli/tests/cli_commands_test.rs
new file mode 100644
index 0000000..513426b
--- /dev/null
+++ b/cortex-mem-cli/tests/cli_commands_test.rs
@@ -0,0 +1,1197 @@
+//! cortex-mem-cli 命令功能测试
+//!
+//! # 测试分类
+//!
+//! ## 1. 基础命令测试 (无需外部服务)
+//! - `--help`:检验帮助信息输出
+//! - `--version`:检验版本号输出
+//!
+//! ## 2. Tenant 命令测试 (仅需配置文件和本地文件系统)
+//! - `tenant list`:列出租户,使用临时目录
+//!
+//! ## 3. 参数验证测试 (无需外部服务)
+//! - 缺少必要参数时的错误提示
+//! - 非法参数值的错误提示(如 min_score > 1.0)
+//! - 非法 URI scheme 的错误提示
+//!
+//! ## 4. 完整功能测试 (需要 Qdrant + LLM + Embedding,标记为 #[ignore])
+//! - `add`:添加消息
+//! - `list`:列出记忆
+//! - `get`:获取单条记忆
+//! - `delete`:删除记忆
+//! - `search`:语义搜索
+//! - `session list/create/close`:会话管理
+//! - `stats`:统计信息
+//! - `layers status/ensure-all/regenerate-oversized`:层文件管理
+//!
+//! # 运行方式
+//!
+//! ```bash
+//! # 只运行不依赖外部服务的测试
+//! cargo test -p cortex-mem-cli
+//!
+//! # 运行全部测试(需要配置好 Qdrant + LLM + Embedding)
+//! cargo test -p cortex-mem-cli -- --include-ignored
+//!
+//! # 通过环境变量指定配置
+//! CONFIG_PATH=/path/to/config.toml TENANT_ID=my-tenant cargo test -p cortex-mem-cli -- --include-ignored
+//! ```
+
+use assert_cmd::Command;
+use predicates::prelude::*;
+use std::fs;
+use tempfile::TempDir;
+
+// ─── 辅助函数 ────────────────────────────────────────────────────────────────
+
+/// 获取 cortex-mem CLI 命令
+fn cli() -> Command {
+ // 使用 Command::new + CARGO_BIN_EXE 方式,兼容自定义 build-dir
+ Command::new(env!("CARGO_BIN_EXE_cortex-mem"))
+}
+
+/// 从环境变量读取配置路径,如未设置则使用 workspace 根目录的 config.toml
+///
+/// 注意:cargo test 的工作目录是 crate 目录(cortex-mem-cli/),而非 workspace 根目录。
+/// 因此需要将相对路径解析为基于 CARGO_MANIFEST_DIR 父目录的绝对路径。
+fn config_path() -> String {
+ match std::env::var("CONFIG_PATH") {
+ Ok(p) => {
+ // 环境变量提供的路径:如果是相对路径,则相对于 workspace 根目录(CARGO_MANIFEST_DIR 的父目录)
+ let path = std::path::Path::new(&p);
+ if path.is_absolute() {
+ p
+ } else {
+ // CARGO_MANIFEST_DIR = cortex-mem-cli/,其父目录 = workspace 根目录
+ let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .expect("CARGO_MANIFEST_DIR has no parent");
+ workspace_root.join(path).to_string_lossy().to_string()
+ }
+ }
+ Err(_) => {
+ // 默认使用 workspace 根目录下的 config.toml
+ let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .expect("CARGO_MANIFEST_DIR has no parent");
+ workspace_root
+ .join("config.toml")
+ .to_string_lossy()
+ .to_string()
+ }
+ }
+}
+
+/// 从环境变量读取 Tenant ID,如未设置则使用默认值
+fn tenant_id() -> String {
+ std::env::var("TENANT_ID")
+ .unwrap_or_else(|_| "default".to_string())
+}
+
+/// 创建临时数据目录,并在其中生成一个最简配置文件(不含外部服务配置)
+/// 该函数用于不需要向量数据库的测试场景
+fn setup_temp_env() -> (TempDir, String) {
+ let tmp = TempDir::new().expect("Failed to create temp dir");
+ let data_dir = tmp.path().join("cortex-data");
+ fs::create_dir_all(&data_dir).expect("Failed to create data dir");
+
+ // 创建 tenants 目录结构(用于 tenant list 测试)
+ let tenants_dir = data_dir.join("tenants");
+ fs::create_dir_all(&tenants_dir).expect("Failed to create tenants dir");
+ fs::create_dir_all(tenants_dir.join("tenant-alpha")).expect("Failed to create tenant dir");
+ fs::create_dir_all(tenants_dir.join("tenant-beta")).expect("Failed to create tenant dir");
+
+ // 生成最小化 config.toml(包含所有必需字段,但 URL 指向本地不存在的服务)
+ let config_content = format!(
+ r#"[qdrant]
+url = "http://localhost:16334"
+collection_name = "test-cortex-mem"
+embedding_dim = 256
+timeout_secs = 5
+api_key = ""
+
+[embedding]
+api_base_url = "http://localhost:18080"
+api_key = "test-key"
+model_name = "test-model"
+batch_size = 10
+timeout_secs = 5
+
+[llm]
+api_base_url = "http://localhost:18080"
+api_key = "test-key"
+model_efficient = "test-model"
+temperature = 0.1
+max_tokens = 4096
+
+[server]
+host = "127.0.0.1"
+port = 3000
+cors_origins = ["*"]
+
+[cortex]
+data_dir = "{data_dir}"
+
+[logging]
+enabled = false
+log_directory = "logs"
+level = "error"
+"#,
+ data_dir = data_dir.display()
+ );
+
+ let config_path = tmp.path().join("config.toml");
+ fs::write(&config_path, &config_content).expect("Failed to write config file");
+
+ let config_str = config_path.to_string_lossy().to_string();
+ (tmp, config_str)
+}
+
+// ─── 1. 基础命令测试 ─────────────────────────────────────────────────────────
+
+/// B01: --help 输出应包含程序名称和使用说明
+#[test]
+fn test_help_command() {
+ cli()
+ .arg("--help")
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("cortex-mem"))
+ .stdout(predicate::str::contains("Usage"));
+}
+
+/// B02: --version 输出应包含二进制名称
+#[test]
+fn test_version_command() {
+ cli()
+ .arg("--version")
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("cortex-mem"));
+}
+
+/// B03: add 子命令的 --help 应包含参数说明
+#[test]
+fn test_add_subcommand_help() {
+ cli()
+ .args(["add", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("thread").or(predicate::str::contains("content")));
+}
+
+/// B04: search 子命令的 --help 应包含查询参数说明
+#[test]
+fn test_search_subcommand_help() {
+ cli()
+ .args(["search", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("query").or(predicate::str::contains("limit")));
+}
+
+/// B05: session 子命令的 --help 应包含子命令说明
+#[test]
+fn test_session_subcommand_help() {
+ cli()
+ .args(["session", "--help"])
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("list")
+ .or(predicate::str::contains("create"))
+ .or(predicate::str::contains("close")),
+ );
+}
+
+/// B06: layers 子命令的 --help 应包含子命令说明
+#[test]
+fn test_layers_subcommand_help() {
+ cli()
+ .args(["layers", "--help"])
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("status")
+ .or(predicate::str::contains("ensure-all"))
+ .or(predicate::str::contains("regenerate-oversized")),
+ );
+}
+
+/// B07: tenant 子命令的 --help 应包含 list 子命令
+#[test]
+fn test_tenant_subcommand_help() {
+ cli()
+ .args(["tenant", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("list"));
+}
+
+/// B08: get 子命令的 --help 应包含 URI 参数说明
+#[test]
+fn test_get_subcommand_help() {
+ cli()
+ .args(["get", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("uri").or(predicate::str::contains("URI")));
+}
+
+/// B09: list 子命令的 --help 应包含相关参数说明
+#[test]
+fn test_list_subcommand_help() {
+ cli()
+ .args(["list", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("uri").or(predicate::str::contains("URI")));
+}
+
+/// B10: delete 子命令的 --help 应包含 URI 参数说明
+#[test]
+fn test_delete_subcommand_help() {
+ cli()
+ .args(["delete", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("uri").or(predicate::str::contains("URI")));
+}
+
+/// B11: stats 子命令的 --help 应成功
+#[test]
+fn test_stats_subcommand_help() {
+ cli()
+ .args(["stats", "--help"])
+ .assert()
+ .success();
+}
+
+// ─── 2. Tenant 命令测试 ──────────────────────────────────────────────────────
+
+/// T01: tenant list 在有租户目录时应列出所有租户
+#[test]
+fn test_tenant_list_with_tenants() {
+ let (_tmp, config) = setup_temp_env();
+
+ cli()
+ .args(["-c", &config, "tenant", "list"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("tenant-alpha"))
+ .stdout(predicate::str::contains("tenant-beta"));
+}
+
+/// T02: tenant list 在无租户目录时应给出友好提示
+#[test]
+fn test_tenant_list_empty() {
+ let tmp = TempDir::new().expect("Failed to create temp dir");
+ let data_dir = tmp.path().join("cortex-data-empty");
+ fs::create_dir_all(&data_dir).expect("Failed to create data dir");
+
+ let config_content = format!(
+ r#"[qdrant]
+url = "http://localhost:16334"
+collection_name = "test-cortex-mem"
+embedding_dim = 256
+timeout_secs = 5
+api_key = ""
+
+[embedding]
+api_base_url = "http://localhost:18080"
+api_key = "test-key"
+model_name = "test-model"
+batch_size = 10
+timeout_secs = 5
+
+[llm]
+api_base_url = "http://localhost:18080"
+api_key = "test-key"
+model_efficient = "test-model"
+temperature = 0.1
+max_tokens = 4096
+
+[server]
+host = "127.0.0.1"
+port = 3000
+cors_origins = ["*"]
+
+[cortex]
+data_dir = "{data_dir}"
+
+[logging]
+enabled = false
+log_directory = "logs"
+level = "error"
+"#,
+ data_dir = data_dir.display()
+ );
+
+ let config_path = tmp.path().join("config.toml");
+ fs::write(&config_path, &config_content).expect("Failed to write config");
+
+ cli()
+ .args(["-c", &config_path.to_string_lossy(), "tenant", "list"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("No tenants").or(predicate::str::contains("found")));
+}
+
+/// T03: tenant list 使用不存在的配置文件时应以错误退出
+#[test]
+fn test_tenant_list_missing_config() {
+ cli()
+ .args(["-c", "/nonexistent/path/config.toml", "tenant", "list"])
+ .assert()
+ .failure();
+}
+
+// ─── 3. 参数验证测试 ─────────────────────────────────────────────────────────
+
+/// V01: add 命令缺少必要参数 --thread 时应以错误退出
+#[test]
+fn test_add_missing_thread_arg() {
+ cli()
+ .args(["add", "some content"])
+ .assert()
+ .failure();
+}
+
+/// V02: add 命令缺少 content 位置参数时应以错误退出
+#[test]
+fn test_add_missing_content_arg() {
+ cli()
+ .args(["add", "--thread", "my-thread"])
+ .assert()
+ .failure();
+}
+
+/// V03: search 命令缺少 query 位置参数时应以错误退出
+#[test]
+fn test_search_missing_query_arg() {
+ cli()
+ .args(["search"])
+ .assert()
+ .failure();
+}
+
+/// V04: get 命令缺少 URI 位置参数时应以错误退出
+#[test]
+fn test_get_missing_uri_arg() {
+ cli()
+ .args(["get"])
+ .assert()
+ .failure();
+}
+
+/// V05: delete 命令缺少 URI 位置参数时应以错误退出
+#[test]
+fn test_delete_missing_uri_arg() {
+ cli()
+ .args(["delete"])
+ .assert()
+ .failure();
+}
+
+/// V06: session create 缺少 thread 参数时应以错误退出
+#[test]
+fn test_session_create_missing_thread() {
+ cli()
+ .args(["session", "create"])
+ .assert()
+ .failure();
+}
+
+/// V07: session close 缺少 thread 参数时应以错误退出
+#[test]
+fn test_session_close_missing_thread() {
+ cli()
+ .args(["session", "close"])
+ .assert()
+ .failure();
+}
+
+/// V08: search 命令的 --min-score 参数超出范围(>1.0)应以错误退出
+/// 注意:参数验证发生在 MemoryOperations 初始化之后,因此该测试需要外部服务
+#[test]
+#[ignore = "参数验证位于 MemoryOperations 初始化之后,需要外部服务才能到达验证逻辑"]
+fn test_search_invalid_min_score_over_limit() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "search",
+ "test query",
+ "-s",
+ "2.0",
+ ])
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("min_score must be between").or(
+ predicate::str::contains("between 0.0 and 1.0"),
+ ));
+}
+
+/// V09: get 命令使用无效的 URI scheme 时应以错误退出
+/// 注意:URI 验证发生在 MemoryOperations 初始化之后,因此该测试需要外部服务
+#[test]
+#[ignore = "URI 验证位于 MemoryOperations 初始化之后,需要外部服务才能到达验证逻辑"]
+fn test_get_invalid_uri_scheme() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "get",
+ "http://invalid-scheme/path",
+ ])
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("Invalid URI scheme").or(
+ predicate::str::contains("invalid").or(predicate::str::contains("error")),
+ ));
+}
+
+/// V10: list 命令不带任何选项时应以错误退出(需要配置才能初始化 MemoryOperations)
+/// 这验证了配置文件缺失时的错误处理
+#[test]
+fn test_list_no_config_fails() {
+ cli()
+ .args(["-c", "/tmp/nonexistent_config_xyzabc.toml", "list"])
+ .assert()
+ .failure();
+}
+
+// ─── 4. 完整功能测试 (需要外部服务,标记 #[ignore]) ─────────────────────────
+//
+// 运行方式:
+// CONFIG_PATH=/path/to/config.toml TENANT_ID= \
+// cargo test -p cortex-mem-cli -- --include-ignored
+//
+// 环境变量:
+// CONFIG_PATH - 配置文件路径 (默认: config.toml)
+// TENANT_ID - 租户 ID (默认: default)
+
+/// F01: add 命令 - 添加用户消息后应打印成功信息和 URI
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_add_user_message() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-add-{}", uuid_short());
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ "--role",
+ "user",
+ "Hello, this is a test message from cli test",
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("successfully").or(predicate::str::contains("✓")))
+ .stdout(predicate::str::contains("cortex://session"));
+}
+
+/// F02: add 命令 - 添加助手消息后应打印成功信息
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_add_assistant_message() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-add-asst-{}", uuid_short());
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ "--role",
+ "assistant",
+ "This is an assistant response for testing",
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("successfully").or(predicate::str::contains("✓")));
+}
+
+/// F03: list 命令 - 列出默认 URI (cortex://session)
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_list_default_uri() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args(["-c", &config, "--tenant", &tenant, "list"])
+ .assert()
+ .success();
+}
+
+/// F04: list 命令 - 列出 user 维度
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_list_user_dimension() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "list",
+ "--uri",
+ "cortex://user",
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("Found").or(predicate::str::contains("No memories")));
+}
+
+/// F05: list 命令 - 添加消息后列出该会话的内容
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_list_after_add() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-list-{}", uuid_short());
+
+ // 先添加一条消息
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ "Test message for list verification",
+ ])
+ .assert()
+ .success();
+
+ // 然后列出该会话
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "list",
+ "--uri",
+ &format!("cortex://session/{}", thread_id),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("Found").or(predicate::str::contains("item")));
+}
+
+/// F06: get 命令 - 先 add 再 get 该 URI 的内容
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_get_after_add() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-get-{}", uuid_short());
+ let unique_content = format!("Unique test content {}", uuid_short());
+
+ // 先添加消息
+ let add_output = cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ &unique_content,
+ ])
+ .assert()
+ .success()
+ .get_output()
+ .stdout
+ .clone();
+
+ // 从输出中提取 URI(形如 cortex://session/...)
+ let output_str = String::from_utf8_lossy(&add_output);
+ let uri = extract_uri_from_output(&output_str);
+
+ if let Some(uri) = uri {
+ // 使用 get 获取内容
+ cli()
+ .args(["-c", &config, "--tenant", &tenant, "get", &uri])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains(unique_content));
+ } else {
+ // URI 提取失败时直接通过(不阻塞 CI)
+ println!("WARN: Could not extract URI from add output, skipping get check");
+ }
+}
+
+/// F07: get 命令 - --abstract-only 选项应返回 L0 层内容
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_get_abstract_only() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-abstract-{}", uuid_short());
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ "Content to test abstract layer retrieval",
+ ])
+ .assert()
+ .success();
+
+ // 列出会话以获取 URI
+ let list_output = cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "list",
+ "--uri",
+ &format!("cortex://session/{}", thread_id),
+ ])
+ .assert()
+ .success()
+ .get_output()
+ .stdout
+ .clone();
+
+ let output_str = String::from_utf8_lossy(&list_output);
+ println!("List output: {}", output_str);
+ // 注:只验证命令能正常执行,具体内容由 L0 层生成逻辑决定
+}
+
+/// F08: delete 命令 - 先 add 再 delete 应成功
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_delete_after_add() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-delete-{}", uuid_short());
+
+ // 先添加消息并获取 URI
+ let add_output = cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ "Message to be deleted",
+ ])
+ .assert()
+ .success()
+ .get_output()
+ .stdout
+ .clone();
+
+ let output_str = String::from_utf8_lossy(&add_output);
+ let uri = extract_uri_from_output(&output_str);
+
+ if let Some(uri) = uri {
+ // 删除该 URI
+ cli()
+ .args(["-c", &config, "--tenant", &tenant, "delete", &uri])
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("deleted").or(predicate::str::contains("successfully")),
+ );
+ } else {
+ println!("WARN: Could not extract URI from add output, skipping delete check");
+ }
+}
+
+/// F09: search 命令 - 基本搜索
+///
+/// search 命令需要调用 Embedding API 将查询转为向量,若 Embedding 服务不可达则会失败。
+/// 本测试验证命令能够正常执行(参数解析正确),对 Embedding 服务的可用性不做强依赖要求。
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_search_basic() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ let output = cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "search",
+ "test query",
+ ])
+ .output()
+ .expect("Failed to run command");
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ // 如果成功:stdout 中应有 "Found" 或 "results"
+ // 如果失败:允许因 Embedding 服务不可达而失败(网络/服务错误),但不应是参数校验失败
+ if output.status.success() {
+ assert!(
+ stdout.contains("Found") || stdout.contains("results") || stdout.contains("0 results"),
+ "Expected search result output, got: {}",
+ stdout
+ );
+ } else {
+ // 允许因网络/服务不可用而失败,但不应是命令解析错误
+ let is_network_or_service_error = stderr.contains("Embedding error")
+ || stderr.contains("HTTP request failed")
+ || stderr.contains("connection refused")
+ || stderr.contains("Vector store error")
+ || stderr.contains("tonic::transport");
+ assert!(
+ is_network_or_service_error,
+ "Unexpected failure (not a network/service error): stderr={}",
+ stderr
+ );
+ println!("INFO: search failed due to service unavailability (acceptable): {}", stderr.trim());
+ }
+}
+
+/// F10: search 命令 - 指定 limit 和 min_score 参数
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_search_with_options() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ let output = cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "search",
+ "test query",
+ "--limit",
+ "5",
+ "--min-score",
+ "0.5",
+ ])
+ .output()
+ .expect("Failed to run command");
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ // 成功或因服务不可达失败均可接受
+ if !output.status.success() {
+ let is_service_error = stderr.contains("Embedding error")
+ || stderr.contains("HTTP request failed")
+ || stderr.contains("connection refused")
+ || stderr.contains("Vector store error");
+ assert!(is_service_error, "Unexpected failure: {}", stderr);
+ }
+}
+
+/// F11: search 命令 - 指定 scope 为 user 维度
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_search_user_scope() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ let output = cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "search",
+ "user preference query",
+ "--scope",
+ "user",
+ ])
+ .output()
+ .expect("Failed to run command");
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ if !output.status.success() {
+ let is_service_error = stderr.contains("Embedding error")
+ || stderr.contains("HTTP request failed")
+ || stderr.contains("connection refused")
+ || stderr.contains("Vector store error");
+ assert!(is_service_error, "Unexpected failure: {}", stderr);
+ }
+}
+
+/// F12: search 命令 - 指定 --thread 限制在某个会话内搜索
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_search_in_thread() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-search-{}", uuid_short());
+
+ // 先添加内容
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ "Rust programming language features",
+ ])
+ .assert()
+ .success();
+
+ // 在该 thread 内搜索(允许因 Embedding 服务不可达而失败)
+ let search_output = cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "search",
+ "Rust",
+ "--thread",
+ &thread_id,
+ ])
+ .output()
+ .expect("Failed to run search command");
+
+ let search_stderr = String::from_utf8_lossy(&search_output.stderr);
+ if !search_output.status.success() {
+ let is_service_error = search_stderr.contains("Embedding error")
+ || search_stderr.contains("HTTP request failed")
+ || search_stderr.contains("connection refused")
+ || search_stderr.contains("Vector store error");
+ assert!(is_service_error, "Unexpected search failure: {}", search_stderr);
+ println!("INFO: search in thread failed due to service unavailability: {}", search_stderr.trim());
+ }
+}
+
+/// F13: session list 命令 - 应列出会话(可能为空)
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_session_list() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args(["-c", &config, "--tenant", &tenant, "session", "list"])
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("sessions")
+ .or(predicate::str::contains("No sessions"))
+ .or(predicate::str::contains("Found")),
+ );
+}
+
+/// F14: session create 命令 - 创建新会话
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_session_create() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-session-create-{}", uuid_short());
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "session",
+ "create",
+ &thread_id,
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("created").or(predicate::str::contains("✓")));
+}
+
+/// F15: session create 命令 - 指定 --title 选项
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_session_create_with_title() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-session-titled-{}", uuid_short());
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "session",
+ "create",
+ &thread_id,
+ "--title",
+ "My Test Session",
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("created").or(predicate::str::contains("✓")));
+}
+
+/// F16: session close 命令 - 先 create 再 close
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_session_close_after_create() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-session-close-{}", uuid_short());
+
+ // 创建会话并添加消息
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ "A test message before closing session",
+ ])
+ .assert()
+ .success();
+
+ // 关闭会话(触发记忆提取流水线)
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "session",
+ "close",
+ &thread_id,
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("closed").or(predicate::str::contains("completed")));
+}
+
+/// F17: stats 命令 - 统计信息应包含维度数据
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_stats() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args(["-c", &config, "--tenant", &tenant, "stats"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("Statistics").or(predicate::str::contains("Sessions")));
+}
+
+/// F18: layers status 命令 - 显示 L0/L1 文件覆盖状态
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_layers_status() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args(["-c", &config, "--tenant", &tenant, "layers", "status"])
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("Layer file status")
+ .or(predicate::str::contains("Total directories")),
+ );
+}
+
+/// F19: layers ensure-all 命令 - 为所有缺失目录生成 L0/L1 文件
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_layers_ensure_all() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args(["-c", &config, "--tenant", &tenant, "layers", "ensure-all"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("Statistics").or(predicate::str::contains("Generated")));
+}
+
+/// F20: layers regenerate-oversized 命令 - 重新生成超大 .abstract 文件
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_layers_regenerate_oversized() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "layers",
+ "regenerate-oversized",
+ ])
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("Statistics")
+ .or(predicate::str::contains("Oversized"))
+ .or(predicate::str::contains("All .abstract files")),
+ );
+}
+
+/// F21: verbose 模式 - --verbose 选项不应导致命令失败
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_verbose_mode() {
+ let config = config_path();
+ let tenant = tenant_id();
+
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "--verbose",
+ "session",
+ "list",
+ ])
+ .assert()
+ .success();
+}
+
+/// F22: 完整工作流 - add → list → get → search → delete
+#[test]
+#[ignore = "需要外部服务 (Qdrant + LLM + Embedding),请配置环境变量后运行"]
+fn test_full_workflow() {
+ let config = config_path();
+ let tenant = tenant_id();
+ let thread_id = format!("cli-test-workflow-{}", uuid_short());
+ let content = format!("Workflow test content {}", uuid_short());
+
+ // Step 1: add 消息
+ let add_output = cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "add",
+ "--thread",
+ &thread_id,
+ &content,
+ ])
+ .assert()
+ .success()
+ .get_output()
+ .stdout
+ .clone();
+
+ let output_str = String::from_utf8_lossy(&add_output);
+ println!("Add output: {}", output_str);
+
+ // Step 2: list 该会话内容
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "list",
+ "--uri",
+ &format!("cortex://session/{}", thread_id),
+ ])
+ .assert()
+ .success();
+
+ // Step 3: search 刚添加的内容
+ cli()
+ .args([
+ "-c",
+ &config,
+ "--tenant",
+ &tenant,
+ "search",
+ "Workflow test",
+ "--thread",
+ &thread_id,
+ ])
+ .assert()
+ .success();
+
+ // Step 4: stats 查看统计
+ cli()
+ .args(["-c", &config, "--tenant", &tenant, "stats"])
+ .assert()
+ .success();
+}
+
+// ─── 辅助函数 ────────────────────────────────────────────────────────────────
+
+/// 生成短 UUID(8 个字符)用于测试隔离
+fn uuid_short() -> String {
+ use std::collections::hash_map::DefaultHasher;
+ use std::hash::{Hash, Hasher};
+ use std::time::SystemTime;
+
+ let mut hasher = DefaultHasher::new();
+ SystemTime::now().hash(&mut hasher);
+ std::thread::current().id().hash(&mut hasher);
+ format!("{:08x}", hasher.finish())
+}
+
+/// 从命令输出中提取第一个 `cortex://` URI
+fn extract_uri_from_output(output: &str) -> Option {
+ output
+ .lines()
+ .find_map(|line| {
+ if let Some(pos) = line.find("cortex://") {
+ // 截取到空白字符或行末
+ let uri_start = &line[pos..];
+ let uri_end = uri_start
+ .find(|c: char| c.is_whitespace())
+ .unwrap_or(uri_start.len());
+ Some(uri_start[..uri_end].to_string())
+ } else {
+ None
+ }
+ })
+}
diff --git a/cortex-mem-core/Cargo.toml b/cortex-mem-core/Cargo.toml
index f79add2..1a307e2 100644
--- a/cortex-mem-core/Cargo.toml
+++ b/cortex-mem-core/Cargo.toml
@@ -24,7 +24,6 @@ tracing-subscriber = { workspace = true }
walkdir = { workspace = true }
rig-core = { workspace = true }
reqwest = { workspace = true }
-log = "0.4"
# Additional dependencies
regex = "1.10"
diff --git a/cortex-mem-core/README.md b/cortex-mem-core/README.md
index 6a97a67..9f59245 100644
--- a/cortex-mem-core/README.md
+++ b/cortex-mem-core/README.md
@@ -11,6 +11,8 @@ Cortex Memory Core implements:
- Vector search integration with Qdrant
- LLM-based memory extraction and profiling
- Event-driven automation system
+- Incremental memory update system with event coordination, cascade layer updates, and LLM result caching
+- Memory forgetting mechanism based on the Ebbinghaus forgetting curve
## 🏗️ Architecture
@@ -29,6 +31,16 @@ Cortex Memory Core implements:
| **`embedding`** | Embedding generation | `EmbeddingClient`, `EmbeddingCache` |
| **`events`** | Event system for automation | `CortexEvent`, `EventBus` |
| **`builder`** | Unified initialization API | `CortexMemBuilder`, `CortexMem` |
+| **`memory_index`** | Memory index and version tracking | `MemoryIndex`, `MemoryMetadata`, `MemoryScope`, `MemoryType` |
+| **`memory_events`** | Memory change event types | `MemoryEvent`, `ChangeType`, `DeleteReason` |
+| **`memory_index_manager`** | Persistent index management | `MemoryIndexManager` |
+| **`incremental_memory_updater`** | Incremental diff-based updates | `IncrementalMemoryUpdater` |
+| **`cascade_layer_updater`** | Cascading L0/L1 layer updates | `CascadeLayerUpdater`, `UpdateStats` |
+| **`cascade_layer_debouncer`** | Batch debouncing for layer updates | `LayerUpdateDebouncer`, `DebouncerConfig` |
+| **`llm_result_cache`** | LRU+TTL cache for LLM results | `LlmResultCache`, `CacheConfig`, `CacheStats` |
+| **`vector_sync_manager`** | Vector store sync coordination | `VectorSyncManager`, `VectorSyncStats` |
+| **`memory_event_coordinator`** | Central event orchestration hub | `MemoryEventCoordinator`, `CoordinatorConfig` |
+| **`memory_cleanup`** | Forgetting mechanism | `MemoryCleanupService`, `MemoryCleanupConfig`, `CleanupStats` |
## 🚀 Quick Start
@@ -394,6 +406,8 @@ pub struct AutomationConfig {
pub index_on_close: bool, // Default: true
pub index_batch_delay: u64, // Default: 2 seconds
pub auto_generate_layers_on_startup: bool, // Default: false
+ pub generate_layers_every_n_messages: usize, // Default: 0 (disabled)
+ pub max_concurrent_llm_tasks: usize, // Default: 3
}
```
@@ -445,6 +459,34 @@ pub enum FilesystemEvent {
}
```
+## ⚡ Incremental Update System
+
+Introduced an event-driven incremental update pipeline that keeps memory layers in sync efficiently:
+
+- **`MemoryEventCoordinator`**: Central hub that receives `MemoryEvent`s (create/update/delete) and orchestrates downstream processing.
+- **`IncrementalMemoryUpdater`**: Computes content diffs to only re-process changed memories, skipping unchanged content.
+- **`CascadeLayerUpdater`**: When a memory changes, cascades L0/L1 layer updates up the directory tree. Uses content hash check (Phase 1) and LLM result cache (Phase 3) to minimize redundant work.
+- **`LayerUpdateDebouncer`**: Batches rapid successive updates to the same directory (Phase 2), reducing LLM calls by 70-90%.
+- **`LlmResultCache`**: LRU + TTL cache for generated L0/L1 content. Reduces LLM API costs by 50-75% for repeated content.
+- **`VectorSyncManager`**: Keeps the Qdrant vector store synchronized with filesystem changes.
+
+## 🧹 Memory Cleanup (Forgetting Mechanism)
+
+The `MemoryCleanupService` which implements the Ebbinghaus forgetting curve:
+
+- Periodically scans the memory index and calculates **memory strength** based on recency and access frequency.
+- Memories with strength below `archive_threshold` (default: 0.1) are **archived** (marked but not deleted).
+- Archived memories with strength below `delete_threshold` (default: 0.02) are **permanently deleted**.
+- Prevents unbounded storage growth in long-running AI agents.
+
+```rust
+use cortex_mem_core::{MemoryCleanupService, MemoryCleanupConfig, MemoryScope};
+
+let svc = MemoryCleanupService::new(index_manager, MemoryCleanupConfig::default());
+let stats = svc.run_cleanup(&MemoryScope::User, "alice").await?;
+println!("Archived: {}, Deleted: {}", stats.archived, stats.deleted);
+```
+
## 🔗 Integration with Other Crates
- **`cortex-mem-config`**: Configuration loading and management
diff --git a/cortex-mem-core/src/automation/auto_extract.rs b/cortex-mem-core/src/automation/auto_extract.rs
deleted file mode 100644
index 80b22d0..0000000
--- a/cortex-mem-core/src/automation/auto_extract.rs
+++ /dev/null
@@ -1,101 +0,0 @@
-use crate::{
- Result,
- filesystem::CortexFilesystem,
- llm::LLMClient,
-};
-use std::sync::Arc;
-use tracing::info;
-
-/// 会话自动提取配置
-#[derive(Debug, Clone)]
-pub struct AutoExtractConfig {
- /// 触发自动提取的最小消息数
- pub min_message_count: usize,
- /// 是否在会话关闭时自动提取
- pub extract_on_close: bool,
-}
-
-impl Default for AutoExtractConfig {
- fn default() -> Self {
- Self {
- min_message_count: 5,
- extract_on_close: true,
- }
- }
-}
-
-/// 自动提取统计
-#[derive(Debug, Clone, Default)]
-pub struct AutoExtractStats {
- pub facts_extracted: usize,
- pub decisions_extracted: usize,
- pub entities_extracted: usize,
- pub user_memories_saved: usize,
- pub agent_memories_saved: usize,
-}
-
-/// 会话自动提取器
-///
-/// v2.5: 此结构体已被简化,记忆提取现在由 SessionManager 通过 MemoryEventCoordinator 处理。
-/// 保留此结构体仅用于向后兼容。
-pub struct AutoExtractor {
- #[allow(dead_code)]
- filesystem: Arc,
- #[allow(dead_code)]
- llm: Arc,
- #[allow(dead_code)]
- config: AutoExtractConfig,
- user_id: String,
-}
-
-impl AutoExtractor {
- /// 创建新的自动提取器
- pub fn new(
- filesystem: Arc,
- llm: Arc,
- config: AutoExtractConfig,
- ) -> Self {
- Self {
- filesystem,
- llm,
- config,
- user_id: "default".to_string(),
- }
- }
-
- /// 创建新的自动提取器,指定用户ID
- pub fn with_user_id(
- filesystem: Arc,
- llm: Arc,
- config: AutoExtractConfig,
- user_id: impl Into,
- ) -> Self {
- Self {
- filesystem,
- llm,
- config,
- user_id: user_id.into(),
- }
- }
-
- /// 设置用户ID
- pub fn set_user_id(&mut self, user_id: impl Into) {
- self.user_id = user_id.into();
- }
-
- /// 提取会话记忆
- ///
- /// v2.5: 此方法已被废弃。记忆提取现在由 SessionManager::close_session 通过
- /// MemoryEventCoordinator 异步处理。此方法返回空统计用于向后兼容。
- pub async fn extract_session(&self, _thread_id: &str) -> Result {
- info!(
- "AutoExtractor::extract_session is deprecated - memory extraction is handled by MemoryEventCoordinator"
- );
- Ok(AutoExtractStats::default())
- }
-
- /// 获取用户ID
- pub fn user_id(&self) -> &str {
- &self.user_id
- }
-}
diff --git a/cortex-mem-core/src/automation/layer_generator.rs b/cortex-mem-core/src/automation/layer_generator.rs
index 3685f4e..0f3492d 100644
--- a/cortex-mem-core/src/automation/layer_generator.rs
+++ b/cortex-mem-core/src/automation/layer_generator.rs
@@ -100,26 +100,25 @@ impl LayerGenerator {
for scope in &["session", "user", "agent", "resources"] {
let scope_uri = format!("cortex://{}", scope);
- // 检查维度是否存在
+ // Check if scope exists
match self.filesystem.exists(&scope_uri).await {
Ok(true) => {
- log::info!("📂 扫描维度: {} ({})", scope, scope_uri);
+ debug!("Scanning scope: {}", scope);
match self.scan_scope(&scope_uri).await {
Ok(dirs) => {
- log::info!("📂 维度 {} 发现 {} 个目录", scope, dirs.len());
+ debug!("Scope {} found {} directories", scope, dirs.len());
directories.extend(dirs);
}
Err(e) => {
- log::warn!("⚠️ 扫描维度 {} 失败: {}", scope, e);
warn!("Failed to scan scope {}: {}", scope, e);
}
}
}
Ok(false) => {
- log::info!("📂 维度 {} 不存在,跳过", scope);
+ debug!("Scope {} does not exist, skipping", scope);
}
Err(e) => {
- log::warn!("⚠️ 检查维度 {} 存在性失败: {}", scope, e);
+ warn!("Failed to check scope {} existence: {}", scope, e);
}
}
}
@@ -127,35 +126,32 @@ impl LayerGenerator {
Ok(directories)
}
- /// 扫描单个维度
+ /// Scan a single scope
async fn scan_scope(&self, scope_uri: &str) -> Result> {
let mut directories = Vec::new();
- // 先检查维度是否存在
+ // First check if scope exists
match self.filesystem.exists(scope_uri).await {
Ok(true) => {
- log::info!("📂 维度目录存在: {}", scope_uri);
+ debug!("Scope directory exists: {}", scope_uri);
}
Ok(false) => {
- log::info!("📂 维度目录不存在: {}", scope_uri);
+ debug!("Scope directory does not exist: {}", scope_uri);
return Ok(directories);
}
Err(e) => {
- log::warn!("⚠️ 检查维度存在性失败: {} - {}", scope_uri, e);
+ warn!("Failed to check scope existence: {} - {}", scope_uri, e);
return Ok(directories);
}
}
- // 尝试列出目录内容
+ // Try to list directory contents
match self.filesystem.list(scope_uri).await {
Ok(entries) => {
- log::info!("📂 维度 {} 下有 {} 个条目", scope_uri, entries.len());
- for entry in &entries {
- log::info!("📂 - {} (is_dir: {})", entry.name, entry.is_directory);
- }
+ debug!("Scope {} has {} entries", scope_uri, entries.len());
}
Err(e) => {
- log::warn!("⚠️ 列出维度目录失败: {} - {}", scope_uri, e);
+ warn!("Failed to list scope directory: {} - {}", scope_uri, e);
return Ok(directories);
}
}
@@ -230,31 +226,18 @@ impl LayerGenerator {
Ok(missing)
}
- /// 确保所有目录拥有 L0/L1
+ /// Ensure all directories have L0/L1
pub async fn ensure_all_layers(&self) -> Result {
- log::info!("🔍 开始扫描目录...");
- info!("开始扫描目录...");
+ info!("Scanning directories for missing L0/L1 layers...");
let directories = self.scan_all_directories().await?;
- log::info!("📋 发现 {} 个目录", directories.len());
- info!("发现 {} 个目录", directories.len());
+ debug!("Found {} directories", directories.len());
- // 🔧 Debug: 打印扫描到的目录
for dir in &directories {
- log::debug!("扫描到目录: {}", dir);
- debug!("扫描到目录: {}", dir);
+ debug!("Scanned directory: {}", dir);
}
- log::info!("🔎 检测缺失的 L0/L1...");
- info!("检测缺失的 L0/L1...");
let missing = self.filter_missing_layers(&directories).await?;
- log::info!("📋 发现 {} 个目录缺失 L0/L1", missing.len());
- info!("发现 {} 个目录缺失 L0/L1", missing.len());
-
- // 🔧 Debug: 打印缺失层级文件的目录
- for dir in &missing {
- log::info!("📝 需要生成层级文件: {}", dir);
- info!("需要生成层级文件: {}", dir);
- }
+ info!("Found {} directories missing L0/L1", missing.len());
if missing.is_empty() {
return Ok(GenerationStats {
@@ -270,53 +253,49 @@ impl LayerGenerator {
failed: 0,
};
- // 分批生成
+ // Generate in batches
let total_batches = (missing.len() + self.config.batch_size - 1) / self.config.batch_size;
for (batch_idx, batch) in missing.chunks(self.config.batch_size).enumerate() {
- log::info!("📦 处理批次 {}/{}", batch_idx + 1, total_batches);
- info!("处理批次 {}/{}", batch_idx + 1, total_batches);
+ debug!("Processing batch {}/{}", batch_idx + 1, total_batches);
for dir in batch {
match self.generate_layers_for_directory(dir).await {
Ok(_) => {
stats.generated += 1;
- log::info!("✅ 生成成功: {}", dir);
- info!("✓ 生成成功: {}", dir);
+ debug!("Generated: {}", dir);
}
Err(e) => {
stats.failed += 1;
- log::warn!("⚠️ 生成失败: {} - {}", dir, e);
- warn!("✗ 生成失败: {} - {}", dir, e);
+ warn!("Failed to generate for {}: {}", dir, e);
}
}
}
- // 批次间延迟
+ // Delay between batches
if batch_idx < total_batches - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(self.config.delay_ms)).await;
}
}
- log::info!("✅ 生成完成: 成功 {}, 失败 {}", stats.generated, stats.failed);
- info!("生成完成: 成功 {}, 失败 {}", stats.generated, stats.failed);
+ info!("Layer generation completed: {} generated, {} failed", stats.generated, stats.failed);
Ok(stats)
}
- /// 确保特定timeline目录拥有L0/L1层级文件
- /// 用于会话关闭时触发生成,避免频繁更新
+ /// Ensure a specific timeline directory has L0/L1 layer files
+ /// Used when session closes to trigger generation, avoiding frequent updates
pub async fn ensure_timeline_layers(&self, timeline_uri: &str) -> Result {
- info!("开始为timeline生成层级文件: {}", timeline_uri);
+ info!("Starting layer generation for timeline: {}", timeline_uri);
- // 扫描timeline下的所有目录
+ // Scan all directories under timeline
let mut directories = Vec::new();
self.scan_recursive(timeline_uri, &mut directories).await?;
- info!("发现 {} 个timeline目录", directories.len());
+ info!("Found {} timeline directories", directories.len());
- // 检测缺失的 L0/L1
+ // Detect missing L0/L1
let missing = self.filter_missing_layers(&directories).await?;
- info!("发现 {} 个目录缺失 L0/L1", missing.len());
+ info!("Found {} directories missing L0/L1", missing.len());
if missing.is_empty() {
return Ok(GenerationStats {
@@ -332,67 +311,67 @@ impl LayerGenerator {
failed: 0,
};
- // 生成层级文件(不需要分批,因为timeline通常不大)
+ // Generate layer files (no need to batch, timeline is usually small)
for dir in missing {
match self.generate_layers_for_directory(&dir).await {
Ok(_) => {
stats.generated += 1;
- info!("✓ 生成成功: {}", dir);
+ info!("Generation succeeded: {}", dir);
}
Err(e) => {
stats.failed += 1;
- warn!("✗ 生成失败: {} - {}", dir, e);
+ warn!("Generation failed: {} - {}", dir, e);
}
}
}
info!(
- "Timeline层级生成完成: 成功 {}, 失败 {}",
+ "Timeline layer generation completed: {} succeeded, {} failed",
stats.generated, stats.failed
);
Ok(stats)
}
- /// 为单个目录生成 L0/L1
+ /// Generate L0/L1 for a single directory
async fn generate_layers_for_directory(&self, uri: &str) -> Result<()> {
- debug!("生成层级文件: {}", uri);
+ debug!("Generating layer files for: {}", uri);
- // 1. 检查是否需要重新生成(避免重复生成未变更的内容)
+ // 1. Check if regeneration is needed (avoid generating unchanged content)
if !self.should_regenerate(uri).await? {
- debug!("目录内容未变更,跳过生成: {}", uri);
+ debug!("Directory content unchanged, skipping generation: {}", uri);
return Ok(());
}
- // 2. 读取目录内容(聚合所有子文件)
+ // 2. Read directory content (aggregate all sub-files)
let content = self.aggregate_directory_content(uri).await?;
if content.is_empty() {
- debug!("目录为空,跳过: {}", uri);
+ debug!("Directory is empty, skipping: {}", uri);
return Ok(());
}
- // 3. 使用现有的 AbstractGenerator 生成 L0 抽象
+ // 3. Use existing AbstractGenerator to generate L0 abstract
let abstract_text = self
.abstract_gen
- .generate_with_llm(&content, &self.llm_client)
+ .generate_with_llm(&content, &self.llm_client, &[])
.await?;
- // 4. 使用现有的 OverviewGenerator 生成 L1 概览
+ // 4. Use existing OverviewGenerator to generate L1 overview
let overview = self
.overview_gen
.generate_with_llm(&content, &self.llm_client)
.await?;
- // 5. 强制执行长度限制
+ // 5. Enforce length limits
let abstract_text = self.enforce_abstract_limit(abstract_text)?;
let overview = self.enforce_overview_limit(overview)?;
- // 6. 添加 "Added" 日期标记(与 extraction.rs 保持一致)
+ // 6. Add "Added" date marker (consistent with extraction.rs)
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
let abstract_with_date = format!("{}\n\n**Added**: {}", abstract_text, timestamp);
let overview_with_date = format!("{}\n\n---\n\n**Added**: {}", overview, timestamp);
- // 7. 写入文件
+ // 7. Write files
let abstract_path = format!("{}/.abstract.md", uri);
let overview_path = format!("{}/.overview.md", uri);
@@ -403,64 +382,64 @@ impl LayerGenerator {
.write(&overview_path, &overview_with_date)
.await?;
- debug!("层级文件生成完成: {}", uri);
+ debug!("Layer files generated for: {}", uri);
Ok(())
}
- /// 检查是否需要重新生成层级文件
+ /// Check if layer files need to be regenerated
///
- /// 检查逻辑:
- /// 1. 如果 .abstract.md 或 .overview.md 不存在 → 需要生成
- /// 2. 如果目录中有文件比 .abstract.md 更新 → 需要重新生成
- /// 3. 否则 → 跳过(避免重复生成)
+ /// Check logic:
+ /// 1. If .abstract.md or .overview.md doesn't exist → need to generate
+ /// 2. If files in directory are newer than .abstract.md → need to regenerate
+ /// 3. Otherwise → skip (avoid duplicate generation)
async fn should_regenerate(&self, uri: &str) -> Result {
let abstract_path = format!("{}/.abstract.md", uri);
let overview_path = format!("{}/.overview.md", uri);
- // 检查层级文件是否存在
+ // Check if layer files exist
let abstract_exists = self.filesystem.exists(&abstract_path).await?;
let overview_exists = self.filesystem.exists(&overview_path).await?;
if !abstract_exists || !overview_exists {
- debug!("层级文件缺失,需要生成: {}", uri);
+ debug!("Layer files missing, need to generate: {}", uri);
return Ok(true);
}
- // 读取 .abstract.md 中的时间戳
+ // Read timestamp from .abstract.md
let abstract_content = match self.filesystem.read(&abstract_path).await {
Ok(content) => content,
Err(_) => {
- debug!("无法读取 .abstract.md,需要重新生成: {}", uri);
+ debug!("Cannot read .abstract.md, need to regenerate: {}", uri);
return Ok(true);
}
};
- // 提取 "Added" 时间戳
+ // Extract "Added" timestamp
let abstract_timestamp = self.extract_added_timestamp(&abstract_content);
if abstract_timestamp.is_none() {
- debug!(".abstract.md 缺少时间戳,需要重新生成: {}", uri);
+ debug!(".abstract.md missing timestamp, need to regenerate: {}", uri);
return Ok(true);
}
let abstract_time = abstract_timestamp.unwrap();
- // 检查目录中的文件是否有更新
+ // Check if files in directory have updates
let entries = self.filesystem.list(uri).await?;
for entry in entries {
- // 跳过隐藏文件和目录
+ // Skip hidden files and directories
if entry.name.starts_with('.') || entry.is_directory {
continue;
}
- // 只检查 .md 和 .txt 文件
+ // Only check .md and .txt files
if entry.name.ends_with(".md") || entry.name.ends_with(".txt") {
- // 读取文件内容,提取其中的时间戳(如果有)
+ // Read file content, extract timestamp if any
if let Ok(file_content) = self.filesystem.read(&entry.uri).await {
if let Some(file_time) = self.extract_added_timestamp(&file_content) {
- // 如果文件时间戳晚于 abstract 时间戳,需要重新生成
+ // If file timestamp is later than abstract timestamp, need to regenerate
if file_time > abstract_time {
- debug!("文件 {} 有更新,需要重新生成: {}", entry.name, uri);
+ debug!("File {} has updates, need to regenerate: {}", entry.name, uri);
return Ok(true);
}
}
@@ -468,18 +447,18 @@ impl LayerGenerator {
}
}
- debug!("目录内容未变更,无需重新生成: {}", uri);
+ debug!("Directory content unchanged, no need to regenerate: {}", uri);
Ok(false)
}
- /// 从内容中提取 "Added" 时间戳
+ /// Extract "Added" timestamp from content
fn extract_added_timestamp(&self, content: &str) -> Option> {
- // 查找 "**Added**: YYYY-MM-DD HH:MM:SS UTC" 格式
+ // Find "**Added**: YYYY-MM-DD HH:MM:SS UTC" format
if let Some(start) = content.find("**Added**: ") {
let timestamp_str = &content[start + 11..];
if let Some(end) = timestamp_str.find('\n') {
let timestamp_str = ×tamp_str[..end].trim();
- // 解析时间戳
+ // Parse timestamp
if let Ok(dt) = DateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S UTC") {
return Some(dt.with_timezone(&Utc));
}
@@ -594,9 +573,9 @@ impl LayerGenerator {
Ok(result)
}
- /// 重新生成所有超大的 .abstract 文件
+ /// Regenerate all oversized .abstract files
pub async fn regenerate_oversized_abstracts(&self) -> Result {
- info!("扫描超大的 .abstract 文件...");
+ info!("Scanning for oversized .abstract files...");
let directories = self.scan_all_directories().await?;
let max_chars = self.config.abstract_config.max_chars;
@@ -610,13 +589,13 @@ impl LayerGenerator {
let abstract_path = format!("{}/.abstract.md", dir);
if let Ok(content) = self.filesystem.read(&abstract_path).await {
- // 移除 "Added" 标记后再检查长度
+ // Remove "Added" marker before checking length
let content_without_metadata = self.strip_metadata(&content);
if content_without_metadata.len() > max_chars {
stats.total += 1;
info!(
- "发现超大 .abstract: {} ({} 字符)",
+ "Found oversized .abstract: {} ({} chars)",
dir,
content_without_metadata.len()
);
@@ -624,11 +603,11 @@ impl LayerGenerator {
match self.generate_layers_for_directory(&dir).await {
Ok(_) => {
stats.regenerated += 1;
- info!("✓ 重新生成成功: {}", dir);
+ info!("Regeneration succeeded: {}", dir);
}
Err(e) => {
stats.failed += 1;
- warn!("✗ 重新生成失败: {} - {}", dir, e);
+ warn!("Regeneration failed: {} - {}", dir, e);
}
}
}
@@ -636,7 +615,7 @@ impl LayerGenerator {
}
info!(
- "重新生成完成: 总计 {}, 成功 {}, 失败 {}",
+ "Regeneration completed: total={}, succeeded={}, failed={}",
stats.total, stats.regenerated, stats.failed
);
diff --git a/cortex-mem-core/src/automation/layer_generator_tests.rs b/cortex-mem-core/src/automation/layer_generator_tests.rs
deleted file mode 100644
index 118f728..0000000
--- a/cortex-mem-core/src/automation/layer_generator_tests.rs
+++ /dev/null
@@ -1,273 +0,0 @@
-use super::*;
-use crate::{CortexFilesystem, FilesystemOperations, llm::{LLMClient, LLMConfig, MemoryExtractionResponse}, Result};
-use std::sync::Arc;
-use async_trait::async_trait;
-
-/// Mock LLM Client for testing
-struct MockLLMClient {
- abstract_response: String,
- overview_response: String,
-}
-
-impl MockLLMClient {
- fn new() -> Self {
- Self {
- abstract_response: "Mock abstract summary for testing.".to_string(),
- overview_response: "# Mock Overview\n\nThis is a mock overview for testing purposes.\n\n## Topics\n- Testing\n- Mocking".to_string(),
- }
- }
-}
-
-#[async_trait]
-impl LLMClient for MockLLMClient {
- async fn complete(&self, _prompt: &str) -> Result {
- Ok(self.abstract_response.clone())
- }
-
- async fn complete_with_system(&self, system: &str, _prompt: &str) -> Result {
- if system.contains("abstract") || system.contains("摘要") {
- Ok(self.abstract_response.clone())
- } else {
- Ok(self.overview_response.clone())
- }
- }
-
- async fn extract_memories(&self, _prompt: &str) -> Result {
- Ok(MemoryExtractionResponse {
- facts: vec![],
- decisions: vec![],
- entities: vec![],
- })
- }
-
- async fn extract_structured_facts(&self, _prompt: &str) -> Result {
- Ok(crate::llm::extractor_types::StructuredFactExtraction {
- facts: vec![],
- })
- }
-
- async fn extract_detailed_facts(&self, _prompt: &str) -> Result {
- Ok(crate::llm::extractor_types::DetailedFactExtraction {
- facts: vec![],
- })
- }
-
- fn model_name(&self) -> &str {
- "mock-llm"
- }
-
- fn config(&self) -> &LLMConfig {
- // Return a static config
- static CONFIG: LLMConfig = LLMConfig {
- api_base_url: String::new(),
- api_key: String::new(),
- model_efficient: String::new(),
- temperature: 0.7,
- max_tokens: 2048,
- };
- &CONFIG
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::TempDir;
-
- async fn setup_test_filesystem() -> (Arc, TempDir) {
- let temp_dir = TempDir::new().unwrap();
- let fs = Arc::new(CortexFilesystem::with_tenant(
- temp_dir.path(),
- "test-tenant",
- ));
- fs.initialize().await.unwrap();
- (fs, temp_dir)
- }
-
- fn mock_llm_client() -> Arc {
- Arc::new(MockLLMClient::new())
- }
-
- fn default_config() -> LayerGenerationConfig {
- LayerGenerationConfig {
- batch_size: 2,
- delay_ms: 100,
- auto_generate_on_startup: false,
- abstract_config: AbstractConfig {
- max_tokens: 400,
- max_chars: 2000,
- target_sentences: 2,
- },
- overview_config: OverviewConfig {
- max_tokens: 1500,
- max_chars: 6000,
- },
- }
- }
-
- #[tokio::test]
- async fn test_scan_all_directories_empty() {
- let (fs, _temp) = setup_test_filesystem().await;
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
-
- let dirs = generator.scan_all_directories().await.unwrap();
-
- assert_eq!(dirs.len(), 0, "Empty filesystem should return no directories");
- }
-
- #[tokio::test]
- async fn test_scan_all_directories_with_files() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- // Create test directories with files
- fs.write("cortex://user/test-user/preferences/pref1.md", "content").await.unwrap();
- fs.write("cortex://agent/test-agent/cases/case1.md", "content").await.unwrap();
- fs.write("cortex://session/test-session/timeline/2026-02/25/msg1.md", "content").await.unwrap();
-
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
- let dirs = generator.scan_all_directories().await.unwrap();
-
- // Should find user/test-user, user/test-user/preferences, agent/test-agent, etc.
- assert!(dirs.len() > 0, "Should find directories");
- assert!(dirs.iter().any(|d| d.contains("preferences")), "Should find preferences dir");
- assert!(dirs.iter().any(|d| d.contains("cases")), "Should find cases dir");
- }
-
- #[tokio::test]
- async fn test_scan_nested_directories() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- fs.write("cortex://user/u1/prefs/lang/rust.md", "content").await.unwrap();
- fs.write("cortex://user/u1/prefs/lang/python.md", "content").await.unwrap();
-
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
- let dirs = generator.scan_all_directories().await.unwrap();
-
- // Should include all nested levels
- assert!(dirs.iter().any(|d| d.contains("user/u1")));
- assert!(dirs.iter().any(|d| d.contains("prefs")));
- assert!(dirs.iter().any(|d| d.contains("lang")));
- }
-
- #[tokio::test]
- async fn test_has_layers_both_present() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- fs.write("cortex://user/test/.abstract.md", "abstract").await.unwrap();
- fs.write("cortex://user/test/.overview.md", "overview").await.unwrap();
-
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
- let has_layers = generator.has_layers("cortex://user/test").await.unwrap();
-
- assert!(has_layers, "Should have layers when both files exist");
- }
-
- #[tokio::test]
- async fn test_has_layers_missing_abstract() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- fs.write("cortex://user/test/.overview.md", "overview").await.unwrap();
-
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
- let has_layers = generator.has_layers("cortex://user/test").await.unwrap();
-
- assert!(!has_layers, "Should not have layers when abstract is missing");
- }
-
- #[tokio::test]
- async fn test_has_layers_missing_overview() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- fs.write("cortex://user/test/.abstract.md", "abstract").await.unwrap();
-
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
- let has_layers = generator.has_layers("cortex://user/test").await.unwrap();
-
- assert!(!has_layers, "Should not have layers when overview is missing");
- }
-
- #[tokio::test]
- async fn test_has_layers_both_missing() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- fs.write("cortex://user/test/file.md", "content").await.unwrap();
-
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
- let has_layers = generator.has_layers("cortex://user/test").await.unwrap();
-
- assert!(!has_layers, "Should not have layers when both files are missing");
- }
-
- #[tokio::test]
- async fn test_filter_missing_layers() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- // Create one complete directory
- fs.write("cortex://user/complete/.abstract.md", "a").await.unwrap();
- fs.write("cortex://user/complete/.overview.md", "o").await.unwrap();
- fs.write("cortex://user/complete/file.md", "content").await.unwrap();
-
- // Create two incomplete directories
- fs.write("cortex://user/missing1/file.md", "content").await.unwrap();
- fs.write("cortex://user/missing2/file.md", "content").await.unwrap();
-
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
-
- let all_dirs = vec![
- "cortex://user/complete".to_string(),
- "cortex://user/missing1".to_string(),
- "cortex://user/missing2".to_string(),
- ];
-
- let missing = generator.filter_missing_layers(&all_dirs).await.unwrap();
-
- assert_eq!(missing.len(), 2, "Should find 2 missing directories");
- assert!(missing.contains(&"cortex://user/missing1".to_string()));
- assert!(missing.contains(&"cortex://user/missing2".to_string()));
- assert!(!missing.contains(&"cortex://user/complete".to_string()));
- }
-
- #[tokio::test]
- async fn test_ensure_all_layers_empty_filesystem() {
- let (fs, _temp) = setup_test_filesystem().await;
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
-
- let stats = generator.ensure_all_layers().await.unwrap();
-
- assert_eq!(stats.total, 0);
- assert_eq!(stats.generated, 0);
- assert_eq!(stats.failed, 0);
- }
-
- #[tokio::test]
- async fn test_ensure_all_layers_with_missing() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- // Create directories with content but no L0/L1
- fs.write("cortex://user/test1/pref.md", "User preference content for testing").await.unwrap();
- fs.write("cortex://user/test2/pref.md", "Another preference for testing").await.unwrap();
-
- let generator = LayerGenerator::new(fs, mock_llm_client(), default_config());
- let stats = generator.ensure_all_layers().await.unwrap();
-
- // Should attempt to generate for missing directories
- assert!(stats.total > 0, "Should find directories needing generation");
- assert!(stats.generated > 0 || stats.failed > 0, "Should attempt generation");
- }
-
- #[tokio::test]
- async fn test_regenerate_oversized_abstracts_no_oversized() {
- let (fs, _temp) = setup_test_filesystem().await;
-
- // Create a normal-sized abstract
- let normal_content = "Short abstract.\n\n**Added**: 2026-02-25 12:00:00 UTC";
- fs.write("cortex://user/test/.abstract.md", normal_content).await.unwrap();
- fs.write("cortex://user/test/file.md", "content").await.unwrap();
-
- let generator = LayerGenerator::new(fs.clone(), mock_llm_client(), default_config());
- let stats = generator.regenerate_oversized_abstracts().await.unwrap();
-
- assert_eq!(stats.total, 0, "Should not find any oversized abstracts");
- assert_eq!(stats.regenerated, 0);
- }
-}
diff --git a/cortex-mem-core/src/automation/manager.rs b/cortex-mem-core/src/automation/manager.rs
index 797c9f5..f89128e 100644
--- a/cortex-mem-core/src/automation/manager.rs
+++ b/cortex-mem-core/src/automation/manager.rs
@@ -1,7 +1,8 @@
use crate::{
Result,
- automation::{AutoExtractor, AutoIndexer, LayerGenerator},
+ automation::AutoIndexer,
events::{CortexEvent, SessionEvent},
+ memory_events::{ChangeType, MemoryEvent},
};
use std::collections::HashSet;
use std::sync::Arc;
@@ -14,114 +15,93 @@ use tracing::{info, warn};
pub struct AutomationConfig {
/// 是否启用自动索引
pub auto_index: bool,
- /// 是否启用自动提取
- pub auto_extract: bool,
/// 消息添加时是否立即索引(实时)
pub index_on_message: bool,
- /// 会话关闭时是否索引(批量)
- pub index_on_close: bool,
/// 索引批处理延迟(秒)
pub index_batch_delay: u64,
- /// 启动时自动生成缺失的 L0/L1 文件
- pub auto_generate_layers_on_startup: bool,
- /// 每N条消息触发一次L0/L1生成(0表示禁用)
- pub generate_layers_every_n_messages: usize,
- /// 最大并发 LLM 任务数(防止压垮 LLM API)
- pub max_concurrent_llm_tasks: usize,
+ /// 最大并发任务数
+ pub max_concurrent_tasks: usize,
}
impl Default for AutomationConfig {
fn default() -> Self {
Self {
auto_index: true,
- auto_extract: true,
- index_on_message: false, // 默认不实时索引(性能考虑)
- index_on_close: true, // 默认会话关闭时索引
+ index_on_message: false, // 默认批处理模式(性能考虑)
index_batch_delay: 2,
- auto_generate_layers_on_startup: false, // 默认关闭(避免启动时阻塞)
- generate_layers_every_n_messages: 0, // 默认禁用(避免频繁LLM调用)
- max_concurrent_llm_tasks: 3, // 默认最多3个并发LLM任务
+ max_concurrent_tasks: 3,
}
}
}
-/// 自动化管理器 - 统一调度索引和提取
+/// 自动化管理器
+///
+/// ## 职责
+/// 监听 `MessageAdded` 事件,将新消息内容(L2 级别)索引到向量数据库。
+///
+/// ## 事件系统集成
+/// - 输入:旧的 `EventBus`(`CortexEvent`)—— 来自 `SessionManager` 的消息通知
+/// - 输出(可选):向 `MemoryEventCoordinator` 的 `MemoryEvent::VectorSyncNeeded` 通道
+/// 发送索引请求,由协调器统一调度;若未配置则直接调用 `AutoIndexer`(兼容旧路径)
+///
+/// ## 不再负责
+/// - 记忆提取(由 `MemoryEventCoordinator` 统一处理)
+/// - L0/L1 层级文件生成(由 `CascadeLayerUpdater` 统一处理)
+/// - Session 关闭时的全量索引(由 `VectorSyncManager` 统一处理)
pub struct AutomationManager {
indexer: Arc,
- extractor: Option>,
- layer_generator: Option>,
config: AutomationConfig,
/// 并发限制信号量
- llm_semaphore: Arc,
+ semaphore: Arc,
+ /// Optional: 向 MemoryEventCoordinator 发送 VectorSyncNeeded 事件
+ /// 若已配置,优先通过协调器调度,而非直接调用 AutoIndexer
+ memory_event_tx: Option>,
}
impl AutomationManager {
- /// 创建自动化管理器
- pub fn new(
+ /// 创建自动化管理器(兼容旧路径,不使用 MemoryEventCoordinator)
+ pub fn new(indexer: Arc, config: AutomationConfig) -> Self {
+ let semaphore = Arc::new(Semaphore::new(config.max_concurrent_tasks));
+ Self {
+ indexer,
+ config,
+ semaphore,
+ memory_event_tx: None,
+ }
+ }
+
+ /// 创建自动化管理器,并接入 MemoryEventCoordinator 通道
+ ///
+ /// 推荐:当 `MemoryEventCoordinator` 可用时使用此构造函数,
+ /// 将 L2 索引请求路由到协调器,实现统一调度。
+ pub fn with_memory_events(
indexer: Arc,
- extractor: Option>,
config: AutomationConfig,
+ memory_event_tx: mpsc::UnboundedSender,
) -> Self {
- let llm_semaphore = Arc::new(Semaphore::new(config.max_concurrent_llm_tasks));
+ let semaphore = Arc::new(Semaphore::new(config.max_concurrent_tasks));
Self {
indexer,
- extractor,
- layer_generator: None,
config,
- llm_semaphore,
+ semaphore,
+ memory_event_tx: Some(memory_event_tx),
}
}
- /// 设置层级生成器(可选)
- pub fn with_layer_generator(mut self, layer_generator: Arc) -> Self {
- self.layer_generator = Some(layer_generator);
- self
- }
-
/// 获取并发限制信号量(供外部使用)
- pub fn llm_semaphore(&self) -> Arc {
- self.llm_semaphore.clone()
+ pub fn semaphore(&self) -> Arc {
+ self.semaphore.clone()
}
- /// 🎯 核心方法:启动自动化任务
+ /// 启动自动化任务,监听 EventBus 事件
pub async fn start(self, mut event_rx: mpsc::UnboundedReceiver) -> Result<()> {
- info!("Starting AutomationManager with config: {:?}", self.config);
-
- // 启动时自动生成缺失的 L0/L1 文件
- if self.config.auto_generate_layers_on_startup {
- if let Some(ref generator) = self.layer_generator {
- info!("启动时检查并生成缺失的 L0/L1 文件...");
- let generator_clone = generator.clone();
- let semaphore = self.llm_semaphore.clone();
- tokio::spawn(async move {
- // 获取信号量许可
- let _permit = semaphore.acquire().await;
- match generator_clone.ensure_all_layers().await {
- Ok(stats) => {
- info!(
- "启动时层级生成完成: 总计 {}, 成功 {}, 失败 {}",
- stats.total, stats.generated, stats.failed
- );
- }
- Err(e) => {
- warn!("启动时层级生成失败: {}", e);
- }
- }
- });
- } else {
- warn!("auto_generate_layers_on_startup 已启用但未设置 layer_generator");
- }
- }
+ info!("AutomationManager started (L2 message indexing only)");
- // 批处理缓冲区(收集需要索引的session_id)
+ // 批处理缓冲区(收集需要索引的 session_id)
let mut pending_sessions: HashSet = HashSet::new();
let batch_delay = Duration::from_secs(self.config.index_batch_delay);
let mut batch_timer: Option = None;
- // 会话消息计数器(用于触发定期L0/L1生成)
- let mut session_message_counts: std::collections::HashMap =
- std::collections::HashMap::new();
-
loop {
tokio::select! {
// 事件处理
@@ -131,9 +111,8 @@ impl AutomationManager {
&mut pending_sessions,
&mut batch_timer,
batch_delay,
- &mut session_message_counts
).await {
- warn!("Failed to handle event: {}", e);
+ warn!("AutomationManager: failed to handle event: {}", e);
}
}
@@ -147,7 +126,7 @@ impl AutomationManager {
} => {
if !pending_sessions.is_empty() {
if let Err(e) = self.flush_batch(&mut pending_sessions).await {
- warn!("Failed to flush batch: {}", e);
+ warn!("AutomationManager: failed to flush batch: {}", e);
}
batch_timer = None;
}
@@ -156,233 +135,83 @@ impl AutomationManager {
}
}
- /// 处理事件
+ /// 处理事件 — 仅关心 MessageAdded(L2 索引)
async fn handle_event(
&self,
event: CortexEvent,
pending_sessions: &mut HashSet,
batch_timer: &mut Option,
batch_delay: Duration,
- session_message_counts: &mut std::collections::HashMap,
) -> Result<()> {
match event {
CortexEvent::Session(SessionEvent::MessageAdded { session_id, .. }) => {
- // 更新消息计数
- let count = session_message_counts
- .entry(session_id.clone())
- .or_insert(0);
- *count += 1;
-
- // 检查是否需要基于消息数量触发L0/L1生成
- if self.config.generate_layers_every_n_messages > 0
- && *count % self.config.generate_layers_every_n_messages == 0
- {
- if let Some(ref generator) = self.layer_generator {
- info!(
- "Message count threshold reached ({} messages), triggering L0/L1 generation for session: {}",
- count, session_id
- );
-
- // 异步生成L0/L1(带并发限制)
- let generator_clone = generator.clone();
- let indexer_clone = self.indexer.clone();
- let session_id_clone = session_id.clone();
- let auto_index = self.config.auto_index;
- let semaphore = self.llm_semaphore.clone();
-
- tokio::spawn(async move {
- // 获取信号量许可(限制并发)
- let _permit = semaphore.acquire().await;
- let timeline_uri =
- format!("cortex://session/{}/timeline", session_id_clone);
-
- // 生成L0/L1
- match generator_clone.ensure_timeline_layers(&timeline_uri).await {
- Ok(stats) => {
- info!(
- "✓ Periodic L0/L1 generation for {}: total={}, generated={}, failed={}",
- session_id_clone,
- stats.total,
- stats.generated,
- stats.failed
- );
-
- // 生成后索引(如果启用了auto_index)
- if auto_index && stats.generated > 0 {
- match indexer_clone.index_thread(&session_id_clone).await {
- Ok(index_stats) => {
- info!(
- "✓ L0/L1 indexed for {}: {} indexed",
- session_id_clone, index_stats.total_indexed
- );
- }
- Err(e) => {
- warn!(
- "✗ Failed to index L0/L1 for {}: {}",
- session_id_clone, e
- );
- }
- }
- }
- }
- Err(e) => {
- warn!(
- "✗ Periodic L0/L1 generation failed for {}: {}",
- session_id_clone, e
- );
- }
- }
- });
- }
+ if !self.config.auto_index {
+ return Ok(());
}
if self.config.index_on_message {
- // 实时索引模式:立即索引
- info!("Real-time indexing session: {}", session_id);
- self.index_session(&session_id).await?;
+ // 实时索引模式:立即索引本 session 的 L2 消息
+ info!("AutomationManager: real-time L2 indexing for session {}", session_id);
+ self.index_session_l2(&session_id).await?;
} else {
// 批处理模式:加入待处理队列
pending_sessions.insert(session_id);
-
- // 启动批处理定时器(如果未启动)
if batch_timer.is_none() {
*batch_timer = Some(tokio::time::Instant::now() + batch_delay);
}
}
}
- CortexEvent::Session(SessionEvent::Closed { session_id }) => {
- if self.config.index_on_close {
- info!(
- "Session closed, triggering async full processing: {}",
- session_id
- );
-
- // 异步执行所有后处理任务(带并发限制)
- let extractor = self.extractor.clone();
- let generator = self.layer_generator.clone();
- let indexer = self.indexer.clone();
- let auto_extract = self.config.auto_extract;
- let auto_index = self.config.auto_index;
- let session_id_clone = session_id.clone();
- let semaphore = self.llm_semaphore.clone();
-
- tokio::spawn(async move {
- // 获取信号量许可(限制并发)
- let _permit = semaphore.acquire().await;
- let start = tokio::time::Instant::now();
-
- // 1. 自动提取记忆(如果配置了且有extractor)
- if auto_extract {
- if let Some(ref extractor) = extractor {
- match extractor.extract_session(&session_id_clone).await {
- Ok(stats) => {
- info!(
- "✓ Extraction completed for {}: {:?}",
- session_id_clone, stats
- );
- }
- Err(e) => {
- warn!(
- "✗ Extraction failed for {}: {}",
- session_id_clone, e
- );
- }
- }
- }
- }
-
- // 2. 生成 L0/L1 层级文件(如果配置了layer_generator)
- if let Some(ref generator) = generator {
- info!("Generating L0/L1 layers for session: {}", session_id_clone);
- let timeline_uri =
- format!("cortex://session/{}/timeline", session_id_clone);
-
- match generator.ensure_timeline_layers(&timeline_uri).await {
- Ok(stats) => {
- info!(
- "✓ L0/L1 generation completed for {}: total={}, generated={}, failed={}",
- session_id_clone,
- stats.total,
- stats.generated,
- stats.failed
- );
- }
- Err(e) => {
- warn!(
- "✗ L0/L1 generation failed for {}: {}",
- session_id_clone, e
- );
- }
- }
- }
-
- // 3. 索引整个会话(包括新生成的L0/L1/L2)
- if auto_index {
- match indexer.index_thread(&session_id_clone).await {
- Ok(stats) => {
- info!(
- "✓ Session {} indexed: {} indexed, {} skipped, {} errors",
- session_id_clone,
- stats.total_indexed,
- stats.total_skipped,
- stats.total_errors
- );
- }
- Err(e) => {
- warn!("✗ Failed to index session {}: {}", session_id_clone, e);
- }
- }
- }
-
- let duration = start.elapsed();
- info!(
- "🎉 Session {} post-processing completed in {:.2}s",
- session_id_clone,
- duration.as_secs_f64()
- );
- });
-
- info!(
- "Session {} close acknowledged, post-processing running in background",
- session_id
- );
- }
- }
+ // Session 关闭由 MemoryEventCoordinator 全权处理,此处忽略
+ CortexEvent::Session(SessionEvent::Closed { .. }) => {}
- _ => { /* 其他事件暂时忽略 */ }
+ _ => {} // 其他事件忽略
}
Ok(())
}
- /// 批量处理待索引的会话
+ /// 批量处理待索引的 session
async fn flush_batch(&self, pending_sessions: &mut HashSet) -> Result<()> {
- info!("Flushing batch: {} sessions", pending_sessions.len());
-
+ info!("AutomationManager: flushing {} sessions", pending_sessions.len());
for session_id in pending_sessions.drain() {
- if let Err(e) = self.index_session(&session_id).await {
- warn!("Failed to index session {}: {}", session_id, e);
+ if let Err(e) = self.index_session_l2(&session_id).await {
+ warn!("AutomationManager: failed to index session {}: {}", session_id, e);
}
}
-
Ok(())
}
- /// 索引单个会话
- async fn index_session(&self, session_id: &str) -> Result<()> {
+ /// 索引单个 session 的 L2 消息内容
+ ///
+ /// 优先通过 `MemoryEventCoordinator` 调度(`VectorSyncNeeded` 事件);
+ /// 若未配置则直接调用 `AutoIndexer`(兼容旧路径)。
+ async fn index_session_l2(&self, session_id: &str) -> Result<()> {
+ // 优先路径:通过 MemoryEventCoordinator 统一调度
+ if let Some(ref tx) = self.memory_event_tx {
+ let session_uri = format!("cortex://session/{}", session_id);
+ let _ = tx.send(MemoryEvent::VectorSyncNeeded {
+ file_uri: session_uri,
+ change_type: ChangeType::Update,
+ });
+ info!("AutomationManager: dispatched VectorSyncNeeded for session {}", session_id);
+ return Ok(());
+ }
+
+ // 兼容路径:直接调用 AutoIndexer
+ let _permit = self.semaphore.acquire().await;
match self.indexer.index_thread(session_id).await {
Ok(stats) => {
info!(
- "Session {} indexed: {} indexed, {} skipped, {} errors",
+ "AutomationManager: session {} L2 indexed ({} indexed, {} skipped, {} errors)",
session_id, stats.total_indexed, stats.total_skipped, stats.total_errors
);
Ok(())
}
Err(e) => {
- warn!("Failed to index session {}: {}", session_id, e);
+ warn!("AutomationManager: failed to index session {}: {}", session_id, e);
Err(e)
}
}
}
-}
\ No newline at end of file
+}
diff --git a/cortex-mem-core/src/automation/mod.rs b/cortex-mem-core/src/automation/mod.rs
index e461e7a..817c268 100644
--- a/cortex-mem-core/src/automation/mod.rs
+++ b/cortex-mem-core/src/automation/mod.rs
@@ -1,15 +1,8 @@
-mod auto_extract;
mod indexer;
mod layer_generator;
mod manager;
mod sync;
-mod watcher;
-#[cfg(test)]
-#[path = "layer_generator_tests.rs"]
-mod layer_generator_tests;
-
-pub use auto_extract::{AutoExtractConfig, AutoExtractStats, AutoExtractor};
pub use indexer::{AutoIndexer, IndexStats, IndexerConfig};
pub use layer_generator::{
AbstractConfig, GenerationStats, LayerGenerationConfig, LayerGenerator, OverviewConfig,
@@ -17,4 +10,3 @@ pub use layer_generator::{
};
pub use manager::{AutomationConfig, AutomationManager};
pub use sync::{SyncConfig, SyncManager, SyncStats};
-pub use watcher::{FsEvent, FsWatcher, WatcherConfig};
diff --git a/cortex-mem-core/src/automation/sync.rs b/cortex-mem-core/src/automation/sync.rs
index c26d811..8a89e5e 100644
--- a/cortex-mem-core/src/automation/sync.rs
+++ b/cortex-mem-core/src/automation/sync.rs
@@ -249,13 +249,43 @@ impl SyncManager {
let mut stats = SyncStats::default();
// ✅ Generate timeline layers ONLY at session root level (not subdirectories)
- // This prevents overwriting session-level summaries with day-level summaries
+ // This prevents overwriting session-level summaries with day-level summaries.
+ //
+ // Skip if BOTH L0 (.abstract.md) and L1 (.overview.md) already exist.
+ //
+ // Design rationale:
+ // - The authoritative L0/L1 generator for a session is
+ // `CascadeLayerUpdater::update_timeline_layers`, called inside
+ // `MemoryEventCoordinator::on_session_closed` (via `close_session_sync`).
+ // That path runs on every session close and uses content-hash change
+ // detection to avoid redundant LLM calls.
+ // - This code path (`SyncManager`) is only a fallback for the vector-index
+ // pass at exit time. Historical sessions that are already fully closed
+ // have stable L0/L1 files that don't need regeneration.
+ // - The current session's L0/L1 are guaranteed to be generated by the
+ // `close_session_sync` call that precedes this sync pass; so the
+ // exist-check skip is safe here too.
let is_session_timeline_root = uri.ends_with("/timeline") && !uri.contains("/timeline/");
if is_session_timeline_root {
- if let Err(e) = self.generate_timeline_layers(uri).await {
- warn!("Failed to generate timeline layers for {}: {}", uri, e);
+ let l0_exists = self
+ .filesystem
+ .exists(&format!("{}/.abstract.md", uri))
+ .await
+ .unwrap_or(false);
+ let l1_exists = self
+ .filesystem
+ .exists(&format!("{}/.overview.md", uri))
+ .await
+ .unwrap_or(false);
+
+ if l0_exists && l1_exists {
+ debug!("Timeline layers already exist for {}, skipping generation", uri);
} else {
- info!("Generated session-level timeline layers for {}", uri);
+ if let Err(e) = self.generate_timeline_layers(uri).await {
+ warn!("Failed to generate timeline layers for {}: {}", uri, e);
+ } else {
+ info!("Generated session-level timeline layers for {}", uri);
+ }
}
}
diff --git a/cortex-mem-core/src/automation/watcher.rs b/cortex-mem-core/src/automation/watcher.rs
deleted file mode 100644
index 107ea41..0000000
--- a/cortex-mem-core/src/automation/watcher.rs
+++ /dev/null
@@ -1,249 +0,0 @@
-use crate::{
- automation::AutoIndexer,
- filesystem::{CortexFilesystem, FilesystemOperations},
- Result,
-};
-use std::sync::Arc;
-use std::time::Duration;
-use tokio::sync::mpsc;
-use tracing::{debug, info, warn};
-
-/// 文件系统变化事件
-#[derive(Debug, Clone)]
-pub enum FsEvent {
- /// 新消息添加
- MessageAdded {
- thread_id: String,
- message_id: String,
- },
- /// 消息更新
- MessageUpdated {
- thread_id: String,
- message_id: String,
- },
- /// 线程删除
- ThreadDeleted { thread_id: String },
-}
-
-/// 文件监听器配置
-#[derive(Debug, Clone)]
-pub struct WatcherConfig {
- /// 轮询间隔(秒)
- pub poll_interval_secs: u64,
- /// 是否自动索引
- pub auto_index: bool,
- /// 批处理延迟(秒)
- pub batch_delay_secs: u64,
-}
-
-impl Default for WatcherConfig {
- fn default() -> Self {
- Self {
- poll_interval_secs: 5,
- auto_index: true,
- batch_delay_secs: 2,
- }
- }
-}
-
-/// 文件系统监听器
-///
-/// 监听cortex文件系统的变化,触发自动索引
-pub struct FsWatcher {
- filesystem: Arc,
- indexer: Arc,
- config: WatcherConfig,
- event_tx: mpsc::UnboundedSender,
- event_rx: Option>,
-}
-
-impl FsWatcher {
- /// 创建新的监听器
- pub fn new(
- filesystem: Arc,
- indexer: Arc,
- config: WatcherConfig,
- ) -> Self {
- let (event_tx, event_rx) = mpsc::unbounded_channel();
-
- Self {
- filesystem,
- indexer,
- config,
- event_tx,
- event_rx: Some(event_rx),
- }
- }
-
- /// 启动监听器
- pub async fn start(mut self) -> Result<()> {
- info!("Starting filesystem watcher with {:?}", self.config);
-
- let event_rx = self
- .event_rx
- .take()
- .ok_or_else(|| crate::Error::Other("Event receiver already taken".to_string()))?;
-
- // 启动事件处理任务
- let indexer = self.indexer.clone();
- let config = self.config.clone();
- tokio::spawn(async move {
- Self::process_events(event_rx, indexer, config).await;
- });
-
- // 启动轮询任务
- self.poll_filesystem().await
- }
-
- /// 轮询文件系统变化
- async fn poll_filesystem(&self) -> Result<()> {
- let mut last_thread_state = std::collections::HashMap::new();
-
- loop {
- tokio::time::sleep(Duration::from_secs(self.config.poll_interval_secs)).await;
-
- match self.scan_for_changes(&mut last_thread_state).await {
- Ok(events) => {
- for event in events {
- if let Err(e) = self.event_tx.send(event) {
- warn!("Failed to send event: {}", e);
- }
- }
- }
- Err(e) => {
- warn!("Error scanning filesystem: {}", e);
- }
- }
- }
- }
-
- /// 扫描文件系统变化
- async fn scan_for_changes(
- &self,
- last_state: &mut std::collections::HashMap>,
- ) -> Result> {
- let threads_uri = "cortex://session";
- let entries = self.filesystem.list(threads_uri).await?;
-
- let mut events = Vec::new();
-
- for entry in entries {
- if !entry.is_directory || entry.name.starts_with('.') {
- continue;
- }
-
- let thread_id = entry.name.clone();
- let timeline_uri = format!("cortex://session/{}/timeline", thread_id);
-
- // 获取当前线程的所有消息
- match self.get_message_ids(&timeline_uri).await {
- Ok(current_messages) => {
- let previous_messages = last_state.get(&thread_id);
-
- if let Some(prev) = previous_messages {
- // 检测新消息
- for msg_id in ¤t_messages {
- if !prev.contains(msg_id) {
- debug!("New message detected: {} in thread {}", msg_id, thread_id);
- events.push(FsEvent::MessageAdded {
- thread_id: thread_id.clone(),
- message_id: msg_id.clone(),
- });
- }
- }
- }
-
- last_state.insert(thread_id, current_messages);
- }
- Err(e) => {
- warn!("Failed to scan thread {}: {}", thread_id, e);
- }
- }
- }
-
- Ok(events)
- }
-
- /// 获取线程中的所有消息ID
- async fn get_message_ids(&self, timeline_uri: &str) -> Result> {
- let mut message_ids = Vec::new();
- self.collect_message_ids_recursive(timeline_uri, &mut message_ids)
- .await?;
- Ok(message_ids)
- }
-
- /// 递归收集消息ID
- fn collect_message_ids_recursive<'a>(
- &'a self,
- uri: &'a str,
- message_ids: &'a mut Vec,
- ) -> std::pin::Pin> + Send + 'a>> {
- Box::pin(async move {
- let entries = self.filesystem.as_ref().list(uri).await?;
-
- for entry in entries {
- if entry.is_directory && !entry.name.starts_with('.') {
- self.collect_message_ids_recursive(&entry.uri, message_ids)
- .await?;
- } else if entry.name.ends_with(".md") && !entry.name.starts_with('.') {
- // 从文件名提取消息ID
- if let Some(msg_id) = entry.name.strip_suffix(".md") {
- message_ids.push(msg_id.to_string());
- }
- }
- }
-
- Ok(())
- })
- }
-
- /// 处理事件
- async fn process_events(
- mut event_rx: mpsc::UnboundedReceiver,
- indexer: Arc,
- config: WatcherConfig,
- ) {
- let mut pending_threads = std::collections::HashSet::new();
-
- loop {
- tokio::select! {
- Some(event) = event_rx.recv() => {
- match event {
- FsEvent::MessageAdded { thread_id, message_id } => {
- info!("Processing new message: {} in thread {}", message_id, thread_id);
- if config.auto_index {
- pending_threads.insert(thread_id);
- }
- }
- FsEvent::MessageUpdated { thread_id, message_id } => {
- debug!("Message updated: {} in thread {}", message_id, thread_id);
- if config.auto_index {
- pending_threads.insert(thread_id);
- }
- }
- FsEvent::ThreadDeleted { thread_id } => {
- info!("Thread deleted: {}", thread_id);
- pending_threads.remove(&thread_id);
- }
- }
- }
- _ = tokio::time::sleep(Duration::from_secs(config.batch_delay_secs)) => {
- // 批量处理待索引的线程
- if !pending_threads.is_empty() {
- let threads: Vec<_> = pending_threads.drain().collect();
- for thread_id in threads {
- match indexer.index_thread(&thread_id).await {
- Ok(stats) => {
- info!("Auto-indexed thread {}: {} messages", thread_id, stats.total_indexed);
- }
- Err(e) => {
- warn!("Failed to auto-index thread {}: {}", thread_id, e);
- }
- }
- }
- }
- }
- }
- }
- }
-}
diff --git a/cortex-mem-core/src/builder.rs b/cortex-mem-core/src/builder.rs
index fceab10..1831164 100644
--- a/cortex-mem-core/src/builder.rs
+++ b/cortex-mem-core/src/builder.rs
@@ -22,7 +22,7 @@ pub struct CortexMemBuilder {
qdrant_config: Option,
llm_client: Option>,
session_config: SessionConfig,
- /// v2.5: 事件协调器配置
+ /// 事件协调器配置
coordinator_config: Option,
}
@@ -63,7 +63,7 @@ impl CortexMemBuilder {
self
}
- /// v2.5: 配置事件协调器
+ /// 配置事件协调器
pub fn with_coordinator_config(mut self, config: CoordinatorConfig) -> Self {
self.coordinator_config = Some(config);
self
@@ -71,7 +71,7 @@ impl CortexMemBuilder {
/// 🎯 构建完整的cortex-mem实例
pub async fn build(self) -> Result {
- info!("Building Cortex Memory with v2.5 incremental update support");
+ info!("Building Cortex Memory with incremental update support");
// 1. 初始化文件系统
let filesystem = Arc::new(CortexFilesystem::new(
@@ -94,74 +94,35 @@ impl CortexMemBuilder {
};
// 3. 初始化Qdrant向量存储(可选)
- let vector_store: Option> = if let Some(ref cfg) = self.qdrant_config {
- match QdrantVectorStore::new(cfg).await {
- Ok(store) => {
- info!("Qdrant vector store connected: {}", cfg.url);
- Some(Arc::new(store))
- }
- Err(e) => {
- warn!("Failed to connect to Qdrant, vector search disabled: {}", e);
- None
+ // 同时保留具体类型(供 MemoryEventCoordinator 使用)和 trait object(供 VectorStore 接口使用)
+ let (qdrant_store_typed, vector_store): (Option>, Option>) =
+ if let Some(ref cfg) = self.qdrant_config {
+ match QdrantVectorStore::new(cfg).await {
+ Ok(store) => {
+ info!("Qdrant vector store connected: {}", cfg.url);
+ let typed = Arc::new(store);
+ let dyn_store: Arc = typed.clone();
+ (Some(typed), Some(dyn_store))
+ }
+ Err(e) => {
+ warn!("Failed to connect to Qdrant, vector search disabled: {}", e);
+ (None, None)
+ }
}
- }
- } else {
- None
- };
+ } else {
+ (None, None)
+ };
// 4. 创建事件总线(用于向后兼容)
let (event_bus, _old_event_rx) = EventBus::new();
let event_bus = Arc::new(event_bus);
- // 5. v2.5: 创建 MemoryEventCoordinator(如果配置了所有必需组件)
- let (coordinator_handle, memory_event_tx) =
- if let (Some(llm), Some(emb), Some(_vs)) =
- (&self.llm_client, &embedding, &vector_store)
+ // 5. 创建 MemoryEventCoordinator(如果配置了所有必需组件)
+ let (coordinator_handle, memory_event_tx) =
+ if let (Some(llm), Some(emb), Some(qdrant_store)) =
+ (&self.llm_client, &embedding, &qdrant_store_typed)
{
- // 将 VectorStore trait object 转换为 QdrantVectorStore
- // 由于我们需要具体类型,这里重新从配置创建
- let qdrant_store = if let Some(ref cfg) = self.qdrant_config {
- match QdrantVectorStore::new(cfg).await {
- Ok(store) => Arc::new(store),
- Err(e) => {
- warn!("Failed to create QdrantVectorStore for coordinator: {}", e);
- let fs = filesystem.clone();
- return Ok(CortexMem {
- filesystem: fs.clone(),
- session_manager: Arc::new(RwLock::new(
- SessionManager::with_event_bus(
- fs,
- self.session_config,
- event_bus.as_ref().clone(),
- )
- )),
- embedding,
- vector_store,
- llm_client: self.llm_client,
- event_bus,
- coordinator_handle: None,
- });
- }
- }
- } else {
- warn!("No Qdrant config available for coordinator");
- let fs = filesystem.clone();
- return Ok(CortexMem {
- filesystem: fs.clone(),
- session_manager: Arc::new(RwLock::new(
- SessionManager::with_event_bus(
- fs,
- self.session_config,
- event_bus.as_ref().clone(),
- )
- )),
- embedding,
- vector_store,
- llm_client: self.llm_client,
- event_bus,
- coordinator_handle: None,
- });
- };
+ let qdrant_store = qdrant_store.clone();
let config = self.coordinator_config.unwrap_or_default();
let (coordinator, tx, rx) = MemoryEventCoordinator::new_with_config(
@@ -174,7 +135,7 @@ impl CortexMemBuilder {
// 启动事件协调器
let handle = tokio::spawn(coordinator.start(rx));
- info!("✅ MemoryEventCoordinator started for v2.5 incremental updates");
+ info!("✅ MemoryEventCoordinator started for incremental updates");
(Some(handle), Some(tx))
} else {
@@ -182,9 +143,11 @@ impl CortexMemBuilder {
(None, None)
};
- // 6. 创建SessionManager(带 v2.5 memory_event_tx)
- let session_manager = if let Some(tx) = memory_event_tx {
- // v2.5: 使用 MemoryEventCoordinator 的事件通道
+ // 6. 创建SessionManager(带 memory_event_tx)
+ // Clone the sender so we can keep one for CortexMem's public getter.
+ let memory_event_tx_for_session = memory_event_tx.clone();
+ let session_manager = if let Some(tx) = memory_event_tx_for_session {
+ // 使用 MemoryEventCoordinator 的事件通道
if let Some(ref llm) = self.llm_client {
SessionManager::with_llm_and_events(
filesystem.clone(),
@@ -228,6 +191,8 @@ impl CortexMemBuilder {
vector_store,
llm_client: self.llm_client,
event_bus,
+ qdrant_store_typed,
+ memory_event_tx,
coordinator_handle,
})
}
@@ -242,7 +207,11 @@ pub struct CortexMem {
pub llm_client: Option>,
#[allow(dead_code)]
event_bus: Arc,
- /// v2.5: MemoryEventCoordinator 的后台任务句柄
+ /// Typed Qdrant store (for consumers that need Arc)
+ qdrant_store_typed: Option>,
+ /// Memory event sender (for VectorSearchEngine / AutomationManager wiring)
+ memory_event_tx: Option>,
+ /// MemoryEventCoordinator 的后台任务句柄
coordinator_handle: Option>,
}
@@ -272,6 +241,18 @@ impl CortexMem {
self.llm_client.clone()
}
+ /// 获取具体类型的 Qdrant 存储(供需要 Arc 的消费者使用)
+ pub fn qdrant_store(&self) -> Option> {
+ self.qdrant_store_typed.clone()
+ }
+
+ /// 获取 memory event sender(用于 VectorSearchEngine / AutomationManager 接入遗忘机制)
+ pub fn memory_event_tx(
+ &self,
+ ) -> Option> {
+ self.memory_event_tx.clone()
+ }
+
/// 优雅关闭
pub async fn shutdown(self) -> Result<()> {
info!("Shutting down CortexMem...");
diff --git a/cortex-mem-core/src/cascade_layer_updater.rs b/cortex-mem-core/src/cascade_layer_updater.rs
index 872d42e..3fb7f1d 100644
--- a/cortex-mem-core/src/cascade_layer_updater.rs
+++ b/cortex-mem-core/src/cascade_layer_updater.rs
@@ -19,7 +19,7 @@ use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
-use tracing::{debug, info};
+use tracing::{debug, info, warn};
/// Update statistics for monitoring optimization effectiveness
#[derive(Debug, Clone, Default)]
@@ -128,28 +128,46 @@ impl CascadeLayerUpdater {
content.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
+
+ /// Strip metadata lines added by this module so they don't pollute
+ /// content aggregation used for hash comparison and parent-level summaries.
+ ///
+ /// Stripped lines:
+ /// - `` — source hash footer
+ /// - `**Added**: ...` — timestamp footer
+ fn strip_metadata_lines(content: &str) -> String {
+ content
+ .lines()
+ .filter(|line| {
+ !line.starts_with("`
+ /// This records the hash of the *source* content that was fed to the LLM,
+ /// not the hash of the generated summary text itself.
async fn should_update_layer(&self, layer_uri: &str, new_content_hash: &str) -> Result {
- // Try to read existing layer file
match self.filesystem.read(layer_uri).await {
Ok(existing_content) => {
- // Calculate hash of existing content (excluding timestamp)
- // Remove timestamp line for comparison
- let content_without_ts = existing_content
- .lines()
- .filter(|line| !line.starts_with("**Added**:"))
- .collect::>()
- .join("\n");
-
- let old_hash = self.calculate_content_hash(&content_without_ts);
-
- // Only update if content changed
- Ok(old_hash != new_content_hash)
+ // Look for stored source-hash comment in the file
+ for line in existing_content.lines() {
+ if let Some(rest) = line.strip_prefix("") {
+ return Ok(stored_hash != new_content_hash);
+ }
+ }
+ }
+ // No hash found in old file (legacy format) → regenerate
+ Ok(true)
}
Err(_) => {
// File doesn't exist, need to create
@@ -244,7 +262,7 @@ impl CascadeLayerUpdater {
debug!("💔 Cache MISS, generating with LLM");
let l0 = self.l0_generator
- .generate_with_llm(&content, &self.llm_client)
+ .generate_with_llm(&content, &self.llm_client, &[])
.await?;
let l1 = self.l1_generator
@@ -265,7 +283,7 @@ impl CascadeLayerUpdater {
} else {
// No cache, generate directly
let l0 = self.l0_generator
- .generate_with_llm(&content, &self.llm_client)
+ .generate_with_llm(&content, &self.llm_client, &[])
.await?;
let l1 = self.l1_generator
@@ -284,10 +302,10 @@ impl CascadeLayerUpdater {
stats.updated_count += 1;
}
- // Add timestamp
+ // Add timestamp + source hash footer (used by should_update_layer)
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
- let abstract_with_ts = format!("{}\n\n**Added**: {}", abstract_text, timestamp);
- let overview_with_ts = format!("{}\n\n---\n\n**Added**: {}", overview, timestamp);
+ let abstract_with_ts = format!("{}\n\n**Added**: {}\n", abstract_text, timestamp, new_content_hash);
+ let overview_with_ts = format!("{}\n\n---\n\n**Added**: {}\n", overview, timestamp, new_content_hash);
// Write layer files
let overview_uri = format!("{}/.overview.md", dir_uri);
@@ -403,7 +421,7 @@ impl CascadeLayerUpdater {
debug!("💔 Cache MISS for root, generating with LLM");
let l0 = self.l0_generator
- .generate_with_llm(&aggregated, &self.llm_client)
+ .generate_with_llm(&aggregated, &self.llm_client, &[])
.await?;
let l1 = self.l1_generator
@@ -422,7 +440,7 @@ impl CascadeLayerUpdater {
}
} else {
let l0 = self.l0_generator
- .generate_with_llm(&aggregated, &self.llm_client)
+ .generate_with_llm(&aggregated, &self.llm_client, &[])
.await?;
let l1 = self.l1_generator
@@ -441,10 +459,10 @@ impl CascadeLayerUpdater {
stats.updated_count += 1;
}
- // Add timestamp
+ // Add timestamp + source hash footer (used by should_update_layer)
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
- let abstract_with_ts = format!("{}\n\n**Added**: {}", abstract_text, timestamp);
- let overview_with_ts = format!("{}\n\n---\n\n**Added**: {}", overview, timestamp);
+ let abstract_with_ts = format!("{}\n\n**Added**: {}\n", abstract_text, timestamp, new_content_hash);
+ let overview_with_ts = format!("{}\n\n---\n\n**Added**: {}\n", overview, timestamp, new_content_hash);
// Write layer files
let overview_uri = format!("{}/.overview.md", root_uri);
@@ -486,7 +504,9 @@ impl CascadeLayerUpdater {
match self.filesystem.read(&entry.uri).await {
Ok(file_content) => {
content.push_str(&format!("\n\n=== {} ===\n\n", entry.name));
- content.push_str(&file_content);
+ // Strip source-hash footer so it doesn't pollute parent-level aggregation
+ let stripped = Self::strip_metadata_lines(&file_content);
+ content.push_str(&stripped);
file_count += 1;
}
Err(e) => {
@@ -527,7 +547,9 @@ impl CascadeLayerUpdater {
let abstract_uri = format!("{}/.abstract.md", entry.uri);
if let Ok(abstract_content) = self.filesystem.read(&abstract_uri).await {
content.push_str(&format!("\n\n## {}\n\n", entry.name));
- content.push_str(&abstract_content);
+ // Strip source-hash footer so it doesn't pollute parent-level aggregation
+ let stripped = Self::strip_metadata_lines(&abstract_content);
+ content.push_str(&stripped);
dir_count += 1;
}
}
@@ -584,9 +606,19 @@ impl CascadeLayerUpdater {
return Ok(());
}
+ // 🔧 Hash check: skip if content hasn't changed since last generation
+ let abstract_uri = format!("{}/.abstract.md", timeline_uri);
+ let content_hash = self.calculate_content_hash(&content);
+ if !self.should_update_layer(&abstract_uri, &content_hash).await? {
+ debug!("⏭️ Skipped timeline L0/L1 for session {} (content unchanged)", session_id);
+ // Still update date-level layers (they have their own hash checks)
+ self.update_timeline_date_layers(&timeline_uri).await?;
+ return Ok(());
+ }
+
// Generate L0 abstract
let abstract_text = self.l0_generator
- .generate_with_llm(&content, &self.llm_client)
+ .generate_with_llm(&content, &self.llm_client, &[])
.await?;
// Generate L1 overview
@@ -594,13 +626,12 @@ impl CascadeLayerUpdater {
.generate_with_llm(&content, &self.llm_client)
.await?;
- // Add timestamp
+ // Add timestamp + source hash footer (used by should_update_layer for change detection)
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
- let abstract_with_ts = format!("{}\n\n**Added**: {}", abstract_text, timestamp);
- let overview_with_ts = format!("{}\n\n---\n\n**Added**: {}", overview, timestamp);
+ let abstract_with_ts = format!("{}\n\n**Added**: {}\n", abstract_text, timestamp, content_hash);
+ let overview_with_ts = format!("{}\n\n---\n\n**Added**: {}\n", overview, timestamp, content_hash);
// Write layer files
- let abstract_uri = format!("{}/.abstract.md", timeline_uri);
let overview_uri = format!("{}/.overview.md", timeline_uri);
self.filesystem.write(&abstract_uri, &abstract_with_ts).await?;
@@ -697,17 +728,22 @@ impl CascadeLayerUpdater {
let month_content = self.aggregate_directory_content_recursive(&entry.uri).await?;
if !month_content.is_empty() {
- let abstract_text = self.l0_generator
- .generate_with_llm(&month_content, &self.llm_client)
- .await?;
-
- let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
- let abstract_with_ts = format!("{}\n\n**Added**: {}", abstract_text, timestamp);
-
let abstract_uri = format!("{}/.abstract.md", entry.uri);
- self.filesystem.write(&abstract_uri, &abstract_with_ts).await?;
-
- debug!("Updated month-level L0 for {}", entry.uri);
+ let content_hash = self.calculate_content_hash(&month_content);
+ // Skip if content hasn't changed
+ if self.should_update_layer(&abstract_uri, &content_hash).await? {
+ let abstract_text = self.l0_generator
+ .generate_with_llm(&month_content, &self.llm_client, &[])
+ .await?;
+
+ let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
+ let abstract_with_ts = format!("{}\n\n**Added**: {}\n", abstract_text, timestamp, content_hash);
+
+ self.filesystem.write(&abstract_uri, &abstract_with_ts).await?;
+ debug!("Updated month-level L0 for {}", entry.uri);
+ } else {
+ debug!("Skipped month-level L0 for {} (content unchanged)", entry.uri);
+ }
}
// Process day directories within
@@ -729,17 +765,22 @@ impl CascadeLayerUpdater {
let day_content = self.aggregate_directory_content(&entry.uri).await?;
if !day_content.is_empty() {
- let abstract_text = self.l0_generator
- .generate_with_llm(&day_content, &self.llm_client)
- .await?;
-
- let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
- let abstract_with_ts = format!("{}\n\n**Added**: {}", abstract_text, timestamp);
-
let abstract_uri = format!("{}/.abstract.md", entry.uri);
- self.filesystem.write(&abstract_uri, &abstract_with_ts).await?;
-
- debug!("Updated day-level L0 for {}", entry.uri);
+ let content_hash = self.calculate_content_hash(&day_content);
+ // Skip if content hasn't changed
+ if self.should_update_layer(&abstract_uri, &content_hash).await? {
+ let abstract_text = self.l0_generator
+ .generate_with_llm(&day_content, &self.llm_client, &[])
+ .await?;
+
+ let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
+ let abstract_with_ts = format!("{}\n\n**Added**: {}\n", abstract_text, timestamp, content_hash);
+
+ self.filesystem.write(&abstract_uri, &abstract_with_ts).await?;
+ debug!("Updated day-level L0 for {}", entry.uri);
+ } else {
+ debug!("Skipped day-level L0 for {} (content unchanged)", entry.uri);
+ }
}
}
}
@@ -790,23 +831,22 @@ impl CascadeLayerUpdater {
pub async fn update_all_layers(&self, scope: &MemoryScope, owner_id: &str) -> Result<()> {
let root_uri = self.get_scope_root(scope, owner_id);
- log::info!("🔄 update_all_layers: 检查根目录 {}", root_uri);
+ debug!("Checking root directory: {}", root_uri);
if !self.filesystem.exists(&root_uri).await? {
- log::info!("📂 根目录 {} 不存在,跳过", root_uri);
+ debug!("Root directory {} does not exist, skipping", root_uri);
return Ok(());
}
- log::info!("📂 根目录存在,开始递归更新层级文件...");
+ debug!("Starting recursive layer update...");
// Walk through all directories and update layers
self.update_all_layers_recursive(&root_uri, scope, owner_id).await?;
// Update root layers last
- log::info!("🔄 开始更新根目录层级文件...");
self.update_root_layers(scope, owner_id).await?;
- log::info!("✅ update_all_layers 完成: {:?}", scope);
+ info!("Layer update completed for {:?}", scope);
Ok(())
}
@@ -820,12 +860,11 @@ impl CascadeLayerUpdater {
Box::pin(async move {
let entries = self.filesystem.list(dir_uri).await?;
- log::info!("📂 update_all_layers_recursive: {} 有 {} 个条目", dir_uri, entries.len());
+ debug!("Directory {} has {} entries", dir_uri, entries.len());
// First, process all subdirectories
for entry in &entries {
if entry.is_directory && !entry.name.starts_with('.') {
- log::info!("📂 进入子目录: {}", entry.name);
self.update_all_layers_recursive(&entry.uri, scope, owner_id).await?;
}
}
@@ -835,13 +874,10 @@ impl CascadeLayerUpdater {
!e.is_directory && !e.name.starts_with('.') && e.name.ends_with(".md")
});
- log::info!("📂 目录 {} 是否有内容文件: {}", dir_uri, has_content);
-
if has_content {
- log::info!("🔄 开始为目录 {} 生成层级文件...", dir_uri);
match self.update_directory_layers(dir_uri, scope, owner_id).await {
- Ok(_) => log::info!("✅ 目录 {} 层级文件生成成功", dir_uri),
- Err(e) => log::warn!("⚠️ 目录 {} 层级文件生成失败: {}", dir_uri, e),
+ Ok(_) => debug!("Layer files generated for {}", dir_uri),
+ Err(e) => warn!("Layer generation failed for {}: {}", dir_uri, e),
}
}
diff --git a/cortex-mem-core/src/embedding/cache.rs b/cortex-mem-core/src/embedding/cache.rs
deleted file mode 100644
index c4d5805..0000000
--- a/cortex-mem-core/src/embedding/cache.rs
+++ /dev/null
@@ -1,228 +0,0 @@
-use crate::Result;
-use std::collections::HashMap;
-use std::sync::Arc;
-use tokio::sync::RwLock;
-use std::time::{Duration, Instant};
-
-/// LRU 缓存项
-#[derive(Clone)]
-struct CacheItem {
- embedding: Vec,
- timestamp: Instant,
-}
-
-/// Embedding 缓存配置
-#[derive(Debug, Clone)]
-pub struct CacheConfig {
- /// 最大缓存条目数
- pub max_entries: usize,
- /// 缓存过期时间(秒)
- pub ttl_secs: u64,
-}
-
-impl Default for CacheConfig {
- fn default() -> Self {
- Self {
- max_entries: 10000,
- ttl_secs: 3600, // 1 小时
- }
- }
-}
-
-/// Embedding 缓存层
-///
-/// 为 EmbeddingClient 提供 LRU 缓存,显著减少重复查询
-pub struct EmbeddingCache {
- inner: Arc,
- cache: Arc>>,
- access_order: Arc>>, // LRU 访问顺序
- config: CacheConfig,
-}
-
-impl EmbeddingCache
-where
- T: EmbeddingProvider + Send + Sync,
-{
- pub fn new(inner: Arc, config: CacheConfig) -> Self {
- Self {
- inner,
- cache: Arc::new(RwLock::new(HashMap::new())),
- access_order: Arc::new(RwLock::new(Vec::new())),
- config,
- }
- }
-
- /// 嵌入单个文本(带缓存)
- pub async fn embed(&self, text: &str) -> Result> {
- let key = self.compute_cache_key(text);
-
- // 1. 尝试从缓存读取
- {
- let cache = self.cache.read().await;
- if let Some(item) = cache.get(&key) {
- // 检查是否过期
- if item.timestamp.elapsed() < Duration::from_secs(self.config.ttl_secs) {
- // 更新 LRU 访问顺序
- self.update_access_order(&key).await;
- return Ok(item.embedding.clone());
- }
- }
- }
-
- // 2. 缓存未命中,调用底层 API
- let embedding = self.inner.embed(text).await?;
-
- // 3. 写入缓存
- self.put_cache(key, embedding.clone()).await;
-
- Ok(embedding)
- }
-
- /// 批量嵌入(带缓存)
- pub async fn embed_batch(&self, texts: &[String]) -> Result>> {
- let mut results = Vec::with_capacity(texts.len());
- let mut cache_misses = Vec::new();
- let mut miss_indices = Vec::new();
-
- // 1. 检查缓存
- {
- let cache = self.cache.read().await;
- for (idx, text) in texts.iter().enumerate() {
- let key = self.compute_cache_key(text);
-
- if let Some(item) = cache.get(&key) {
- if item.timestamp.elapsed() < Duration::from_secs(self.config.ttl_secs) {
- results.push(Some(item.embedding.clone()));
- self.update_access_order(&key).await;
- continue;
- }
- }
-
- // 缓存未命中
- results.push(None);
- cache_misses.push(text.clone());
- miss_indices.push(idx);
- }
- }
-
- // 2. 批量查询未命中的项
- if !cache_misses.is_empty() {
- let embeddings = self.inner.embed_batch(&cache_misses).await?;
-
- // 3. 写入缓存并填充结果
- for (text, embedding) in cache_misses.iter().zip(embeddings.iter()) {
- let key = self.compute_cache_key(text);
- self.put_cache(key, embedding.clone()).await;
- }
-
- // 4. 填充缺失的结果
- for (miss_idx, embedding) in miss_indices.iter().zip(embeddings.iter()) {
- results[*miss_idx] = Some(embedding.clone());
- }
- }
-
- // 5. 转换 Option> -> Vec
- Ok(results.into_iter().map(|opt| opt.unwrap()).collect())
- }
-
- /// 计算缓存键(使用文本哈希)
- fn compute_cache_key(&self, text: &str) -> String {
- use std::collections::hash_map::DefaultHasher;
- use std::hash::{Hash, Hasher};
-
- let mut hasher = DefaultHasher::new();
- text.hash(&mut hasher);
- format!("{:x}", hasher.finish())
- }
-
- /// 写入缓存(带 LRU 淘汰)
- async fn put_cache(&self, key: String, embedding: Vec) {
- let mut cache = self.cache.write().await;
- let mut access_order = self.access_order.write().await;
-
- // LRU 淘汰
- if cache.len() >= self.config.max_entries {
- if let Some(oldest_key) = access_order.first().cloned() {
- cache.remove(&oldest_key);
- access_order.remove(0);
- }
- }
-
- // 插入新项
- cache.insert(key.clone(), CacheItem {
- embedding,
- timestamp: Instant::now(),
- });
-
- access_order.push(key);
- }
-
- /// 更新 LRU 访问顺序
- async fn update_access_order(&self, key: &str) {
- let mut access_order = self.access_order.write().await;
-
- if let Some(pos) = access_order.iter().position(|k| k == key) {
- access_order.remove(pos);
- access_order.push(key.to_string());
- }
- }
-
- /// 获取缓存统计
- pub async fn stats(&self) -> CacheStats {
- let cache = self.cache.read().await;
- CacheStats {
- total_entries: cache.len(),
- max_entries: self.config.max_entries,
- ttl_secs: self.config.ttl_secs,
- }
- }
-
- /// 清空缓存
- pub async fn clear(&self) {
- let mut cache = self.cache.write().await;
- let mut access_order = self.access_order.write().await;
- cache.clear();
- access_order.clear();
- }
-}
-
-/// 缓存统计
-#[derive(Debug, Clone)]
-pub struct CacheStats {
- pub total_entries: usize,
- pub max_entries: usize,
- pub ttl_secs: u64,
-}
-
-/// Embedding 提供者 Trait
-///
-/// 抽象出 embed 和 embed_batch 方法,便于缓存层包装
-#[async_trait::async_trait]
-pub trait EmbeddingProvider {
- async fn embed(&self, text: &str) -> Result>;
- async fn embed_batch(&self, texts: &[String]) -> Result>>;
-}
-
-// 为 EmbeddingClient 实现 EmbeddingProvider
-#[async_trait::async_trait]
-impl EmbeddingProvider for crate::embedding::EmbeddingClient {
- async fn embed(&self, text: &str) -> Result> {
- self.embed(text).await
- }
-
- async fn embed_batch(&self, texts: &[String]) -> Result>> {
- self.embed_batch(texts).await
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_cache_config_default() {
- let config = CacheConfig::default();
- assert_eq!(config.max_entries, 10000);
- assert_eq!(config.ttl_secs, 3600);
- }
-}
diff --git a/cortex-mem-core/src/embedding/client.rs b/cortex-mem-core/src/embedding/client.rs
index f2a4603..849b4d4 100644
--- a/cortex-mem-core/src/embedding/client.rs
+++ b/cortex-mem-core/src/embedding/client.rs
@@ -1,14 +1,147 @@
use crate::Result;
use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+use tokio::sync::{Mutex, RwLock};
+use tracing::{debug, info, warn};
-/// Embedding configuration
+/// Embedding 速率限制器
+///
+/// 实现令牌桶算法,保证单并发且每分钟不超过指定次数的 API 调用。
+/// 默认基准:30 次/分钟(即每次请求最小间隔 2000ms)。
+pub struct RateLimiter {
+ /// 上次请求完成的时间戳(None 表示尚未发出任何请求)
+ last_request_at: Mutex