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
|
# OMBRE_HOOK_SKIP — set to "1" to disable the hook temporarily
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -28,35 +27,18 @@ def main():
|
|||||||
|
|
||||||
base_url = os.environ.get("OMBRE_HOOK_URL", "http://localhost:8000").rstrip("/")
|
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(
|
req = urllib.request.Request(
|
||||||
f"{base_url}/mcp",
|
f"{base_url}/breath-hook",
|
||||||
data=payload,
|
headers={"Accept": "text/plain"},
|
||||||
headers={"Content-Type": "application/json"},
|
method="GET",
|
||||||
method="POST",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=8) as response:
|
with urllib.request.urlopen(req, timeout=8) as response:
|
||||||
raw = response.read().decode("utf-8")
|
raw = response.read().decode("utf-8")
|
||||||
data = json.loads(raw)
|
output = raw.strip()
|
||||||
# Extract text from MCP tool result
|
if output:
|
||||||
result_content = data.get("result", {}).get("content", [])
|
print(output)
|
||||||
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}")
|
|
||||||
except (urllib.error.URLError, OSError):
|
except (urllib.error.URLError, OSError):
|
||||||
# Server not available (local stdio mode or not running) — silent fail
|
# Server not available (local stdio mode or not running) — silent fail
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session_breath.py\"",
|
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session_breath.py\"",
|
||||||
"shell": "powershell",
|
"shell": "/bin/bash",
|
||||||
"timeout": 12,
|
"timeout": 12,
|
||||||
"statusMessage": "Ombre Brain 正在浮现记忆..."
|
"statusMessage": "Ombre Brain 正在浮现记忆..."
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session_breath.py\"",
|
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session_breath.py\"",
|
||||||
"shell": "powershell",
|
"shell": "/bin/bash",
|
||||||
"timeout": 12,
|
"timeout": 12,
|
||||||
"statusMessage": "Ombre Brain 正在浮现记忆..."
|
"statusMessage": "Ombre Brain 正在浮现记忆..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ Claude ←→ MCP Protocol ←→ server.py
|
|||||||
### 步骤 / Steps
|
### 步骤 / Steps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/P0lar1zzZ/Ombre-Brain.git
|
git clone https://git.p0lar1s.uk/P0lar1s/Ombre_Brain.git
|
||||||
cd Ombre-Brain
|
cd Ombre_Brain
|
||||||
|
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ class BucketManager:
|
|||||||
scoring = config.get("scoring_weights", {})
|
scoring = config.get("scoring_weights", {})
|
||||||
self.w_topic = scoring.get("topic_relevance", 4.0)
|
self.w_topic = scoring.get("topic_relevance", 4.0)
|
||||||
self.w_emotion = scoring.get("emotion_resonance", 2.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.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
|
# Create a new bucket
|
||||||
@@ -573,9 +574,9 @@ class BucketManager:
|
|||||||
)
|
)
|
||||||
* 2
|
* 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:
|
# Emotion resonance sub-score:
|
||||||
@@ -619,7 +620,7 @@ class BucketManager:
|
|||||||
days = max(0.0, (datetime.now() - last_active).total_seconds() / 86400)
|
days = max(0.0, (datetime.now() - last_active).total_seconds() / 86400)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
days = 30
|
days = 30
|
||||||
return math.exp(-0.02 * days)
|
return math.exp(-0.1 * days)
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# List all buckets
|
# 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:
|
if count_tokens_approx(content) < 100:
|
||||||
return self._format_output(content, metadata)
|
return self._format_output(content, metadata)
|
||||||
|
|
||||||
# --- Try API compression first (best quality) ---
|
# --- Local compression (Always used as requested) ---
|
||||||
# --- 优先尝试 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) ---
|
|
||||||
# --- 本地压缩兜底 ---
|
|
||||||
result = self._local_dehydrate(content)
|
result = self._local_dehydrate(content)
|
||||||
return self._format_output(result, metadata)
|
return self._format_output(result, metadata)
|
||||||
|
|
||||||
@@ -322,9 +309,10 @@ class Dehydrator:
|
|||||||
# --- Top-8 sentences + keyword list / 取高分句 + 关键词列表 ---
|
# --- Top-8 sentences + keyword list / 取高分句 + 关键词列表 ---
|
||||||
selected = [s for _, s in scored[:8]]
|
selected = [s for _, s in scored[:8]]
|
||||||
summary = "。".join(selected)
|
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)
|
# Local merge (simple concatenation + truncation)
|
||||||
@@ -393,7 +381,6 @@ class Dehydrator:
|
|||||||
header = ""
|
header = ""
|
||||||
if metadata and isinstance(metadata, dict):
|
if metadata and isinstance(metadata, dict):
|
||||||
name = metadata.get("name", "未命名")
|
name = metadata.get("name", "未命名")
|
||||||
tags = ", ".join(metadata.get("tags", []))
|
|
||||||
domains = ", ".join(metadata.get("domain", []))
|
domains = ", ".join(metadata.get("domain", []))
|
||||||
try:
|
try:
|
||||||
valence = float(metadata.get("valence", 0.5))
|
valence = float(metadata.get("valence", 0.5))
|
||||||
@@ -403,10 +390,10 @@ class Dehydrator:
|
|||||||
header = f"📌 记忆桶: {name}"
|
header = f"📌 记忆桶: {name}"
|
||||||
if domains:
|
if domains:
|
||||||
header += f" [主题:{domains}]"
|
header += f" [主题:{domains}]"
|
||||||
if tags:
|
|
||||||
header += f" [标签:{tags}]"
|
|
||||||
header += f" [情感:V{valence:.1f}/A{arousal:.1f}]"
|
header += f" [情感:V{valence:.1f}/A{arousal:.1f}]"
|
||||||
header += "\n"
|
header += "\n"
|
||||||
|
|
||||||
|
content = re.sub(r'\[\[([^\]]+)\]\]', r'\1', content)
|
||||||
return f"{header}{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)
|
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
|
# Internal helper: merge-or-create
|
||||||
# 内部辅助:检查是否可合并,可以则合并,否则新建
|
# 内部辅助:检查是否可合并,可以则合并,否则新建
|
||||||
@@ -111,7 +148,7 @@ async def _merge_or_create(
|
|||||||
返回 (桶ID或名称, 是否合并)。
|
返回 (桶ID或名称, 是否合并)。
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Search for merge failed, creating new / 合并搜索失败,新建: {e}")
|
logger.warning(f"Search for merge failed, creating new / 合并搜索失败,新建: {e}")
|
||||||
existing = []
|
existing = []
|
||||||
@@ -186,7 +223,8 @@ async def breath(
|
|||||||
pinned_results = []
|
pinned_results = []
|
||||||
for b in pinned_buckets:
|
for b in pinned_buckets:
|
||||||
try:
|
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}")
|
pinned_results.append(f"📌 [核心准则] {summary}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to dehydrate pinned bucket / 钉选桶脱水失败: {e}")
|
logger.warning(f"Failed to dehydrate pinned bucket / 钉选桶脱水失败: {e}")
|
||||||
@@ -211,7 +249,8 @@ async def breath(
|
|||||||
dynamic_results = []
|
dynamic_results = []
|
||||||
for b in top:
|
for b in top:
|
||||||
try:
|
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"])
|
await bucket_mgr.touch(b["id"])
|
||||||
score = decay_engine.calculate_score(b["metadata"])
|
score = decay_engine.calculate_score(b["metadata"])
|
||||||
dynamic_results.append(f"[权重:{score:.2f}] {summary}")
|
dynamic_results.append(f"[权重:{score:.2f}] {summary}")
|
||||||
@@ -249,7 +288,8 @@ async def breath(
|
|||||||
results = []
|
results = []
|
||||||
for bucket in matches:
|
for bucket in matches:
|
||||||
try:
|
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"])
|
await bucket_mgr.touch(bucket["id"])
|
||||||
results.append(summary)
|
results.append(summary)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -271,7 +311,8 @@ async def breath(
|
|||||||
drifted = random.sample(low_weight, min(random.randint(1, 3), len(low_weight)))
|
drifted = random.sample(low_weight, min(random.randint(1, 3), len(low_weight)))
|
||||||
drift_results = []
|
drift_results = []
|
||||||
for b in drifted:
|
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}")
|
drift_results.append(f"[surface_type: random]\n{summary}")
|
||||||
results.append("--- 忽然想起来 ---\n" + "\n---\n".join(drift_results))
|
results.append("--- 忽然想起来 ---\n" + "\n---\n".join(drift_results))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user