fix: improve merge logic, time decay, breath output format, session hook

This commit is contained in:
P0lar1s
2026-04-15 22:00:55 +08:00
parent ab34dc4348
commit faf80fea69
7 changed files with 99 additions and 58 deletions

View File

@@ -15,7 +15,6 @@
# OMBRE_HOOK_SKIP — set to "1" to disable the hook temporarily
# ============================================================
import json
import os
import sys
import urllib.request
@@ -28,35 +27,18 @@ def main():
base_url = os.environ.get("OMBRE_HOOK_URL", "http://localhost:8000").rstrip("/")
# Build MCP call via HTTP POST to the streamable-http endpoint
# The breath tool with no query triggers surfacing mode.
payload = json.dumps({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "breath",
"arguments": {"query": "", "max_results": 2}
}
}).encode("utf-8")
req = urllib.request.Request(
f"{base_url}/mcp",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
f"{base_url}/breath-hook",
headers={"Accept": "text/plain"},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=8) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw)
# Extract text from MCP tool result
result_content = data.get("result", {}).get("content", [])
text_parts = [c.get("text", "") for c in result_content if c.get("type") == "text"]
output = "\n".join(text_parts).strip()
if output and output != "权重池平静,没有需要处理的记忆。":
print(f"[Ombre Brain - 记忆浮现]\n{output}")
output = raw.strip()
if output:
print(output)
except (urllib.error.URLError, OSError):
# Server not available (local stdio mode or not running) — silent fail
pass

View File

@@ -7,7 +7,7 @@
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session_breath.py\"",
"shell": "powershell",
"shell": "/bin/bash",
"timeout": 12,
"statusMessage": "Ombre Brain 正在浮现记忆..."
}
@@ -19,7 +19,7 @@
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session_breath.py\"",
"shell": "powershell",
"shell": "/bin/bash",
"timeout": 12,
"statusMessage": "Ombre Brain 正在浮现记忆..."
}

View File

