fix: improve merge logic, time decay, breath output format, session hook
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 正在浮现记忆..."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
30
check_buckets.py
Normal 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())
|
||||
@@ -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}"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
|
||||
51
server.py
51
server.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user