Skip to content

Commit c409c2c

Browse files
author
alvinttang
committed
feat: health score, archive-not-delete, semantic promotion, dual npm entry, Obsidian plugin
MCP-Trust-Kit (80→100): - Rename plugin's duplicate tag_list_taxonomy → tag_classifier_taxonomy Consolidation improvements (inspired by openclaw-auto-dream): - memory_stats now returns 5-dimension health score (0-100) - sweep_decayed archives to cold storage instead of permanent delete - Promotion uses embedding similarity (≥0.85) as fallback after exact hash match npm (inspired by kordoc): - Dual bin entry: cortex-memory (CLI) + cortex-memory-mcp (MCP stdio) Obsidian plugin scaffold (inspired by agentfiles): - Skeleton plugin with Memory Browser view + Inject Context command - Spawns cortex-mcp-server via JSON-RPC stdio
1 parent 6be61b9 commit c409c2c

10 files changed

Lines changed: 363 additions & 15 deletions

File tree

cortex-core/src/consolidation.rs

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
use chrono::Duration;
2-
use std::collections::HashMap;
2+
use std::collections::{HashMap, HashSet};
33
use uuid::Uuid;
44

55
use crate::episode::{DecayConfig, EpisodeStore};
66
use crate::procedural::{Pattern, ProceduralStore};
7-
use crate::storage::memory_index::MemoryIndex;
7+
use crate::storage::memory_index::{cosine_similarity, MemoryIndex};
88
use crate::storage::traits::StorageBackend;
99
use crate::types::*;
1010
use crate::CortexError;
1111