@@ -166,8 +166,8 @@ Claude ←→ MCP Protocol ←→ server.py
### 步骤 / Steps
```bash
git clone https://github.com/P0lar1zzZ/Ombre-Brain.git
cd Ombre-Brain
git clone https://git.p0lar1s.uk/P0lar1s/Ombre_Brain.git
cd Ombre_Brain
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate

View File

@@ -87,8 +87,9 @@ class BucketManager:
scoring = config.get("scoring_weights", {})
self.w_topic = scoring.get("topic_relevance", 4.0)
self.w_emotion = scoring.get("emotion_resonance", 2.0)
self.w_time = scoring.get("time_proximity", 1.5)
self.w_time = scoring.get("time_proximity", 2.5)
self.w_importance = scoring.get("importance", 1.0)
self.content_weight = scoring.get("content_weight", 3.0) # Added to allow better content-based matching during merge
# ---------------------------------------------------------
# Create a new bucket
@@ -573,9 +574,9 @@ class BucketManager:
)
* 2
)
content_score = fuzz.partial_ratio(query, bucket.get("content", "")[:500]) * 1
content_score = fuzz.partial_ratio(query, bucket.get("content", "")[:1000]) * self.content_weight
return (name_score + domain_score + tag_score + content_score) / (100 * 8.5)
return (name_score + domain_score + tag_score + content_score) / (100 * 10.5)
# ---------------------------------------------------------
# Emotion resonance sub-score:
@@ -619,7 +620,7 @@ class BucketManager:
days = max(0.0, (datetime.now() - last_active).total_seconds() / 86400)
except (ValueError, TypeError):
days = 30
return math.exp(-0.02 * days)
return math.exp(-0.1 * days)
# ---------------------------------------------------------
# List all buckets

30
check_buckets.py Normal file
View File

@@ -0,0 +1,30 @@
import asyncio
from bucket_manager import BucketManager
from utils import load_config
async def main():
config = load_config()
bm = BucketManager(config)
buckets = await bm.list_all(include_archive=True)
print(f"Total buckets: {len(buckets)}")
domains = {}
for b in buckets:
for d in b.get("metadata", {}).get("domain", []):
domains[d] = domains.get(d, 0) + 1
print(f"Domains: {domains}")
# Check for formatting issues (e.g., missing critical fields)
issues = 0
for b in buckets:
meta = b.get("metadata", {})
if not meta.get("name") or not meta.get("domain") or not b.get("content"):
print(f"Format issue in {b['id']}")
issues += 1
print(f"Found {issues} formatting issues.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -193,21 +193,8 @@ class Dehydrator:
if count_tokens_approx(content) < 100:
return self._format_output(content, metadata)
# --- Try API compression first (best quality) ---
# --- 优先尝试 API 压缩 ---
if self.api_available:
try:
result = await self._api_dehydrate(content)
if result:
return self._format_output(result, metadata)
except Exception as e:
logger.warning(
f"API dehydration failed, degrading to local / "
f"API 脱水失败,降级到本地压缩: {e}"
)
# --- Local compression fallback (works without API) ---
# --- 本地压缩兜底 ---
# --- Local compression (Always used as requested) ---
# --- 本地压缩 ---
result = self._local_dehydrate(content)
return self._format_output(result, metadata)
@@ -322,9 +309,10 @@ class Dehydrator:
# --- Top-8 sentences + keyword list / 取高分句 + 关键词列表 ---
selected = [s for _, s in scored[:8]]
summary = "".join(selected)
keyword_str = ", ".join(keywords[:10])
return f"[摘要] {summary}\n[关键词] {keyword_str}"
if len(summary) > 1000:
summary = summary[:1000] + ""
return summary
# ---------------------------------------------------------
# Local merge (simple concatenation + truncation)
@@ -393,7 +381,6 @@ class Dehydrator:
header = ""
if metadata and isinstance(metadata, dict):
name = metadata.get("name", "未命名")
tags = ", ".join(metadata.get("tags", []))
domains = ", ".join(metadata.get("domain", []))
try:
valence = float(metadata.get("valence", 0.5))
@@ -403,10 +390,10 @@ class Dehydrator:
header = f"📌 记忆桶: {name}"
if domains:
header += f" [主题:{domains}]"
if tags:
header += f" [标签:{tags}]"
header += f" [情感:V{valence:.1f}/A{arousal:.1f}]"
header += "\n"
content = re.sub(r'\[\[([^\]]+)\]\]', r'\1', content)
return f"{header}{content}"
# ---------------------------------------------------------

View File

@@ -89,6 +89,43 @@ async def health_check(request):
return JSONResponse({"status": "error", "detail": str(e)}, status_code=500)
# =============================================================
# /breath-hook endpoint: Dedicated hook for SessionStart
# 会话启动专用挂载点
# =============================================================
@mcp.custom_route("/breath-hook", methods=["GET"])
async def breath_hook(request):
from starlette.responses import PlainTextResponse
try:
all_buckets = await bucket_mgr.list_all(include_archive=False)
# pinned
pinned = [b for b in all_buckets if b["metadata"].get("pinned") or b["metadata"].get("protected")]
# top 2 unresolved by score
unresolved = [b for b in all_buckets
if not b["metadata"].get("resolved", False)
and b["metadata"].get("type") != "permanent"
and not b["metadata"].get("pinned")
and not b["metadata"].get("protected")]
scored = sorted(unresolved, key=lambda b: decay_engine.calculate_score(b["metadata"]), reverse=True)
top = scored[:2]
parts = []
for b in pinned:
summary = await dehydrator.dehydrate(b["content"], {k: v for k, v in b["metadata"].items() if k != "tags"})
parts.append(f"📌 [核心准则] {summary}")
for b in top:
summary = await dehydrator.dehydrate(b["content"], {k: v for k, v in b["metadata"].items() if k != "tags"})
await bucket_mgr.touch(b["id"])
parts.append(summary)
if not parts:
return PlainTextResponse("")
return PlainTextResponse("[Ombre Brain - 记忆浮现]\n" + "\n---\n".join(parts))
except Exception as e:
logger.warning(f"Breath hook failed: {e}")
return PlainTextResponse("")
# =============================================================
# Internal helper: merge-or-create
# 内部辅助:检查是否可合并,可以则合并,否则新建
@@ -111,7 +148,7 @@ async def _merge_or_create(
返回 (桶ID或名称, 是否合并)。
"""
try:
existing = await bucket_mgr.search(content, limit=1)
existing = await bucket_mgr.search(content, limit=1, domain_filter=domain or None)
except Exception as e:
logger.warning(f"Search for merge failed, creating new / 合并搜索失败,新建: {e}")
existing = []
@@ -186,7 +223,8 @@ async def breath(
pinned_results = []
for b in pinned_buckets:
try:
summary = await dehydrator.dehydrate(b["content"], b["metadata"])
clean_meta = {k: v for k, v in b["metadata"].items() if k != "tags"}
summary = await dehydrator.dehydrate(b["content"], clean_meta)
pinned_results.append(f"📌 [核心准则] {summary}")
except Exception as e:
logger.warning(f"Failed to dehydrate pinned bucket / 钉选桶脱水失败: {e}")
@@ -211,7 +249,8 @@ async def breath(
dynamic_results = []
for b in top:
try:
summary = await dehydrator.dehydrate(b["content"], b["metadata"])
clean_meta = {k: v for k, v in b["metadata"].items() if k != "tags"}
summary = await dehydrator.dehydrate(b["content"], clean_meta)
await bucket_mgr.touch(b["id"])
score = decay_engine.calculate_score(b["metadata"])
dynamic_results.append(f"[权重:{score:.2f}] {summary}")
@@ -249,7 +288,8 @@ async def breath(
results = []
for bucket in matches:
try:
summary = await dehydrator.dehydrate(bucket["content"], bucket["metadata"])
clean_meta = {k: v for k, v in bucket["metadata"].items() if k != "tags"}
summary = await dehydrator.dehydrate(bucket["content"], clean_meta)
await bucket_mgr.touch(bucket["id"])
results.append(summary)
except Exception as e:
@@ -271,7 +311,8 @@ async def breath(
drifted = random.sample(low_weight, min(random.randint(1, 3), len(low_weight)))
drift_results = []
for b in drifted:
summary = await dehydrator.dehydrate(b["content"], b["metadata"])
clean_meta = {k: v for k, v in b["metadata"].items() if k != "tags"}
summary = await dehydrator.dehydrate(b["content"], clean_meta)
drift_results.append(f"[surface_type: random]\n{summary}")
results.append("--- 忽然想起来 ---\n" + "\n---\n".join(drift_results))
except Exception as e: