From 80e795ba6879b41eb877c4010a59a396beca20bb Mon Sep 17 00:00:00 2001 From: taki Date: Thu, 26 Feb 2026 18:15:14 +0800 Subject: [PATCH 1/6] feat(qdrant): add api_key support across config and clients - add optional qdrant.api_key to config schema - read QDRANT_API_KEY in core default config fallback - pass api_key to Qdrant client builder - thread api_key through CLI/MCP/RIG/tools/service/example call paths --- cortex-mem-cli/src/main.rs | 1 + cortex-mem-config/src/lib.rs | 2 ++ cortex-mem-core/src/config.rs | 2 ++ cortex-mem-core/src/vector_store/qdrant.rs | 12 ++++++++++++ cortex-mem-mcp/src/main.rs | 3 ++- cortex-mem-rig/src/lib.rs | 4 +++- cortex-mem-service/src/state.rs | 4 ++++ cortex-mem-tools/src/operations.rs | 6 +++++- examples/cortex-mem-tars/src/agent.rs | 3 ++- examples/cortex-mem-tars/src/config.rs | 1 + examples/cortex-mem-tars/src/infrastructure.rs | 3 ++- 11 files changed, 36 insertions(+), 5 deletions(-) diff --git a/cortex-mem-cli/src/main.rs b/cortex-mem-cli/src/main.rs index db4ad50..0b107b5 100644 --- a/cortex-mem-cli/src/main.rs +++ b/cortex-mem-cli/src/main.rs @@ -181,6 +181,7 @@ async fn main() -> Result<()> { llm_client, &config.qdrant.url, &config.qdrant.collection_name, + config.qdrant.api_key.as_deref(), &config.embedding.api_base_url, &config.embedding.api_key, &config.embedding.model_name, diff --git a/cortex-mem-config/src/lib.rs b/cortex-mem-config/src/lib.rs index e5b9750..c47b6b7 100644 --- a/cortex-mem-config/src/lib.rs +++ b/cortex-mem-config/src/lib.rs @@ -57,6 +57,8 @@ pub struct QdrantConfig { pub collection_name: String, pub embedding_dim: Option, pub timeout_secs: u64, + #[serde(default)] + pub api_key: Option, } /// Embedding configuration for vector search diff --git a/cortex-mem-core/src/config.rs b/cortex-mem-core/src/config.rs index 2bdf6d2..3ab4a70 100644 --- a/cortex-mem-core/src/config.rs +++ b/cortex-mem-core/src/config.rs @@ -7,6 +7,7 @@ pub struct QdrantConfig { pub collection_name: String, pub embedding_dim: Option, pub timeout_secs: u64, + pub api_key: Option, /// 🆕 Optional tenant ID for collection isolation /// If set, collection_name will be suffixed with "_" pub tenant_id: Option, @@ -19,6 +20,7 @@ impl Default for QdrantConfig { collection_name: "cortex-mem".to_string(), embedding_dim: None, timeout_secs: 30, + api_key: std::env::var("QDRANT_API_KEY").ok(), tenant_id: None, // 🆕 默认不使用租户隔离 } } diff --git a/cortex-mem-core/src/vector_store/qdrant.rs b/cortex-mem-core/src/vector_store/qdrant.rs index 4b6d6b3..34b5294 100644 --- a/cortex-mem-core/src/vector_store/qdrant.rs +++ b/cortex-mem-core/src/vector_store/qdrant.rs @@ -35,6 +35,12 @@ impl QdrantVectorStore { /// with "_" for tenant isolation. pub async fn new(config: &QdrantConfig) -> Result { let client = Qdrant::from_url(&config.url) + .api_key( + config + .api_key + .clone() + .or_else(|| std::env::var("QDRANT_API_KEY").ok()), + ) .build() .map_err(|e| Error::VectorStore(e))?; @@ -63,6 +69,12 @@ impl QdrantVectorStore { _llm_client: &dyn crate::llm::LLMClient, ) -> Result { let client = Qdrant::from_url(&config.url) + .api_key( + config + .api_key + .clone() + .or_else(|| std::env::var("QDRANT_API_KEY").ok()), + ) .build() .map_err(|e| Error::VectorStore(e))?; diff --git a/cortex-mem-mcp/src/main.rs b/cortex-mem-mcp/src/main.rs index 3962271..321921e 100644 --- a/cortex-mem-mcp/src/main.rs +++ b/cortex-mem-mcp/src/main.rs @@ -65,6 +65,7 @@ async fn main() -> Result<()> { llm_client, &config.qdrant.url, &config.qdrant.collection_name, + config.qdrant.api_key.as_deref(), &config.embedding.api_base_url, &config.embedding.api_key, &config.embedding.model_name, @@ -93,4 +94,4 @@ async fn main() -> Result<()> { } Ok(()) -} \ No newline at end of file +} diff --git a/cortex-mem-rig/src/lib.rs b/cortex-mem-rig/src/lib.rs index 7bff022..d486783 100644 --- a/cortex-mem-rig/src/lib.rs +++ b/cortex-mem-rig/src/lib.rs @@ -76,6 +76,7 @@ pub async fn create_memory_tools_with_tenant_and_vector( llm_client: Arc, qdrant_url: &str, qdrant_collection: &str, + qdrant_api_key: Option<&str>, embedding_api_base_url: &str, embedding_api_key: &str, embedding_model_name: &str, @@ -88,6 +89,7 @@ pub async fn create_memory_tools_with_tenant_and_vector( llm_client, qdrant_url, qdrant_collection, + qdrant_api_key, embedding_api_base_url, embedding_api_key, embedding_model_name, @@ -95,4 +97,4 @@ pub async fn create_memory_tools_with_tenant_and_vector( user_id, // 🆕 传递user_id ).await?; Ok(MemoryTools::new(Arc::new(operations))) -} \ No newline at end of file +} diff --git a/cortex-mem-service/src/state.rs b/cortex-mem-service/src/state.rs index a353559..15d35a9 100644 --- a/cortex-mem-service/src/state.rs +++ b/cortex-mem-service/src/state.rs @@ -173,6 +173,7 @@ impl AppState { collection_name: config.qdrant.collection_name, embedding_dim: config.qdrant.embedding_dim, timeout_secs: config.qdrant.timeout_secs, + api_key: config.qdrant.api_key.clone(), tenant_id: None, // 🆕 初始化时不设置租户ID(global) }; @@ -239,6 +240,7 @@ impl AppState { .ok() .and_then(|s| s.parse().ok()), timeout_secs: 30, + api_key: std::env::var("QDRANT_API_KEY").ok(), tenant_id: None, // 🆕 初始化时不设置租户ID(global) }) } else { @@ -360,6 +362,7 @@ impl AppState { collection_name: config.qdrant.collection_name, embedding_dim: config.qdrant.embedding_dim, timeout_secs: config.qdrant.timeout_secs, + api_key: config.qdrant.api_key.clone(), tenant_id: None, // 🆕 初始化为None }; @@ -382,6 +385,7 @@ impl AppState { .ok() .and_then(|s| s.parse().ok()), timeout_secs: 30, + api_key: std::env::var("QDRANT_API_KEY").ok(), tenant_id, // 🆕 使用当前租户ID }; cortex_mem_core::QdrantVectorStore::new(&qdrant_config) diff --git a/cortex-mem-tools/src/operations.rs b/cortex-mem-tools/src/operations.rs index ee07ae8..37abc01 100644 --- a/cortex-mem-tools/src/operations.rs +++ b/cortex-mem-tools/src/operations.rs @@ -83,6 +83,7 @@ impl MemoryOperations { llm_client: Arc, qdrant_url: &str, qdrant_collection: &str, + qdrant_api_key: Option<&str>, embedding_api_base_url: &str, embedding_api_key: &str, embedding_model_name: &str, @@ -116,6 +117,9 @@ impl MemoryOperations { collection_name: qdrant_collection.to_string(), embedding_dim, timeout_secs: 30, + api_key: qdrant_api_key + .map(|s| s.to_string()) + .or_else(|| std::env::var("QDRANT_API_KEY").ok()), tenant_id: Some(tenant_id.clone()), // 🆕 设置租户ID }; let vector_store = Arc::new(QdrantVectorStore::new(&qdrant_config).await?); @@ -515,4 +519,4 @@ impl MemoryOperations { } } } -} \ No newline at end of file +} diff --git a/examples/cortex-mem-tars/src/agent.rs b/examples/cortex-mem-tars/src/agent.rs index 9cd0540..3f5cc5a 100644 --- a/examples/cortex-mem-tars/src/agent.rs +++ b/examples/cortex-mem-tars/src/agent.rs @@ -82,6 +82,7 @@ pub async fn create_memory_agent( cortex_llm_client, &config.qdrant.url, &config.qdrant.collection_name, + config.qdrant.api_key.as_deref(), &config.embedding.api_base_url, &config.embedding.api_key, &config.embedding.model_name, @@ -664,4 +665,4 @@ impl AgentChatHandler { Ok(response) } -} \ No newline at end of file +} diff --git a/examples/cortex-mem-tars/src/config.rs b/examples/cortex-mem-tars/src/config.rs index df5fda7..0c7b1c3 100644 --- a/examples/cortex-mem-tars/src/config.rs +++ b/examples/cortex-mem-tars/src/config.rs @@ -71,6 +71,7 @@ impl ConfigManager { collection_name: "cortex_mem".to_string(), embedding_dim: Some(1536), timeout_secs: 30, + api_key: std::env::var("QDRANT_API_KEY").ok(), }, embedding: cortex_mem_config::EmbeddingConfig::default(), llm: cortex_mem_config::LLMConfig { diff --git a/examples/cortex-mem-tars/src/infrastructure.rs b/examples/cortex-mem-tars/src/infrastructure.rs index 57c094f..fc1e1fb 100644 --- a/examples/cortex-mem-tars/src/infrastructure.rs +++ b/examples/cortex-mem-tars/src/infrastructure.rs @@ -40,6 +40,7 @@ impl Infrastructure { llm_client, &config.qdrant.url, &config.qdrant.collection_name, + config.qdrant.api_key.as_deref(), &config.embedding.api_base_url, &config.embedding.api_key, &config.embedding.model_name, @@ -61,4 +62,4 @@ impl Infrastructure { pub fn config(&self) -> &Config { &self.config } -} \ No newline at end of file +} From 58e4eb8366e44e1ade76ab7d2be37f79c7733a08 Mon Sep 17 00:00:00 2001 From: Sopaco Date: Fri, 27 Feb 2026 09:44:58 +0800 Subject: [PATCH 2/6] docs update --- cortex-mem-insights/server.ts | 531 +++++++++--------- litho.docs/en/3.Workflow.md | 44 +- .../Automation Management Domain.md | 31 +- .../Layer Management Domain.md | 20 + litho.docs/en/5.Boundary-Interfaces.md | 108 ++++ ...70\345\277\203\346\265\201\347\250\213.md" | 44 +- ...41\347\220\206\351\242\206\345\237\237.md" | 25 + ...41\347\220\206\351\242\206\345\237\237.md" | 11 + ...73\347\273\237\350\276\271\347\225\214.md" | 108 ++++ 9 files changed, 653 insertions(+), 269 deletions(-) diff --git a/cortex-mem-insights/server.ts b/cortex-mem-insights/server.ts index 12e26c6..36eaaed 100644 --- a/cortex-mem-insights/server.ts +++ b/cortex-mem-insights/server.ts @@ -1,5 +1,5 @@ /** - * Cortex Memory Insights - Standalone Server + * Cortex Memory Insights - Standalone Insights Host * * 这个文件是打包成可执行文件的入口点 * 使用Bun的静态文件服务器功能 + API代理 @@ -12,99 +12,113 @@ * - 支持自定义端口 */ -import { spawn } from 'child_process'; -import { existsSync } from 'fs'; -import { join } from 'path'; +import { spawn } from "child_process"; +import { existsSync } from "fs"; +import { join } from "path"; const DEFAULT_PORT = 8159; -const HOST = '127.0.0.1'; -const API_TARGET = process.env.API_TARGET || 'http://localhost:8085'; // cortex-mem-service地址 +const HOST = "127.0.0.1"; +const API_TARGET = process.env.API_TARGET || "http://localhost:8085"; // cortex-mem-service地址 // 版本信息(可通过--define注入) declare const VERSION: string | undefined; declare const BUILD_TIME: string | undefined; -const version = typeof VERSION !== 'undefined' ? VERSION : 'dev'; -const buildTime = typeof BUILD_TIME !== 'undefined' ? BUILD_TIME : new Date().toISOString(); +const version = typeof VERSION !== "undefined" ? VERSION : "dev"; +const buildTime = + typeof BUILD_TIME !== "undefined" ? BUILD_TIME : new Date().toISOString(); /** * 自动打开浏览器 */ function openBrowser(url: string): void { - const platform = process.platform; - let command: string; - - if (platform === 'darwin') { - command = 'open'; - } else if (platform === 'win32') { - command = 'start'; - } else { - // Linux and others - command = 'xdg-open'; - } - - try { - spawn(command, [url], { - detached: true, - stdio: 'ignore' - }).unref(); - console.log(`🌐 Opening browser at ${url}...`); - } catch (error) { - console.warn(`⚠️ Could not open browser automatically: ${error}`); - console.log(`📝 Please open ${url} manually`); - } + const platform = process.platform; + let command: string; + + if (platform === "darwin") { + command = "open"; + } else if (platform === "win32") { + command = "start"; + } else { + // Linux and others + command = "xdg-open"; + } + + try { + spawn(command, [url], { + detached: true, + stdio: "ignore", + }).unref(); + console.log(`🌐 Opening browser at ${url}...`); + } catch (error) { + console.warn(`⚠️ Could not open browser automatically: ${error}`); + console.log(`📝 Please open ${url} manually`); + } } /** * 解析命令行参数 */ -function parseArgs(): { port: number; noBrowser: boolean; help: boolean; apiTarget: string } { - const args = process.argv.slice(2); - - // 帮助信息 - if (args.includes('--help') || args.includes('-h')) { - return { port: DEFAULT_PORT, noBrowser: false, help: true, apiTarget: API_TARGET }; - } - - // 端口 - let port = DEFAULT_PORT; - const portIndex = args.findIndex((arg) => arg === '--port' || arg === '-p'); - if (portIndex >= 0 && args[portIndex + 1]) { - const parsedPort = parseInt(args[portIndex + 1]); - if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort < 65536) { - port = parsedPort; - } - } - - // 环境变量端口 - if (process.env.PORT) { - const envPort = parseInt(process.env.PORT); - if (!isNaN(envPort) && envPort > 0 && envPort < 65536) { - port = envPort; - } - } - - // API target - let apiTarget = API_TARGET; - const apiIndex = args.findIndex((arg) => arg === '--api-target' || arg === '--api'); - if (apiIndex >= 0 && args[apiIndex + 1]) { - apiTarget = args[apiIndex + 1]; - } - - // 禁用自动打开浏览器 - const noBrowser = args.includes('--no-browser') || args.includes('--headless'); - - return { port, noBrowser, help: false, apiTarget }; +function parseArgs(): { + port: number; + noBrowser: boolean; + help: boolean; + apiTarget: string; +} { + const args = process.argv.slice(2); + + // 帮助信息 + if (args.includes("--help") || args.includes("-h")) { + return { + port: DEFAULT_PORT, + noBrowser: false, + help: true, + apiTarget: API_TARGET, + }; + } + + // 端口 + let port = DEFAULT_PORT; + const portIndex = args.findIndex((arg) => arg === "--port" || arg === "-p"); + if (portIndex >= 0 && args[portIndex + 1]) { + const parsedPort = parseInt(args[portIndex + 1]); + if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort < 65536) { + port = parsedPort; + } + } + + // 环境变量端口 + if (process.env.PORT) { + const envPort = parseInt(process.env.PORT); + if (!isNaN(envPort) && envPort > 0 && envPort < 65536) { + port = envPort; + } + } + + // API target + let apiTarget = API_TARGET; + const apiIndex = args.findIndex( + (arg) => arg === "--api-target" || arg === "--api", + ); + if (apiIndex >= 0 && args[apiIndex + 1]) { + apiTarget = args[apiIndex + 1]; + } + + // 禁用自动打开浏览器 + const noBrowser = + args.includes("--no-browser") || args.includes("--headless"); + + return { port, noBrowser, help: false, apiTarget }; } /** * 显示帮助信息 */ function showHelp(): void { - console.log(` + console.log(` ╔════════════════════════════════════════════════╗ ║ Cortex Memory Insights v${version.padEnd(18)} ║ -║ Standalone Server ║ +║ Standalone Insights Host ║ ╚════════════════════════════════════════════════╝ Usage: cortex-mem-insights [options] @@ -137,216 +151,227 @@ Build Information: * 获取dist目录路径 */ function getDistPath(): string { - // 尝试多个可能的路径 - const possiblePaths = [ - join(import.meta.dir, 'dist'), // 开发模式 - join(import.meta.dir, '..', 'dist'), // 编译后可能的路径 - join(process.cwd(), 'dist') // 当前工作目录 - ]; - - for (const path of possiblePaths) { - if (existsSync(path)) { - return path; - } - } - - // 如果都不存在,返回第一个(会在后面报错) - return possiblePaths[0]; + // 尝试多个可能的路径 + const possiblePaths = [ + join(import.meta.dir, "dist"), // 开发模式 + join(import.meta.dir, "..", "dist"), // 编译后可能的路径 + join(process.cwd(), "dist"), // 当前工作目录 + ]; + + for (const path of possiblePaths) { + if (existsSync(path)) { + return path; + } + } + + // 如果都不存在,返回第一个(会在后面报错) + return possiblePaths[0]; } /** * 获取MIME type */ function getMimeType(path: string): string { - const ext = path.split('.').pop()?.toLowerCase(); - const mimeTypes: Record = { - html: 'text/html', - css: 'text/css', - js: 'application/javascript', - json: 'application/json', - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - svg: 'image/svg+xml', - ico: 'image/x-icon', - woff: 'font/woff', - woff2: 'font/woff2', - ttf: 'font/ttf', - eot: 'application/vnd.ms-fontobject' - }; - return mimeTypes[ext || ''] || 'application/octet-stream'; + const ext = path.split(".").pop()?.toLowerCase(); + const mimeTypes: Record = { + html: "text/html", + css: "text/css", + js: "application/javascript", + json: "application/json", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + ico: "image/x-icon", + woff: "font/woff", + woff2: "font/woff2", + ttf: "font/ttf", + eot: "application/vnd.ms-fontobject", + }; + return mimeTypes[ext || ""] || "application/octet-stream"; } /** * 代理请求到后端API */ -async function proxyRequest(req: Request, apiTarget: string): Promise { - const url = new URL(req.url); - const targetUrl = `${apiTarget}${url.pathname}${url.search}`; - - try { - // 复制请求头,但移除host - const headers = new Headers(req.headers); - headers.delete('host'); - - // 转发请求 - const proxyReq = new Request(targetUrl, { - method: req.method, - headers: headers, - body: req.method !== 'GET' && req.method !== 'HEAD' ? req.body : undefined - }); - - const response = await fetch(proxyReq); - - // 复制响应头 - const responseHeaders = new Headers(response.headers); - // 添加CORS头(如果需要) - responseHeaders.set('Access-Control-Allow-Origin', '*'); - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders - }); - } catch (error) { - console.error(`❌ Proxy error for ${url.pathname}:`, error); - return new Response( - JSON.stringify({ - success: false, - error: `Failed to connect to backend service at ${apiTarget}. Please ensure cortex-mem-service is running.`, - timestamp: new Date().toISOString() - }), - { - status: 503, - headers: { - 'Content-Type': 'application/json' - } - } - ); - } +async function proxyRequest( + req: Request, + apiTarget: string, +): Promise { + const url = new URL(req.url); + const targetUrl = `${apiTarget}${url.pathname}${url.search}`; + + try { + // 复制请求头,但移除host + const headers = new Headers(req.headers); + headers.delete("host"); + + // 转发请求 + const proxyReq = new Request(targetUrl, { + method: req.method, + headers: headers, + body: + req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, + }); + + const response = await fetch(proxyReq); + + // 复制响应头 + const responseHeaders = new Headers(response.headers); + // 添加CORS头(如果需要) + responseHeaders.set("Access-Control-Allow-Origin", "*"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error) { + console.error(`❌ Proxy error for ${url.pathname}:`, error); + return new Response( + JSON.stringify({ + success: false, + error: `Failed to connect to backend service at ${apiTarget}. Please ensure cortex-mem-service is running.`, + timestamp: new Date().toISOString(), + }), + { + status: 503, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } } /** * 主函数 */ async function main() { - const { port, noBrowser, help, apiTarget } = parseArgs(); + const { port, noBrowser, help, apiTarget } = parseArgs(); - if (help) { - showHelp(); - process.exit(0); - } + if (help) { + showHelp(); + process.exit(0); + } - console.log(` + console.log(` ╔════════════════════════════════════════════════╗ ║ Cortex Memory Insights v${version.padEnd(18)} ║ -║ Standalone Server ║ +║ Standalone Insights Host ║ ╚════════════════════════════════════════════════╝ `); - console.log(`📦 Version: ${version}`); - console.log(`🔨 Build: ${buildTime}`); - console.log(`🌐 Starting server...`); - - // 获取dist目录 - const distPath = getDistPath(); - - if (!existsSync(distPath)) { - console.error(`\n❌ Error: dist/ directory not found at ${distPath}`); - console.error(`\n💡 Please run 'bun run build' first to generate the dist/ directory\n`); - process.exit(1); - } - - console.log(`📁 Serving from: ${distPath}`); - console.log(`🔗 API proxy to: ${apiTarget}`); - - // 启动HTTP服务器 - const server = Bun.serve({ - port, - hostname: HOST, - - async fetch(req) { - const url = new URL(req.url); - let pathname = url.pathname; - - // API代理:/api/v2/* 和 /health - if (pathname.startsWith('/api/v2') || pathname === '/health') { - return proxyRequest(req, apiTarget); - } - - // 根路径返回index.html - if (pathname === '/') { - pathname = '/index.html'; - } - - // 构建文件路径 - const filePath = join(distPath, pathname); - - // 检查文件是否存在 - const file = Bun.file(filePath); - const exists = await file.exists(); - - if (exists) { - return new Response(file, { - headers: { - 'Content-Type': getMimeType(pathname), - 'Cache-Control': pathname === '/index.html' ? 'no-cache' : 'public, max-age=31536000' - } - }); - } - - // 如果文件不存在,返回index.html(支持前端路由) - // 除非是明确的API路径或静态资源 - if (!pathname.startsWith('/api') && !pathname.includes('.')) { - const indexFile = Bun.file(join(distPath, 'index.html')); - return new Response(indexFile, { - headers: { - 'Content-Type': 'text/html', - 'Cache-Control': 'no-cache' - } - }); - } - - return new Response('Not Found', { status: 404 }); - }, - - error(error) { - console.error('❌ Server error:', error); - return new Response('Internal Server Error', { status: 500 }); - } - }); - - const serverUrl = `http://${HOST}:${port}`; - console.log(`\n✅ Server running at: ${serverUrl}`); - console.log(`📁 Serving: Cortex Memory Insights UI`); - console.log(`🔗 Proxying: /api/v2/* → ${apiTarget}/api/v2/*`); - console.log(`🔗 Proxying: /health → ${apiTarget}/health`); - - // 自动打开浏览器 - if (!noBrowser) { - setTimeout(() => { - openBrowser(serverUrl); - }, 500); // 延迟500ms确保服务器完全启动 - } else { - console.log(`📝 Browser auto-open disabled. Please visit ${serverUrl} manually.`); - } - - console.log(`\n💡 Press Ctrl+C to stop the server\n`); - - // 优雅关闭 - const shutdown = () => { - console.log('\n👋 Shutting down server...'); - server.stop(); - process.exit(0); - }; - - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); + console.log(`📦 Version: ${version}`); + console.log(`🔨 Build: ${buildTime}`); + console.log(`🌐 Starting server...`); + + // 获取dist目录 + const distPath = getDistPath(); + + if (!existsSync(distPath)) { + console.error(`\n❌ Error: dist/ directory not found at ${distPath}`); + console.error( + `\n💡 Please run 'bun run build' first to generate the dist/ directory\n`, + ); + process.exit(1); + } + + console.log(`📁 Serving from: ${distPath}`); + console.log(`🔗 API proxy to: ${apiTarget}`); + + // 启动HTTP服务器 + const server = Bun.serve({ + port, + hostname: HOST, + + async fetch(req) { + const url = new URL(req.url); + let pathname = url.pathname; + + // API代理:/api/v2/* 和 /health + if (pathname.startsWith("/api/v2") || pathname === "/health") { + return proxyRequest(req, apiTarget); + } + + // 根路径返回index.html + if (pathname === "/") { + pathname = "/index.html"; + } + + // 构建文件路径 + const filePath = join(distPath, pathname); + + // 检查文件是否存在 + const file = Bun.file(filePath); + const exists = await file.exists(); + + if (exists) { + return new Response(file, { + headers: { + "Content-Type": getMimeType(pathname), + "Cache-Control": + pathname === "/index.html" + ? "no-cache" + : "public, max-age=31536000", + }, + }); + } + + // 如果文件不存在,返回index.html(支持前端路由) + // 除非是明确的API路径或静态资源 + if (!pathname.startsWith("/api") && !pathname.includes(".")) { + const indexFile = Bun.file(join(distPath, "index.html")); + return new Response(indexFile, { + headers: { + "Content-Type": "text/html", + "Cache-Control": "no-cache", + }, + }); + } + + return new Response("Not Found", { status: 404 }); + }, + + error(error) { + console.error("❌ Server error:", error); + return new Response("Internal Server Error", { status: 500 }); + }, + }); + + const serverUrl = `http://${HOST}:${port}`; + console.log(`\n✅ Server running at: ${serverUrl}`); + console.log(`📁 Serving: Cortex Memory Insights UI`); + console.log(`🔗 Proxying: /api/v2/* → ${apiTarget}/api/v2/*`); + console.log(`🔗 Proxying: /health → ${apiTarget}/health`); + + // 自动打开浏览器 + if (!noBrowser) { + setTimeout(() => { + openBrowser(serverUrl); + }, 500); // 延迟500ms确保服务器完全启动 + } else { + console.log( + `📝 Browser auto-open disabled. Please visit ${serverUrl} manually.`, + ); + } + + console.log(`\n💡 Press Ctrl+C to stop the server\n`); + + // 优雅关闭 + const shutdown = () => { + console.log("\n👋 Shutting down server..."); + server.stop(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); } // 启动服务器 main().catch((error) => { - console.error('❌ Fatal error:', error); - process.exit(1); + console.error("❌ Fatal error:", error); + process.exit(1); }); diff --git a/litho.docs/en/3.Workflow.md b/litho.docs/en/3.Workflow.md index cfc0a10..0266bf1 100644 --- a/litho.docs/en/3.Workflow.md +++ b/litho.docs/en/3.Workflow.md @@ -189,9 +189,15 @@ final_score = (l0_score × 0.2) + (l1_score × 0.3) + (l2_score × 0.5) - **Factual Queries**: Pattern matching for fact-seeking (who, what, when) - **Temporal Queries**: Time-based constraints detected via regex +**Default Thresholds**: +- **Default Search Threshold**: 0.6 (increased from 0.5 for better precision) +- **Entity Query Threshold**: 0.4 (higher recall for specific lookups) +- **Minimum Threshold**: 0.4 (prevents returning overly irrelevant results) + **Degradation Strategies**: -1. **Progressive Threshold Reduction**: If L0 returns empty, reduce similarity threshold from 0.5 → 0.4 → 0.3 +1. **Progressive Threshold Reduction**: If L0 returns empty, reduce similarity threshold with floor at 0.4 (prevents returning overly broad results) 2. **Layer Bypass**: Fallback to full semantic search bypassing hierarchical architecture when layered retrieval fails +3. **Scope Filtering**: Application-level URI prefix filtering ensures scope isolation even if vector store filters are misconfigured --- @@ -247,14 +253,17 @@ flowchart TD AutomationConfig { auto_index: true, index_on_message: true, // ✅ Immediate indexing - index_on_close: false, // Handled by exit-time sync + index_on_close: true, // ✅ Session close triggers L0/L1 generation + indexing index_batch_delay: 1, + generate_layers_every_n_messages: 5, // ✅ Periodic L0/L1 generation } ``` -#### 2.3.4 Exit-time Layer Generation & Sync +#### 2.3.4 Exit-time and Session-Close Layer Generation & Sync -**Triggered by**: Application shutdown (`App::on_exit()`) +**Triggered by**: +1. Application shutdown (`App::on_exit()`) +2. Session close via `close_session()` MCP tool or HTTP API **Multi-stage Process**: @@ -264,7 +273,8 @@ AutomationConfig { - Saves to `cortex://user/{user_id}/` categorized directories **Stage 2: Layer File Generation** -- `LayerGenerator::ensure_all_layers()` scans all directories +- `LayerGenerator::ensure_timeline_layers()` for specific session, or +- `LayerGenerator::ensure_all_layers()` for all sessions - **Change Detection**: Compares file timestamps with existing `.abstract.md` - Only regenerates if: - `.abstract.md` or `.overview.md` is missing @@ -273,7 +283,8 @@ AutomationConfig { - **Token Savings**: Skips 90% of regeneration by timestamp tracking **Stage 3: Vector Sync** -- `SyncManager::sync_all()` scans entire filesystem +- `SyncManager::sync_specific_path()` for session-scoped sync, or +- `SyncManager::sync_all()` for full system sync - Indexes **all** markdown files (session, user, agent data) - Includes newly generated `.abstract.md` and `.overview.md` - **Deduplication**: Content hash checking prevents duplicate indexing @@ -300,6 +311,27 @@ pub async fn on_exit(&mut self) -> Result<()> { } ``` +**MCP Tool Integration**: +```rust +// New MCP tools for session lifecycle management +// cortex-mem-mcp/src/service.rs + +#[tool(description = "Generate L0/L1 layer files for memories")] +async fn generate_layers( + thread_id: Option, // Optional: specific session or all +) -> GenerateLayersResult; + +#[tool(description = "Index memories to vector database")] +async fn index_memories( + thread_id: Option, // Optional: specific session or all +) -> IndexMemoriesResult; + +#[tool(description = "Close session and trigger final processing")] +async fn close_session( + thread_id: String, +) -> CloseSessionResult; +``` + #### 2.3.5 Key Mechanisms **Change Detection Strategy**: diff --git a/litho.docs/en/4.Deep-Exploration/Automation Management Domain.md b/litho.docs/en/4.Deep-Exploration/Automation Management Domain.md index adcd81d..0fdd898 100644 --- a/litho.docs/en/4.Deep-Exploration/Automation Management Domain.md +++ b/litho.docs/en/4.Deep-Exploration/Automation Management Domain.md @@ -103,9 +103,13 @@ graph TB pub struct AutomationConfig { pub auto_index: bool, // Enable automatic indexing pub auto_extract: bool, // Enable memory extraction on session close + pub index_on_message: bool, // Index on message add (default: true) + pub index_on_close: bool, // Index on session close (default: true) pub poll_interval_secs: u64, // Filesystem polling frequency (default: 5) pub batch_delay_secs: u64, // Batching window for indexing (default: 2) pub sync_on_startup: bool, // Run full sync on system start + pub auto_generate_layers_on_startup: bool, // Generate L0/L1 on startup (default: false) + pub generate_layers_every_n_messages: usize, // Periodic L0/L1 generation (0 = disabled) } ``` @@ -141,16 +145,23 @@ loop { ### 3.3 Auto Indexer (`automation/indexer.rs`) -**Responsibility**: Converts filesystem-based conversation threads into vector-searchable embeddings in Qdrant. +**Responsibility**: Converts filesystem-based conversation threads into vector-searchable embeddings in Qdrant. Now supports indexing timeline L0/L1 layers for enhanced hierarchical search. **Processing Pipeline**: 1. **Receive Trigger**: Consumes `FsEvent` from the Automation Manager 2. **Batch Accumulation**: Collects events for `batch_delay_secs` to group related changes 3. **Content Retrieval**: Reads raw L2 content via `CortexFilesystem` 4. **Layer Generation**: Invokes `LayerManager` to generate L0 (abstract) and L1 (overview) summaries if not cached -5. **Vectorization**: Generates embeddings via `EmbeddingClient` -6. **Upsert Operation**: Stores vectors in Qdrant with tenant-aware collection naming (`cortex-mem-{tenant_id}`) -7. **ID Generation**: Uses deterministic vector IDs derived from URI + layer type (e.g., `session/123#l0`) +5. **Timeline Layer Indexing**: Indexes L0/L1 layers from timeline directories recursively +6. **Vectorization**: Generates embeddings via `EmbeddingClient` +7. **Upsert Operation**: Stores vectors in Qdrant with tenant-aware collection naming (`cortex-mem-{tenant_id}`) +8. **ID Generation**: Uses deterministic vector IDs derived from URI + layer type (e.g., `session/123#l0`) + +**Key Functions**: +- `AutoIndexer::index_thread(thread_id: &str)`: Indexes a specific conversation thread +- `AutoIndexer::index_batch(threads: Vec)`: Processes multiple threads atomically +- `AutoIndexer::index_timeline_layers(thread_id: &str)`: Indexes L0/L1 layers for timeline directories +- `AutoIndexer::regenerate_layers(thread_id: &str)`: Forces regeneration of L0/L1 summaries **Key Functions**: - `AutoIndexer::index_thread(thread_id: &str)`: Indexes a specific conversation thread @@ -301,6 +312,12 @@ auto_index = true # Enable automatic extraction when sessions close auto_extract = true +# Index immediately on message add (recommended for real-time search) +index_on_message = true + +# Generate L0/L1 and index on session close +index_on_close = true + # Filesystem polling interval in seconds poll_interval_secs = 5 @@ -310,6 +327,12 @@ batch_delay_secs = 2 # Perform full sync on startup sync_on_startup = false +# Generate missing L0/L1 layers on startup (can cause startup delay) +auto_generate_layers_on_startup = false + +# Generate L0/L1 every N messages (0 = disabled) +generate_layers_every_n_messages = 5 + # Maximum concurrent indexing operations max_concurrent_indexes = 10 diff --git a/litho.docs/en/4.Deep-Exploration/Layer Management Domain.md b/litho.docs/en/4.Deep-Exploration/Layer Management Domain.md index d4708ae..0bb57b2 100644 --- a/litho.docs/en/4.Deep-Exploration/Layer Management Domain.md +++ b/litho.docs/en/4.Deep-Exploration/Layer Management Domain.md @@ -57,6 +57,26 @@ pub async fn generate_batch(&self, uris: &[Uri]) -> Result Result<(), LayerError> ``` +### 3.2 Layer Generator (`/cortex-mem-core/src/automation/layer_generator.rs`) + +The **Layer Generator** handles L0/L1 generation for directories, supporting both full-system scans and session-scoped generation. + +**Key Methods:** +- `ensure_all_layers()`: Scans all directories and generates missing L0/L1 files +- `ensure_timeline_layers(timeline_uri: &str)`: Generates L0/L1 for a specific timeline directory (used on session close) +- `should_regenerate(uri: &str)`: Checks if regeneration is needed based on timestamps + +**Change Detection Strategy:** +1. Check if `.abstract.md` or `.overview.md` exists +2. Extract "Added" timestamp from existing layer files +3. Compare with source file modification times +4. Only regenerate if source files are newer than layer files + +**Performance Optimization:** +- Skips 90% of regeneration by timestamp-based change detection +- Batch processing with configurable delays +- Content truncation to prevent LLM context overflow + ### 3.2 Summary Generators (`/cortex-mem-core/src/layers/generator.rs`) The generator subcomponents handle LLM-powered content transformation: diff --git a/litho.docs/en/5.Boundary-Interfaces.md b/litho.docs/en/5.Boundary-Interfaces.md index 47aca04..2e59357 100644 --- a/litho.docs/en/5.Boundary-Interfaces.md +++ b/litho.docs/en/5.Boundary-Interfaces.md @@ -300,6 +300,114 @@ cortex-mem-cli --config config.toml search "user preferences" --scope user --lim **Response Format**: {"current_tenant": "string", "success": "boolean"} +## MCP Tools + +### store_memory + +**Description**: Store a memory message in a session thread. Automatically creates session if not exists. + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `content` (string): required - Memory content to store +- `thread_id` (string): optional - Thread/session ID (defaults to "default") +- `role` (string): optional - Role (user/assistant/system, defaults to "user") + +**Response Format**: {"success": "boolean", "uri": "string", "message_id": "string"} + +### query_memory + +**Description**: Search memories using semantic vector search with L0/L1/L2 layered retrieval. + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `query` (string): required - Natural language query +- `thread_id` (string): optional - Scope to specific thread +- `limit` (integer): optional - Max results (default: 10) +- `min_score` (float): optional - Minimum similarity score (default: 0.6) + +**Response Format**: {"results": [{"id": "string", "content": "string", "score": "float", "uri": "string"}]} + +### list_memories + +**Description**: List memories in a thread with pagination support. + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `thread_id` (string): optional - Thread ID to list +- `limit` (integer): optional - Max results (default: 50) +- `offset` (integer): optional - Pagination offset + +**Response Format**: {"memories": [{"id": "string", "uri": "string", "timestamp": "string"}], "total": "integer"} + +### get_memory + +**Description**: Retrieve a specific memory by URI or ID. + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `uri` (string): required - Memory URI or ID + +**Response Format**: {"id": "string", "content": "string", "uri": "string", "metadata": "object"} + +### delete_memory + +**Description**: Delete a memory and its associated vectors from all layers (L0/L1/L2). + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `uri` (string): required - Memory URI to delete + +**Response Format**: {"success": "boolean", "message": "string"} + +### get_abstract + +**Description**: Get the L0 abstract summary of a memory. + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `uri` (string): required - Memory URI + +**Response Format**: {"uri": "string", "abstract_text": "string"} + +### generate_layers + +**Description**: Generate L0/L1 layer files for memories. Supports session-scoped or full generation. + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `thread_id` (string): optional - Thread ID for session-scoped generation (if omitted, generates for all) + +**Response Format**: {"success": "boolean", "message": "string", "total": "integer", "generated": "integer", "failed": "integer"} + +### index_memories + +**Description**: Index memories to vector database. Supports session-scoped or full indexing. + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `thread_id` (string): optional - Thread ID for session-scoped indexing (if omitted, indexes all) + +**Response Format**: {"success": "boolean", "message": "string", "total_files": "integer", "indexed_files": "integer", "skipped_files": "integer", "error_files": "integer"} + +### close_session + +**Description**: Close a session and trigger final processing (L0/L1 generation, memory extraction, indexing). + +**Source File**: `cortex-mem-mcp/src/service.rs` + +**Parameters**: +- `thread_id` (string): required - Thread/session ID to close + +**Response Format**: {"success": "boolean", "thread_id": "string", "message": "string"} + ## Router Routes ### /filesystem/list diff --git "a/litho.docs/zh/3\343\200\201\346\240\270\345\277\203\346\265\201\347\250\213.md" "b/litho.docs/zh/3\343\200\201\346\240\270\345\277\203\346\265\201\347\250\213.md" index afa760d..8bb1dd5 100644 --- "a/litho.docs/zh/3\343\200\201\346\240\270\345\277\203\346\265\201\347\250\213.md" +++ "b/litho.docs/zh/3\343\200\201\346\240\270\345\277\203\346\265\201\347\250\213.md" @@ -189,9 +189,15 @@ final_score = (l0_score × 0.2) + (l1_score × 0.3) + (l2_score × 0.5) - **事实查询**: 事实寻求的模式匹配(谁、什么、何时) - **时间查询**: 通过正则表达式检测的基于时间的约束 +**默认阈值**: +- **默认搜索阈值**: 0.6(从0.5提升以提高精确度) +- **实体查询阈值**: 0.4(特定查找更高召回率) +- **最小阈值**: 0.4(防止返回过度不相关的结果) + **降级策略**: -1. **渐进阈值降低**: 如果L0返回空,将相似度阈值从0.5 → 0.4 → 0.3降低 +1. **渐进阈值降低**: 如果L0返回空,降低相似度阈值,但最低不低于0.4(防止返回过度宽泛的结果) 2. **层级旁路**: 当分层检索失败时,回退到绕过层级架构的完整语义搜索 +3. **作用域过滤**: 应用层URI前缀过滤确保作用域隔离,即使向量存储过滤器配置错误 --- @@ -247,14 +253,17 @@ flowchart TD AutomationConfig { auto_index: true, index_on_message: true, // ✅ 立即索引 - index_on_close: false, // 由退出时同步处理 + index_on_close: true, // ✅ 会话关闭时生成L0/L1并索引 index_batch_delay: 1, + generate_layers_every_n_messages: 5, // ✅ 定期L0/L1生成 } ``` -#### 2.3.4 退出时层级生成与同步 +#### 2.3.4 退出时与会话关闭时的层级生成与同步 -**触发条件**: 应用关闭(`App::on_exit()`) +**触发条件**: +1. 应用关闭(`App::on_exit()`) +2. 通过 `close_session()` MCP工具或HTTP API关闭会话 **多阶段处理**: @@ -264,7 +273,8 @@ AutomationConfig { - 保存到 `cortex://user/{user_id}/` 分类目录 **阶段2:层级文件生成** -- `LayerGenerator::ensure_all_layers()` 扫描所有目录 +- `LayerGenerator::ensure_timeline_layers()` 用于特定会话,或 +- `LayerGenerator::ensure_all_layers()` 用于所有会话 - **变更检测**: 比较文件时间戳与现有 `.abstract.md` - 仅在以下情况重新生成: - `.abstract.md` 或 `.overview.md` 缺失 @@ -273,7 +283,8 @@ AutomationConfig { - **Token 节省**: 通过时间戳跟踪跳过 90% 的重新生成 **阶段3:向量同步** -- `SyncManager::sync_all()` 扫描整个文件系统 +- `SyncManager::sync_specific_path()` 用于会话作用域同步,或 +- `SyncManager::sync_all()` 用于全系统同步 - 索引**所有** markdown 文件(会话、用户、智能体数据) - 包括新生成的 `.abstract.md` 和 `.overview.md` - **去重**: 内容哈希检查防止重复索引 @@ -300,6 +311,27 @@ pub async fn on_exit(&mut self) -> Result<()> { } ``` +**MCP工具集成**: +```rust +// 新增MCP工具用于会话生命周期管理 +// cortex-mem-mcp/src/service.rs + +#[tool(description = "为记忆生成L0/L1层级文件")] +async fn generate_layers( + thread_id: Option, // 可选:特定会话或全部 +) -> GenerateLayersResult; + +#[tool(description = "将记忆索引到向量数据库")] +async fn index_memories( + thread_id: Option, // 可选:特定会话或全部 +) -> IndexMemoriesResult; + +#[tool(description = "关闭会话并触发最终处理")] +async fn close_session( + thread_id: String, +) -> CloseSessionResult; +``` + #### 2.3.5 关键机制 **变更检测策略**: diff --git "a/litho.docs/zh/4\343\200\201\346\267\261\345\205\245\346\216\242\347\264\242/\345\261\202\347\272\247\347\256\241\347\220\206\351\242\206\345\237\237.md" "b/litho.docs/zh/4\343\200\201\346\267\261\345\205\245\346\216\242\347\264\242/\345\261\202\347\272\247\347\256\241\347\220\206\351\242\206\345\237\237.md" index d1a11d0..964eb57 100644 --- "a/litho.docs/zh/4\343\200\201\346\267\261\345\205\245\346\216\242\347\264\242/\345\261\202\347\272\247\347\256\241\347\220\206\351\242\206\345\237\237.md" +++ "b/litho.docs/zh/4\343\200\201\346\267\261\345\205\245\346\216\242\347\264\242/\345\261\202\347\272\247\347\256\241\347\220\206\351\242\206\345\237\237.md" @@ -57,6 +57,22 @@ pub async fn generate_batch(&self, uris: &[Uri]) -> Result Result<(), LayerError> ``` +### 3.3 层级生成器 (`/cortex-mem-core/src/automation/layer_generator.rs`) + +**层级生成器**负责扫描文件系统、检测缺失的L0/L1文件,并批量生成层级摘要。 + +**关键功能**: +- `LayerGenerator::scan_all_directories()`: 扫描所有维度目录 +- `LayerGenerator::ensure_all_layers()`: 确保所有目录拥有L0/L1文件 +- `LayerGenerator::ensure_timeline_layers(timeline_uri)`: 为特定时间线生成层级文件 +- `LayerGenerator::should_regenerate(uri)`: 基于时间戳的变更检测,避免重复生成 + +**生成策略**: +1. 扫描四个核心维度:session、user、agent、resources +2. 过滤出缺失 `.abstract.md` 或 `.overview.md` 的目录 +3. 基于时间戳检测是否需要重新生成(源文件更新时才生成) +4. 批量处理,支持配置批处理大小和延迟 + ### 3.2 摘要生成器 (`/cortex-mem-core/src/layers/generator.rs`) 生成器子组件处理LLM动力的内容转换: @@ -179,6 +195,15 @@ pub async fn generate_timeline_layers(&self, timeline_uri: &Uri) -> Result<(), L ``` 每个租户的层级在隔离路径中生成和存储,向量存储使用租户后缀集合(`cortex-mem-{tenant_id}`)。 +### 场景C: 会话退出时层级生成 +当会话关闭时,系统可以触发特定时间线的层级文件生成: +```rust +// 在会话关闭时调用 +let timeline_uri = format!("cortex://session/{}/timeline", thread_id); +layer_generator.ensure_timeline_layers(&timeline_uri).await?; +``` +这确保会话结束时所有对话节点都有对应的L0/L1层级文件,优化后续搜索性能。 + --- ## 8. 总结 diff --git "a/litho.docs/zh/4\343\200\201\346\267\261\345\205\245\346\216\242\347\264\242/\350\207\252\345\212\250\345\214\226\347\256\241\347\220\206\351\242\206\345\237\237.md" "b/litho.docs/zh/4\343\200\201\346\267\261\345\205\245\346\216\242\347\264\242/\350\207\252\345\212\250\345\214\226\347\256\241\347\220\206\351\242\206\345\237\237.md" index aa1f34f..fad5323 100644 --- "a/litho.docs/zh/4\343\200\201\346\267\261\345\205\245\346\216\242\347\264\242/\350\207\252\345\212\250\345\214\226\347\256\241\347\220\206\351\242\206\345\237\237.md" +++ "b/litho.docs/zh/4\343\200\201\346\267\261\345\205\245\346\216\242\347\264\242/\350\207\252\345\212\250\345\214\226\347\256\241\347\220\206\351\242\206\345\237\237.md" @@ -104,9 +104,13 @@ graph TB pub struct AutomationConfig { pub auto_index: bool, // 启用自动索引 pub auto_extract: bool, // 启用会话关闭时记忆提取 + pub index_on_message: bool, // 消息添加时索引(默认:true) + pub index_on_close: bool, // 会话关闭时索引(默认:true) pub poll_interval_secs: u64, // 文件系统轮询频率(默认:5) pub batch_delay_secs: u64, // 索引批量窗口(默认:2) pub sync_on_startup: bool, // 系统启动时运行完整同步 + pub auto_generate_layers_on_startup: bool, // 启动时生成L0/L1(默认:false) + pub generate_layers_every_n_messages: usize, // 定期L0/L1生成(0 = 禁用) } ``` @@ -157,6 +161,13 @@ loop { - `AutoIndexer::index_thread(thread_id: &str)`: 索引特定对话线程 - `AutoIndexer::index_batch(threads: Vec)`: 原子处理多个线程 - `AutoIndexer::regenerate_layers(thread_id: &str)`: 强制重新生成L0/L1摘要 +- `AutoIndexer::index_timeline_layers(thread_id: &str)`: 索引会话时间线的L0/L1层级文件 + +**时间线层级索引**: +自动索引器还支持索引会话时间线目录中的L0/L1层级文件: +- 扫描 `cortex://session/{thread_id}/timeline` 目录结构 +- 为每个包含 `.abstract.md` 和 `.overview.md` 的目录生成向量索引 +- 使用目录URI而非文件URI作为向量ID,确保层级文件正确关联 **性能考虑**: - 为LLM API速率限制实现指数退避 diff --git "a/litho.docs/zh/5\343\200\201\347\263\273\347\273\237\350\276\271\347\225\214.md" "b/litho.docs/zh/5\343\200\201\347\263\273\347\273\237\350\276\271\347\225\214.md" index 5746a77..4f23a41 100644 --- "a/litho.docs/zh/5\343\200\201\347\263\273\347\273\237\350\276\271\347\225\214.md" +++ "b/litho.docs/zh/5\343\200\201\347\263\273\347\273\237\350\276\271\347\225\214.md" @@ -300,6 +300,114 @@ cortex-mem-cli --config config.toml search "user preferences" --scope user --lim **响应格式**: {"current_tenant": "string", "success": "boolean"} +## MCP工具 + +### store_memory + +**描述**: 在会话线程中存储记忆消息。如果会话不存在则自动创建。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `content` (字符串): 必需 - 要存储的记忆内容 +- `thread_id` (字符串): 可选 - 线程/会话ID(默认为"default") +- `role` (字符串): 可选 - 角色(user/assistant/system,默认为"user") + +**响应格式**: {"success": "boolean", "uri": "string", "message_id": "string"} + +### query_memory + +**描述**: 使用语义向量搜索和L0/L1/L2分层检索搜索记忆。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `query` (字符串): 必需 - 自然语言查询 +- `thread_id` (字符串): 可选 - 限定到特定线程 +- `limit` (整数): 可选 - 最大结果数(默认: 10) +- `min_score` (浮点数): 可选 - 最小相似度分数(默认: 0.6) + +**响应格式**: {"results": [{"id": "string", "content": "string", "score": "float", "uri": "string"}]} + +### list_memories + +**描述**: 列出线程中的记忆,支持分页。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `thread_id` (字符串): 可选 - 要列出的线程ID +- `limit` (整数): 可选 - 最大结果数(默认: 50) +- `offset` (整数): 可选 - 分页偏移量 + +**响应格式**: {"memories": [{"id": "string", "uri": "string", "timestamp": "string"}], "total": "integer"} + +### get_memory + +**描述**: 通过URI或ID检索特定记忆。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `uri` (字符串): 必需 - 记忆URI或ID + +**响应格式**: {"id": "string", "content": "string", "uri": "string", "metadata": "object"} + +### delete_memory + +**描述**: 删除记忆及其关联的所有层向量(L0/L1/L2)。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `uri` (字符串): 必需 - 要删除的记忆URI + +**响应格式**: {"success": "boolean", "message": "string"} + +### get_abstract + +**描述**: 获取记忆的L0抽象摘要。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `uri` (字符串): 必需 - 记忆URI + +**响应格式**: {"uri": "string", "abstract_text": "string"} + +### generate_layers + +**描述**: 为记忆生成L0/L1层级文件。支持会话作用域或全量生成。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `thread_id` (字符串): 可选 - 会话作用域生成的线程ID(如果省略则为全部生成) + +**响应格式**: {"success": "boolean", "message": "string", "total": "integer", "generated": "integer", "failed": "integer"} + +### index_memories + +**描述**: 将记忆索引到向量数据库。支持会话作用域或全量索引。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `thread_id` (字符串): 可选 - 会话作用域索引的线程ID(如果省略则索引全部) + +**响应格式**: {"success": "boolean", "message": "string", "total_files": "integer", "indexed_files": "integer", "skipped_files": "integer", "error_files": "integer"} + +### close_session + +**描述**: 关闭会话并触发最终处理(L0/L1生成、记忆提取、索引)。 + +**源文件**: `cortex-mem-mcp/src/service.rs` + +**参数**: +- `thread_id` (字符串): 必需 - 要关闭的线程/会话ID + +**响应格式**: {"success": "boolean", "thread_id": "string", "message": "string"} + ## 路由器路由 ### /filesystem/list From 0f79335628cc507ec658113b29128b8f3cda61a4 Mon Sep 17 00:00:00 2001 From: Sopaco Date: Fri, 27 Feb 2026 10:15:03 +0800 Subject: [PATCH 3/6] Fix browser opening on Windows Update compile:prod script to remove BUILD_TIME definition and pre-run build step. Modify openBrowser function to use proper Windows cmd /c start syntax instead of direct start command. Reorganize the function to call spawn directly within platform-specific blocks. --- cortex-mem-insights/package.json | 2 +- cortex-mem-insights/server.ts | 33 ++++++++++++++++++-------------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/cortex-mem-insights/package.json b/cortex-mem-insights/package.json index 50b77c9..d9a9a95 100644 --- a/cortex-mem-insights/package.json +++ b/cortex-mem-insights/package.json @@ -9,7 +9,7 @@ "preview": "vite preview", "serve": "bun server.ts", "compile": "bun build --compile --minify --bytecode --sourcemap ./server.ts --outfile ./dist/cortex-mem-insights", - "compile:prod": "bun build --compile --minify --bytecode --sourcemap --define VERSION='\"2.0.0\"' --define BUILD_TIME='\"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'\"' ./server.ts --outfile ./dist/cortex-mem-insights", + "compile:prod": "bun run build && bun build --compile --minify --bytecode --sourcemap --define VERSION='\"2.0.0\"' ./server.ts --outfile ./dist/cortex-mem-insights", "compile:mac": "bun build --compile --minify --bytecode --target=bun-darwin-arm64 --define VERSION='\"2.0.0\"' ./server.ts --outfile ./dist/cortex-mem-insights-mac-arm64", "compile:mac-x64": "bun build --compile --minify --bytecode --target=bun-darwin-x64 --define VERSION='\"2.0.0\"' ./server.ts --outfile ./dist/cortex-mem-insights-mac-x64", "compile:linux": "bun build --compile --minify --bytecode --target=bun-linux-x64 --define VERSION='\"2.0.0\"' ./server.ts --outfile ./dist/cortex-mem-insights-linux", diff --git a/cortex-mem-insights/server.ts b/cortex-mem-insights/server.ts index 36eaaed..a28306a 100644 --- a/cortex-mem-insights/server.ts +++ b/cortex-mem-insights/server.ts @@ -33,22 +33,27 @@ const buildTime = */ function openBrowser(url: string): void { const platform = process.platform; - let command: string; - - if (platform === "darwin") { - command = "open"; - } else if (platform === "win32") { - command = "start"; - } else { - // Linux and others - command = "xdg-open"; - } try { - spawn(command, [url], { - detached: true, - stdio: "ignore", - }).unref(); + if (platform === "darwin") { + // macOS + spawn("open", [url], { + detached: true, + stdio: "ignore", + }).unref(); + } else if (platform === "win32") { + // Windows - 使用cmd /c start命令 + spawn("cmd", ["/c", "start", "", url], { + detached: true, + stdio: "ignore", + }).unref(); + } else { + // Linux and others + spawn("xdg-open", [url], { + detached: true, + stdio: "ignore", + }).unref(); + } console.log(`🌐 Opening browser at ${url}...`); } catch (error) { console.warn(`⚠️ Could not open browser automatically: ${error}`); From 5a9a341e41db65d4b47a3dd1b2393ea784587b73 Mon Sep 17 00:00:00 2001 From: Sopaco Date: Fri, 27 Feb 2026 14:20:47 +0800 Subject: [PATCH 4/6] Prioritize breadth in L0 abstract generation Relax the single-sentence constraint and update system and prompt text to request concise (~100 tokens) summaries that cover multiple key aspects, favor breadth over depth, and use compact phrasing. --- cortex-mem-core/src/layers/generator.rs | 8 +++++--- cortex-mem-core/src/llm/prompts.rs | 11 ++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cortex-mem-core/src/layers/generator.rs b/cortex-mem-core/src/layers/generator.rs index 0ef599b..21bbc21 100644 --- a/cortex-mem-core/src/layers/generator.rs +++ b/cortex-mem-core/src/layers/generator.rs @@ -4,7 +4,8 @@ use std::sync::Arc; /// Abstract (L0) generator /// -/// Generates a 1-2 sentence summary (~100 tokens) from content using LLM +/// Generates a concise summary (~100 tokens) from content using LLM +/// for quick relevance checking and filtering pub struct AbstractGenerator; impl AbstractGenerator { @@ -15,8 +16,9 @@ impl AbstractGenerator { /// Generate abstract from content using LLM (mandatory) pub async fn generate_with_llm(&self, content: &str, llm: &Arc) -> Result { let system = r#"You are an expert at creating concise abstracts. -Your goal is to generate single-sentence summaries that capture the core essence of content for quick relevance checking. -Keep abstracts under 100 tokens. Be direct and informative."#; +Your goal is to generate summaries that capture multiple key aspects of content for quick relevance checking. +Keep abstracts under 100 tokens. Prioritize breadth over depth - cover more topics briefly rather than elaborating on one. +Be direct and informative. Use compact phrasing to maximize information density."#; let prompt = crate::llm::prompts::Prompts::abstract_generation(content); diff --git a/cortex-mem-core/src/llm/prompts.rs b/cortex-mem-core/src/llm/prompts.rs index 2e22425..6caaf43 100644 --- a/cortex-mem-core/src/llm/prompts.rs +++ b/cortex-mem-core/src/llm/prompts.rs @@ -4,16 +4,17 @@ pub struct Prompts; impl Prompts { /// Prompt for generating L0 abstract /// - /// Based on OpenViking design: ~100 tokens, single-sentence summary - /// for quick relevance checking and filtering + /// Based on OpenViking design: ~100 tokens for quick relevance checking and filtering pub fn abstract_generation(content: &str) -> String { format!( r#"Generate a concise abstract (~100 tokens maximum) for the following content. Requirements: -- Single sentence or 2-3 short sentences maximum -- Capture the CORE ESSENCE: who, what, when (if applicable) -- Focus on the most important information for quick relevance checking +- Stay within ~100 tokens limit +- Cover MULTIPLE key aspects when content is rich (who, what, key topics, important outcomes) +- Prioritize information breadth over depth - mention more topics rather than elaborating on one +- Use compact phrasing: "discussed X, Y, and Z" instead of long explanations +- For multi-topic content: list key themes briefly rather than focusing on just one - Use clear, direct language - Avoid filler words and unnecessary details - **CRITICAL: Use the SAME LANGUAGE as the input content** From 6728099d75e7b487532a5730c3943f3962635ce9 Mon Sep 17 00:00:00 2001 From: Sopaco Date: Fri, 27 Feb 2026 15:50:07 +0800 Subject: [PATCH 5/6] Use Unicode-aware truncation for content limits --- .../src/automation/layer_generator.rs | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/cortex-mem-core/src/automation/layer_generator.rs b/cortex-mem-core/src/automation/layer_generator.rs index ba6a941..59bced9 100644 --- a/cortex-mem-core/src/automation/layer_generator.rs +++ b/cortex-mem-core/src/automation/layer_generator.rs @@ -452,9 +452,11 @@ impl LayerGenerator { // 截断到合理长度(避免超出 LLM 上下文限制) let max_chars = 10000; - if content.len() > max_chars { - content.truncate(max_chars); + if content.chars().count() > max_chars { + let truncated: String = content.chars().take(max_chars).collect(); + let mut content = truncated; content.push_str("\n\n[内容已截断...]"); + return Ok(content); } Ok(content) @@ -465,17 +467,30 @@ impl LayerGenerator { let mut result = text.trim().to_string(); let max_chars = self.config.abstract_config.max_chars; - if result.len() <= max_chars { + if result.chars().count() <= max_chars { return Ok(result); } + // 找到 max_chars 字符对应的字节位置 + let byte_limit = result + .char_indices() + .nth(max_chars) + .map(|(i, _)| i) + .unwrap_or(result.len()); + // 截断到最后一个句号/问号/叹号 - if let Some(pos) = result[..max_chars] + if let Some(pos) = result[..byte_limit] .rfind(|c| c == '。' || c == '.' || c == '?' || c == '!' || c == '!' || c == '?') { result.truncate(pos + 1); } else { - result.truncate(max_chars - 3); + // 找到 max_chars - 3 字符对应的字节位置 + let truncate_pos = result + .char_indices() + .nth(max_chars.saturating_sub(3)) + .map(|(i, _)| i) + .unwrap_or(result.len()); + result.truncate(truncate_pos); result.push_str("..."); } @@ -487,16 +502,29 @@ impl LayerGenerator { let mut result = text.trim().to_string(); let max_chars = self.config.overview_config.max_chars; - if result.len() <= max_chars { + if result.chars().count() <= max_chars { return Ok(result); } + // 找到 max_chars 字符对应的字节位置 + let byte_limit = result + .char_indices() + .nth(max_chars) + .map(|(i, _)| i) + .unwrap_or(result.len()); + // 截断到最后一个段落 - if let Some(pos) = result[..max_chars].rfind("\n\n") { + if let Some(pos) = result[..byte_limit].rfind("\n\n") { result.truncate(pos); result.push_str("\n\n[内容已截断...]"); } else { - result.truncate(max_chars - 3); + // 找到 max_chars - 3 字符对应的字节位置 + let truncate_pos = result + .char_indices() + .nth(max_chars.saturating_sub(3)) + .map(|(i, _)| i) + .unwrap_or(result.len()); + result.truncate(truncate_pos); result.push_str("..."); } From 293df8b7098b181a73798bb25f0ec810d8f37dcb Mon Sep 17 00:00:00 2001 From: Sopaco Date: Fri, 27 Feb 2026 17:19:10 +0800 Subject: [PATCH 6/6] Add tenant commands, validation, and layer fixes - Add tenant subcommand, tenant module and tenant listing CLI - Validate search parameters (min_score range, non-zero limit) - Update add command to use message URI returned by operations - Translate layers output to English and improve messages - Modify LayerManager to handle directory URIs, avoid generating abstracts for directories, and normalize layer file paths - Add test_cli.sh for automated CLI tests --- cortex-mem-cli/src/commands/add.rs | 10 +- cortex-mem-cli/src/commands/layers.rs | 62 ++++---- cortex-mem-cli/src/commands/mod.rs | 1 + cortex-mem-cli/src/commands/search.rs | 13 ++ cortex-mem-cli/src/commands/tenant.rs | 51 +++++++ cortex-mem-cli/src/main.rs | 36 ++++- cortex-mem-cli/test_cli.sh | 208 ++++++++++++++++++++++++++ cortex-mem-core/src/layers/manager.rs | 34 ++++- 8 files changed, 370 insertions(+), 45 deletions(-) create mode 100644 cortex-mem-cli/src/commands/tenant.rs create mode 100755 cortex-mem-cli/test_cli.sh diff --git a/cortex-mem-cli/src/commands/add.rs b/cortex-mem-cli/src/commands/add.rs index ed9bad5..726c28c 100644 --- a/cortex-mem-cli/src/commands/add.rs +++ b/cortex-mem-cli/src/commands/add.rs @@ -12,15 +12,13 @@ pub async fn execute( println!("{} Adding message to session: {}", "📝".bold(), thread.cyan()); // Add message using MemoryOperations - let message_id = operations.add_message(thread, role, content).await?; + // Note: add_message returns the full URI of the message file + let message_uri = operations.add_message(thread, role, content).await?; println!("{} Message added successfully", "✓".green().bold()); println!(" {}: {}", "Thread".cyan(), thread); println!(" {}: {}", "Role".cyan(), role); - println!(" {}: {}", "ID".cyan(), message_id); - - let uri = format!("cortex://session/{}/timeline/{}.md", thread, message_id); - println!(" {}: {}", "URI".cyan(), uri.bright_blue()); + println!(" {}: {}", "URI".cyan(), message_uri.bright_blue()); Ok(()) -} \ No newline at end of file +} diff --git a/cortex-mem-cli/src/commands/layers.rs b/cortex-mem-cli/src/commands/layers.rs index ae8c6dc..50aa69b 100644 --- a/cortex-mem-cli/src/commands/layers.rs +++ b/cortex-mem-cli/src/commands/layers.rs @@ -3,11 +3,11 @@ use cortex_mem_core::automation::{LayerGenerator, LayerGenerationConfig}; use cortex_mem_tools::MemoryOperations; use std::sync::Arc; -/// 确保所有目录拥有 L0/L1 文件 +/// Ensure all directories have L0/L1 files pub async fn ensure_all(operations: Arc) -> Result<()> { - println!("🔍 扫描文件系统,检查缺失的 .abstract.md 和 .overview.md 文件...\n"); + println!("🔍 Scanning filesystem for missing .abstract.md and .overview.md files...\n"); - // 从 session_manager 中获取 LLM client + // Get LLM client from session_manager let llm_client = { let sm = operations.session_manager().read().await; sm.llm_client() @@ -15,7 +15,7 @@ pub async fn ensure_all(operations: Arc) -> Result<()> { .clone() }; - // 创建 LayerGenerator + // Create LayerGenerator let config = LayerGenerationConfig::default(); let generator = LayerGenerator::new( operations.filesystem().clone(), @@ -23,28 +23,28 @@ pub async fn ensure_all(operations: Arc) -> Result<()> { config, ); - // 执行扫描和生成 + // Execute scan and generation let stats = generator.ensure_all_layers().await?; - // 显示结果 - println!("\n✅ 生成完成!"); + // Display results + println!("\n✅ Generation complete!"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("📊 统计信息:"); - println!(" • 总计发现缺失: {} 个目录", stats.total); - println!(" • 成功生成: {} 个", stats.generated); - println!(" • 失败: {} 个", stats.failed); + println!("📊 Statistics:"); + println!(" • Total missing: {} directories", stats.total); + println!(" • Generated: {}", stats.generated); + println!(" • Failed: {}", stats.failed); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); if stats.failed > 0 { - println!("\n⚠️ 部分目录生成失败,请检查日志获取详细信息"); + println!("\n⚠️ Some directories failed to generate. Check logs for details."); } Ok(()) } -/// 显示层级文件状态 +/// Display layer file status pub async fn status(operations: Arc) -> Result<()> { - println!("📊 层级文件状态检查\n"); + println!("📊 Layer file status check\n"); let llm_client = { let sm = operations.session_manager().read().await; @@ -60,11 +60,11 @@ pub async fn status(operations: Arc) -> Result<()> { config, ); - // 扫描所有目录 + // Scan all directories let directories = generator.scan_all_directories().await?; - println!("🗂️ 总计目录数: {}\n", directories.len()); + println!("🗂️ Total directories: {}\n", directories.len()); - // 检测缺失的目录 + // Detect missing directories let missing = generator.filter_missing_layers(&directories).await?; let complete = directories.len() - missing.len(); @@ -75,33 +75,33 @@ pub async fn status(operations: Arc) -> Result<()> { }; println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("✅ 完整 (有 L0/L1): {} ({:.0}%)", complete, complete_percent); - println!("❌ 缺失 (无 L0/L1): {} ({:.0}%)", missing.len(), 100 - complete_percent); + println!("✅ Complete (has L0/L1): {} ({:.0}%)", complete, complete_percent); + println!("❌ Missing (no L0/L1): {} ({:.0}%)", missing.len(), 100 - complete_percent); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); if missing.len() > 0 { - println!("\n💡 提示: 运行 `cortex-mem-cli layers ensure-all` 来生成缺失的文件"); + println!("\n💡 Tip: Run `cortex-mem layers ensure-all` to generate missing files"); if missing.len() <= 10 { - println!("\n缺失的目录:"); + println!("\nMissing directories:"); for dir in &missing { println!(" • {}", dir); } } else { - println!("\n缺失的目录 (显示前 10 个):"); + println!("\nMissing directories (showing first 10):"); for dir in missing.iter().take(10) { println!(" • {}", dir); } - println!(" ... 还有 {} 个", missing.len() - 10); + println!(" ... and {} more", missing.len() - 10); } } Ok(()) } -/// 重新生成超大的 .abstract 文件 +/// Regenerate oversized .abstract files pub async fn regenerate_oversized(operations: Arc) -> Result<()> { - println!("🔍 扫描超大的 .abstract.md 文件...\n"); + println!("🔍 Scanning for oversized .abstract.md files...\n"); let llm_client = { let sm = operations.session_manager().read().await; @@ -119,16 +119,16 @@ pub async fn regenerate_oversized(operations: Arc) -> Result<( let stats = generator.regenerate_oversized_abstracts().await?; - println!("\n✅ 重新生成完成!"); + println!("\n✅ Regeneration complete!"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("📊 统计信息:"); - println!(" • 发现超大文件: {} 个", stats.total); - println!(" • 成功重新生成: {} 个", stats.regenerated); - println!(" • 失败: {} 个", stats.failed); + println!("📊 Statistics:"); + println!(" • Oversized files found: {}", stats.total); + println!(" • Successfully regenerated: {}", stats.regenerated); + println!(" • Failed: {}", stats.failed); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); if stats.total == 0 { - println!("\n✨ 所有 .abstract 文件大小都在限制范围内!"); + println!("\n✨ All .abstract files are within size limits!"); } Ok(()) diff --git a/cortex-mem-cli/src/commands/mod.rs b/cortex-mem-cli/src/commands/mod.rs index 7e66fea..7878d1e 100644 --- a/cortex-mem-cli/src/commands/mod.rs +++ b/cortex-mem-cli/src/commands/mod.rs @@ -6,3 +6,4 @@ pub mod list; pub mod search; pub mod session; pub mod stats; +pub mod tenant; diff --git a/cortex-mem-cli/src/commands/search.rs b/cortex-mem-cli/src/commands/search.rs index 235b3bc..9f1ce18 100644 --- a/cortex-mem-cli/src/commands/search.rs +++ b/cortex-mem-cli/src/commands/search.rs @@ -12,6 +12,19 @@ pub async fn execute( min_score: f32, scope: &str, ) -> Result<()> { + // Validate min_score parameter + if min_score < 0.0 || min_score > 1.0 { + return Err(anyhow::anyhow!( + "min_score must be between 0.0 and 1.0, got {:.2}", + min_score + )); + } + + // Validate limit parameter + if limit == 0 { + return Err(anyhow::anyhow!("limit must be greater than 0")); + } + println!("{} Searching for: {}", "🔍".bold(), query.yellow()); // Build search scope URI diff --git a/cortex-mem-cli/src/commands/tenant.rs b/cortex-mem-cli/src/commands/tenant.rs new file mode 100644 index 0000000..e5972ea --- /dev/null +++ b/cortex-mem-cli/src/commands/tenant.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use colored::Colorize; +use std::path::Path; + +/// List all available tenants +pub async fn list(data_dir: &str) -> Result<()> { + println!("{} Listing all available tenants", "📋".bold()); + + let tenants_dir = Path::new(data_dir).join("tenants"); + + if !tenants_dir.exists() { + println!("\n{} No tenants directory found at {}", "ℹ".yellow().bold(), tenants_dir.display()); + return Ok(()); + } + + let mut tenants = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&tenants_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + if let Some(name) = entry.file_name().to_str() { + // Skip hidden directories + if !name.starts_with('.') { + tenants.push(name.to_string()); + } + } + } + } + } + + if tenants.is_empty() { + println!("\n{} No tenants found", "ℹ".yellow().bold()); + println!("\n Data directory: {}", tenants_dir.display().to_string().dimmed()); + return Ok(()); + } + + // Sort tenants alphabetically + tenants.sort(); + + println!("\n{} Found {} tenant(s):", "✓".green().bold(), tenants.len()); + println!(); + + for tenant in tenants { + println!("• {}", tenant.bright_blue().bold()); + } + + println!("\n {} Use --tenant to specify which tenant to operate on", "💡".dimmed()); + println!(" {} Data directory: {}", "📁".dimmed(), tenants_dir.display().to_string().dimmed()); + + Ok(()) +} diff --git a/cortex-mem-cli/src/main.rs b/cortex-mem-cli/src/main.rs index 0b107b5..b28b889 100644 --- a/cortex-mem-cli/src/main.rs +++ b/cortex-mem-cli/src/main.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use std::sync::Arc; mod commands; -use commands::{add, delete, get, layers, list, search, session, stats}; +use commands::{add, delete, get, layers, list, search, session, stats, tenant}; /// Cortex-Mem CLI - File-based memory management for AI Agents #[derive(Parser)] @@ -18,7 +18,7 @@ struct Cli { #[arg(short, long, default_value = "config.toml")] config: PathBuf, - /// Tenant identifier + /// Tenant identifier (use 'cortex-mem tenant list' to see available tenants) #[arg(long, default_value = "default")] tenant: String, @@ -109,6 +109,12 @@ enum Commands { #[command(subcommand)] action: LayersAction, }, + + /// Tenant management + Tenant { + #[command(subcommand)] + action: TenantAction, + }, } #[derive(Subcommand)] @@ -139,6 +145,12 @@ enum LayersAction { RegenerateOversized, } +#[derive(Subcommand)] +enum TenantAction { + /// List all available tenants + List, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -160,6 +172,19 @@ async fn main() -> Result<()> { ) })?; + // Determine data directory + let data_dir = config.cortex.data_dir(); + + // Handle tenant list command early (doesn't need MemoryOperations) + if let Commands::Tenant { action } = cli.command { + match action { + TenantAction::List => { + tenant::list(&data_dir).await?; + } + } + return Ok(()); + } + // Initialize LLM client let model_name = config.llm.model_efficient.clone(); let llm_config = cortex_mem_core::llm::LLMConfig { @@ -171,9 +196,6 @@ async fn main() -> Result<()> { }; let llm_client = Arc::new(LLMClientImpl::new(llm_config)?); - // Determine data directory - let data_dir = config.cortex.data_dir(); - // Initialize MemoryOperations with vector search let operations = MemoryOperations::new( &data_dir, @@ -193,6 +215,7 @@ async fn main() -> Result<()> { if cli.verbose { eprintln!("LLM model: {}", model_name); eprintln!("Data directory: {}", data_dir); + eprintln!("Tenant: {}", cli.tenant); } let operations = Arc::new(operations); @@ -257,6 +280,9 @@ async fn main() -> Result<()> { layers::regenerate_oversized(operations).await?; } }, + Commands::Tenant { .. } => { + // Already handled above + } } Ok(()) diff --git a/cortex-mem-cli/test_cli.sh b/cortex-mem-cli/test_cli.sh new file mode 100755 index 0000000..cc5b020 --- /dev/null +++ b/cortex-mem-cli/test_cli.sh @@ -0,0 +1,208 @@ +#!/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-core/src/layers/manager.rs b/cortex-mem-core/src/layers/manager.rs index 260bcae..bd31e9a 100644 --- a/cortex-mem-core/src/layers/manager.rs +++ b/cortex-mem-core/src/layers/manager.rs @@ -45,7 +45,18 @@ impl LayerManager { return self.filesystem.read(&abstract_uri).await; } - // Otherwise, generate from L2 using LLM + // Check if URI is a directory (doesn't end with .md) + let is_directory = !uri.ends_with(".md"); + + if is_directory { + // For directories, abstract should be pre-generated via layers ensure-all + return Err(crate::Error::Other(format!( + "Abstract not found for directory '{}'. Run 'cortex-mem layers ensure-all' to generate it.", + uri + ))); + } + + // For files, generate abstract from L2 using LLM let detail = self.load_detail(uri).await?; let abstract_text = self.abstract_gen.generate_with_llm(&detail, &self.llm_client).await?; @@ -193,14 +204,31 @@ impl LayerManager { } /// Get layer URI for a base URI + /// + /// For file URIs (ending with .md): extract directory and append layer file + /// For directory URIs: directly append layer file fn get_layer_uri(base_uri: &str, layer: ContextLayer) -> String { match layer { ContextLayer::L0Abstract => { - let dir = base_uri.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(base_uri); + // Check if URI points to a file (ends with .md) or a directory + let dir = if base_uri.ends_with(".md") { + // File URI: extract directory path + base_uri.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(base_uri) + } else { + // Directory URI: use as-is + base_uri + }; format!("{}/.abstract.md", dir) } ContextLayer::L1Overview => { - let dir = base_uri.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(base_uri); + // Check if URI points to a file (ends with .md) or a directory + let dir = if base_uri.ends_with(".md") { + // File URI: extract directory path + base_uri.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(base_uri) + } else { + // Directory URI: use as-is + base_uri + }; format!("{}/.overview.md", dir) } ContextLayer::L2Detail => base_uri.to_string(),