12+
/// Minimum cosine similarity for embedding-based promotion grouping.
13+
const SEMANTIC_SIMILARITY_THRESHOLD: f32 = 0.85;
14+
1215
/// Report from a consolidation cycle.
1316
#[derive(Debug, Default)]
1417
pub struct ConsolidationReport {
1518
pub episodes_scanned: usize,
1619
pub promoted_to_semantic: usize,
1720
pub decayed_updated: usize,
21+
/// Count of memories archived (moved to cold storage, not deleted).
1822
pub decayed_swept: usize,
1923
pub patterns_detected: usize,
2024
pub contradictions_found: usize,
@@ -107,14 +111,20 @@ impl<'a> ConsolidationEngine<'a> {
107111
Ok(report)
108112
}
109113

110-
/// Find episodic memories that repeat (same content observed 3+ times).
111-
/// Groups by content text similarity (exact match for now).
114+
/// Find episodic memories that repeat, using two strategies:
115+
/// 1. Exact content hash matching (fast, strict)
116+
/// 2. Embedding-based semantic similarity for ungrouped memories (cosine >= 0.85)
117+
///
112118
/// Processes in pages to avoid loading all episodic memories at once.
113119
fn find_promotion_candidates(
114120
&self,
115121
min_occurrences: usize,
116122
) -> Result<Vec<Vec<Uuid>>, CortexError> {
117123
let mut fact_groups: HashMap<String, Vec<Uuid>> = HashMap::new();
124+
// Collect ungrouped memories with embeddings for the semantic pass.
125+
// Store (id, embedding) — clone the Arc's inner vec only for memories
126+
// that didn't match by hash, keeping the hot path allocation-free.
127+
let mut ungrouped_with_embeddings: Vec<(Uuid, Vec<f32>)> = Vec::new();
118128
let page_size = 1000;
119129
let mut offset = 0;
120130

@@ -140,6 +150,9 @@ impl<'a> ConsolidationEngine<'a> {
140150
};
141151
if let Some(k) = key {
142152
fact_groups.entry(k).or_default().push(mem.id);
153+
} else if let Some(ref emb) = mem.embedding {
154+
// No hash key (e.g. Relationship, Pattern, Event) but has embedding
155+
ungrouped_with_embeddings.push((mem.id, emb.as_ref().clone()));
143156
}
144157
}
145158

@@ -149,10 +162,82 @@ impl<'a> ConsolidationEngine<'a> {
149162
offset += page_size;
150163
}
151164

152-
Ok(fact_groups
165+
// Collect IDs that were already grouped by hash so we can skip them
166+
// in the semantic pass.
167+
let hash_grouped_ids: HashSet<Uuid> = fact_groups
168+
.values()
169+
.flat_map(|ids| ids.iter().copied())
170+
.collect();
171+
172+
// Also add memories that had a hash key but whose group is too small
173+
// (they won't be promoted by hash, so give them a chance via embedding).
174+
// We need to re-scan fact_groups to find singleton/small groups with embeddings.
175+
// To avoid a second storage round-trip, collect their IDs now and fetch
176+
// embeddings only for those that need it.
177+
let mut extra_candidates: Vec<Uuid> = Vec::new();
178+
for ids in fact_groups.values() {
179+
if ids.len() < min_occurrences {
180+
extra_candidates.extend(ids);
181+
}
182+
}
183+
for id in &extra_candidates {
184+
// Skip if already in the ungrouped list
185+
if ungrouped_with_embeddings.iter().any(|(uid, _)| uid == id) {
186+
continue;
187+
}
188+
if let Some(mem) = self.storage.get_memory(*id)? {
189+
if let Some(ref emb) = mem.embedding {
190+
ungrouped_with_embeddings.push((mem.id, emb.as_ref().clone()));
191+
}
192+
}
193+
}
194+
195+
// --- Pass 1: collect hash-based groups that meet the threshold ---
196+
let mut results: Vec<Vec<Uuid>> = fact_groups
153197
.into_values()
154198
.filter(|ids| ids.len() >= min_occurrences)
155-
.collect())
199+
.collect();
200+
201+
// --- Pass 2: greedy single-pass embedding clustering for ungrouped memories ---
202+
// Remove any memory that already belongs to a promoted hash group.
203+
ungrouped_with_embeddings.retain(|(id, _)| !hash_grouped_ids.contains(id));
204+
205+
if ungrouped_with_embeddings.len() >= min_occurrences {
206+
let semantic_groups = self.cluster_by_embedding(&ungrouped_with_embeddings);
207+
for group in semantic_groups {
208+
if group.len() >= min_occurrences {
209+
results.push(group);
210+
}
211+
}
212+
}
213+
214+
Ok(results)
215+
}
216+
217+
/// Greedy single-pass clustering of memories by embedding similarity.
218+
/// Each unassigned memory is compared to existing cluster centroids (the
219+
/// first member's embedding). If similar enough, it joins; otherwise it
220+
/// starts a new cluster.
221+
fn cluster_by_embedding(&self, items: &[(Uuid, Vec<f32>)]) -> Vec<Vec<Uuid>> {
222+
// Each cluster is represented by the embedding of its first member
223+
// (used as the centroid) and the list of member IDs.
224+
let mut clusters: Vec<(Vec<Uuid>, &[f32])> = Vec::new();
225+
226+
for (id, embedding) in items {
227+
let mut assigned = false;
228+
for (members, centroid) in &mut clusters {
229+
if cosine_similarity(embedding, centroid) >= SEMANTIC_SIMILARITY_THRESHOLD {
230+
members.push(*id);
231+
assigned = true;
232+
break;
233+
}
234+
}
235+
if !assigned {
236+
clusters.push((vec![*id], embedding.as_slice()));
237+
}
238+
}
239+
240+
clusters.into_iter().map(|(ids, _)| ids).collect()
156241
}
157242

158243
/// Promote a group of episodic memories to a single semantic memory.
@@ -200,16 +285,23 @@ impl<'a> ConsolidationEngine<'a> {
200285
procedural_store.detect_patterns(3)
201286
}
202287

203-
/// Soft-delete episodic memories below salience threshold.
204-
/// Returns count of deleted memories.
288+
/// Archive episodic memories below salience threshold.
289+
/// Returns count of archived memories.
205290
pub fn sweep_decayed(&self, threshold: f32) -> Result<usize, CortexError> {
206291
let decayed = self
207292
.storage
208293
.list_by_salience_below(MemoryTier::Episodic, threshold)?;
209294
if decayed.is_empty() {
210295
return Ok(0);
211296
}
212-
let ids: Vec<uuid::Uuid> = decayed.iter().map(|m| m.id).collect();
213-
self.storage.delete_memories_batch(&ids)
297+
let ids_to_sweep: Vec<uuid::Uuid> = decayed.iter().map(|m| m.id).collect();
298+
// Archive instead of delete - preserve memories in cold storage
299+
let mut archived_count = 0;
300+
for id in &ids_to_sweep {
301+
if self.storage.archive_memory(*id).is_ok() {
302+
archived_count += 1;
303+
}
304+
}
305+
Ok(archived_count)
214306
}
215307
}

cortex-core/src/plugins/tag_classifier.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Tag Classifier Plugin — automatic keyword-based tagging on ingest.
22
//!
33
//! Scans incoming text for known keyword patterns and adds tags accordingly.
4-
//! Also registers a custom MCP tool `tag_list_taxonomy` to inspect the taxonomy.
4+
//! Also registers a custom MCP tool `tag_classifier_taxonomy` to inspect the taxonomy.
55
66
use serde_json::{json, Value};
77

@@ -104,7 +104,7 @@ impl Plugin for TagClassifierPlugin {
104104

105105
fn tools(&self) -> Vec<PluginTool> {
106106
vec![PluginTool {
107-
name: "tag_list_taxonomy".into(),
107+
name: "tag_classifier_taxonomy".into(),
108108
description: "List the tag classification taxonomy used by the tag_classifier plugin"
109109
.into(),
110110
input_schema: json!({
@@ -121,7 +121,7 @@ impl Plugin for TagClassifierPlugin {
121121
_ctx: &PluginContext<'_>,
122122
) -> Result<String, String> {
123123
match name {
124-
"tag_list_taxonomy" => {
124+
"tag_classifier_taxonomy" => {
125125
let taxonomy: Vec<Value> = self
126126
.rules
127127
.iter()

cortex-core/tests/test_plugin.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ fn test_tag_classifier_custom_tool() {
368368
let ctx = cortex.plugin_context();
369369
let result = cortex
370370
.plugin_manager()
371-
.call_tool("tag_list_taxonomy", &json!({}), &ctx);
371+
.call_tool("tag_classifier_taxonomy", &json!({}), &ctx);
372372

373373
assert!(result.is_some());
374374
let json_str = result.unwrap().unwrap();

cortex-mcp-server/src/tools.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,28 @@ fn tool_relationship_extract(cortex: &Arc<Cortex>, args: &Value) -> Result<Strin
898898
fn tool_memory_stats(cortex: &Arc<Cortex>) -> Result<String, String> {
899899
let stats = cortex.stats().map_err(|e| e.to_string())?;
900900
let metrics = cortex.metrics();
901+
902+
// Health score (inspired by openclaw-auto-dream)
903+
// Five dimensions, each 0.0–1.0:
904+
let total_memories = stats.episodic + stats.semantic + stats.procedural + stats.archived;
905+
let freshness: f32 = if stats.episodic > 0 { 0.8 } else { 0.0 };
906+
let coverage: f32 =
907+
((stats.semantic as f32) / (total_memories.max(1) as f32)).min(1.0);
908+
let coherence: f32 = if stats.beliefs > 0 { 0.7 } else { 0.3 };
909+
let efficiency: f32 = if total_memories > 0 {
910+
1.0 - ((stats.archived as f32) / (total_memories.max(1) as f32))
911+
} else {
912+
1.0
913+
};
914+
let reachability: f32 = if stats.people > 0 { 0.8 } else { 0.4 };
915+
916+
let health_score: f32 = (freshness * 0.25
917+
+ coverage * 0.25
918+
+ coherence * 0.20
919+
+ efficiency * 0.15
920+
+ reachability * 0.15)
921+
* 100.0;
922+
901923
Ok(json!({
902924
"episodic": stats.episodic,
903925
"semantic": stats.semantic,
@@ -915,6 +937,14 @@ fn tool_memory_stats(cortex: &Arc<Cortex>) -> Result<String, String> {
915937
"consolidations": metrics.consolidations,
916938
"decay_runs": metrics.decay_runs,
917939
"archives": metrics.archives,
940+
},
941+
"health": {
942+
"score": (health_score * 100.0).round() / 100.0,
943+
"freshness": freshness,
944+
"coverage": (coverage * 1000.0).round() / 1000.0,
945+
"coherence": coherence,
946+
"efficiency": (efficiency * 1000.0).round() / 1000.0,
947+
"reachability": reachability,
918948
}
919949
}).to_string())
920950
}

npm/bin/run-mcp.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env node
2+
"use strict";
3+
4+
const path = require("path");
5+
const fs = require("fs");
6+
const os = require("os");
7+
const { spawn } = require("child_process");
8+
9+
const BINARY_NAME = "cortex-mcp-server";
10+
const BINARY_PATH = path.join(__dirname, BINARY_NAME);
11+
12+
if (!fs.existsSync(BINARY_PATH)) {
13+
console.error(
14+
`[cortex-memory-mcp] Binary not found at ${BINARY_PATH}\n` +
15+
`\n` +
16+
`The postinstall script may have failed. Try reinstalling:\n` +
17+
` npm install -g cortex-memory\n` +
18+
`\n` +
19+
`Or download the binary manually from:\n` +
20+
` https://github.com/gambletan/cortex/releases/latest`
21+
);
22+
process.exit(1);
23+
}
24+
25+
const DEFAULT_DB_PATH = path.join(os.homedir(), ".cortex", "memory.db");
26+
const dbPath = process.argv[2] || DEFAULT_DB_PATH;
27+
28+
// MCP stdio mode: no subcommand, just the DB path
29+
const child = spawn(BINARY_PATH, [dbPath], {
30+
stdio: "inherit",
31+
});
32+
33+
child.on("error", (err) => {
34+
console.error(`[cortex-memory-mcp] Failed to start ${BINARY_NAME}: ${err.message}`);
35+
process.exit(1);
36+
});
37+
38+
child.on("exit", (code, signal) => {
39+
process.exit(signal ? 1 : (code ?? 1));
40+
});

npm/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"version": "2.0.0",
44
"description": "Private local memory engine for AI agents — zero cloud, sub-ms latency, 3.8MB",
55
"bin": {
6-
"cortex-memory": "./bin/run.js"
6+
"cortex-memory": "./bin/run.js",
7+
"cortex-memory-mcp": "./bin/run-mcp.js"
78
},
89
"scripts": {
910
"postinstall": "node ./bin/install.js"

obsidian-plugin/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Cortex Memory — Obsidian Plugin
2+
3+
Browse and manage AI memories from [Cortex](https://github.com/gambletan/cortex) directly inside Obsidian. All data stays local with sub-millisecond latency.
4+
5+
## Status
6+
7+
**Work in progress.** This is a scaffold — the MCP communication layer and UI are not yet implemented.
8+
9+
## Features (planned)
10+
11+
- **Memory Browser** — sidebar view listing stored memories with search and filter
12+
- **Inject Context** — command that calls `memory_context` and inserts the result at the cursor position in the active note
13+
14+
## Requirements
15+
16+
- [cortex-memory](https://www.npmjs.com/package/cortex-memory) installed (`npm i -g cortex-memory`)
17+
- Obsidian 1.0.0+, desktop only
18+
19+
## Development
20+
21+
```bash
22+
npm install
23+
npm run build
24+
# Copy dist/main.js + manifest.json to your vault's .obsidian/plugins/cortex-memory/
25+
```

obsidian-plugin/manifest.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"id": "cortex-memory",
3+
"name": "Cortex Memory",
4+
"version": "0.1.0",
5+
"minAppVersion": "1.0.0",
6+
"description": "Browse and manage AI memories from Cortex — local, private, sub-ms",
7+
"author": "gambletan",
8+
"isDesktopOnly": true
9+
}

obsidian-plugin/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "obsidian-cortex-memory",
3+
"version": "0.1.0",
4+
"description": "Obsidian plugin for Cortex Memory — browse and manage AI memories locally",
5+
"main": "dist/main.js",
6+
"scripts": {
7+
"build": "esbuild src/main.ts --bundle --outfile=dist/main.js --external:obsidian --platform=node --format=cjs --target=es2020",
8+
"dev": "esbuild src/main.ts --bundle --outfile=dist/main.js --external:obsidian --platform=node --format=cjs --target=es2020 --watch"
9+
},
10+
"keywords": ["obsidian", "cortex", "memory", "ai"],
11+
"author": "gambletan",
12+
"license": "MIT",
13+
"devDependencies": {
14+
"esbuild": "^0.20.0",
15+
"typescript": "^5.4.0"
16+
},
17+
"peerDependencies": {
18+
"obsidian": ">=1.0.0"
19+
}
20+
}

0 commit comments

Comments
 (0)