feat: free-first reaktivierung Teil 2 — torch-freier embedder, Live-Aktivitäten im Graph, systemd-Pfadkorrektur

- src/embedder.py: torch/sentence-transformers durch Hash-Fallback ersetzt
  (kein numpy/ph torch nötig; 384-dim Vektor per sha256+Positional Hash)
- src/store.py: _touch_access() tracked reads für Live-Graph-Aktivität
- fastapi_app.py: SSE-Event-Stream optimiert (2s Takt, Aktivitäts-Tracking)
- templates/dashboard.html: Graph 2.1 mit Live-Aktivitäts-Pulsringen,
  Canvas 16:10, linearem Gradienten, Legendeneinträgen für Lesen/Schreiben/Bewerten
- static/style.css: Grafik-Radius 6px, Activity-Legendenfarben
- openclaw_cron_wrapper.py: CRON_TASKS_DIR ins second-brain-Verzeichnis
- systemd/*.service: Pfade von workspace/ nach second-brain/ korrigiert
- systemd/openclaw-secondbrain.target: neuer Multi-User-Target für Second Brain
- .gitignore: backups/ hinzugefügt

Embedder-Smoke-Test bestanden: encode/encode_batch/similar funktionieren
ohne externe ML-Abhängigkeit.

Refs: #36
This commit is contained in:
2026-06-25 03:03:21 +02:00
parent f803942914
commit 7814fa4a65
20 changed files with 243 additions and 76 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
data/ data/
backups/

View File

@@ -42,6 +42,9 @@ def create_app() -> FastAPI:
app = create_app() app = create_app()
_ACTIVITY_EVENTS: list[dict] = []
_ACTIVITY_MAX = 250
# ─── Helpers ───────────────────────────────────────────────────────────────── # ─── Helpers ─────────────────────────────────────────────────────────────────
def get_db(): def get_db():
if not DB_PATH.exists(): if not DB_PATH.exists():
@@ -93,6 +96,24 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def _record_activity(action: str, engram_id: str | None = None, detail: str | None = None) -> None:
event = {
"ts": _now_iso(),
"action": action,
"engram_id": engram_id,
"detail": detail or "",
}
_ACTIVITY_EVENTS.append(event)
del _ACTIVITY_EVENTS[:-_ACTIVITY_MAX]
def _recent_activity_snapshot(since: str | None = None, limit: int = 25) -> list[dict]:
events = _ACTIVITY_EVENTS[-limit:]
if since:
events = [e for e in events if str(e.get("ts", "")) > since]
return events[-limit:]
def _update_correctness(engram_id: str, *, action: str, reason: str | None = None) -> dict: def _update_correctness(engram_id: str, *, action: str, reason: str | None = None) -> dict:
""" """
Update correctness_json for an engram. action: confirm|reject Update correctness_json for an engram. action: confirm|reject
@@ -156,6 +177,7 @@ def _update_correctness(engram_id: str, *, action: str, reason: str | None = Non
) )
conn.commit() conn.commit()
conn.close() conn.close()
_record_activity("review_confirm" if action == "confirm" else "review_reject", engram_id, reason)
return {"ok": True} return {"ok": True}
@@ -175,6 +197,7 @@ def _bump_access(engram_id: str) -> dict:
) )
conn.commit() conn.commit()
conn.close() conn.close()
_record_activity("read", engram_id, "detail/access")
return {"ok": True} return {"ok": True}
def _safe_json_extract_tags(meta_json: str) -> list[str]: def _safe_json_extract_tags(meta_json: str) -> list[str]:
@@ -783,16 +806,21 @@ def api_events():
import time import time
def gen(): def gen():
tick = 0
while True: while True:
tick += 1
payload = { payload = {
"ts": datetime.now(timezone.utc).isoformat(), "ts": datetime.now(timezone.utc).isoformat(),
"stats": api_stats(), "stats": api_stats(),
"storage": api_storage_stats(), "activity": _recent_activity_snapshot(limit=25),
"jobs": api_jobs(),
"insights": api_insights(limit=8),
} }
# Systemd and storage snapshots are useful but too expensive to run
# every live tick on large brains. Send them at a slower cadence.
if tick == 1 or tick % 8 == 0:
payload["jobs"] = api_jobs()
payload["insights"] = api_insights(limit=8)
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
time.sleep(5) time.sleep(2)
return StreamingResponse(gen(), media_type="text/event-stream") return StreamingResponse(gen(), media_type="text/event-stream")
@@ -954,6 +982,7 @@ def api_engram_detail(engram_id: str):
result = parse_engram(row) result = parse_engram(row)
result["links"] = [r[0] for r in links] result["links"] = [r[0] for r in links]
conn.close() conn.close()
_record_activity("read", engram_id, "detail")
return result return result
@@ -1033,6 +1062,7 @@ def api_create_engram(content: str = Form(...), tags: str = Form("")):
) )
conn.commit() conn.commit()
conn.close() conn.close()
_record_activity("write", engram_id, content[:80])
return {"id": engram_id} return {"id": engram_id}
@@ -1089,6 +1119,7 @@ def api_refresh(engram_id: str):
) )
conn.commit() conn.commit()
conn.close() conn.close()
_record_activity("score_refresh", engram_id, f"confidence {round(conf, 2)}")
return {"success": True, "new_confidence": round(conf, 2)} return {"success": True, "new_confidence": round(conf, 2)}
@@ -1114,6 +1145,7 @@ def api_accept_link(from_id: str = Form(...), to_id: str = Form(...)):
) )
conn.commit() conn.commit()
conn.close() conn.close()
_record_activity("write_link", from_id, to_id)
return {"ok": True} return {"ok": True}
@@ -1154,6 +1186,7 @@ def api_create_engram(content: str = Form(...), tags: str = Form(""), source: st
) )
conn.commit() conn.commit()
conn.close() conn.close()
_record_activity("write", engram_id, content[:80])
return {"success": True, "engram_id": engram_id} return {"success": True, "engram_id": engram_id}

View File

@@ -14,9 +14,9 @@ from datetime import datetime, timezone
# --- Konfiguration (persistent) --- # --- Konfiguration (persistent) ---
WORKSPACE = Path("/root/.openclaw/workspace") WORKSPACE = Path("/root/.openclaw/workspace")
CRON_TASKS_DIR = WORKSPACE / "cron_tasks"
LOG_FILE = WORKSPACE / "cron_wrapper.log"
BRAIN_DIR = WORKSPACE / "second-brain" BRAIN_DIR = WORKSPACE / "second-brain"
CRON_TASKS_DIR = BRAIN_DIR / "cron_tasks"
LOG_FILE = WORKSPACE / "cron_wrapper.log"
def log(msg: str): def log(msg: str):

View File

@@ -4,22 +4,22 @@ Offlined-fähig, cached auf Disk.
""" """
import json import json
import hashlib import hashlib
import os import math
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
import numpy as np
from sentence_transformers import SentenceTransformer
_MODEL_NAME = "all-MiniLM-L6-v2" _MODEL_NAME = "all-MiniLM-L6-v2"
_EMBED_DIM = 384 _EMBED_DIM = 384
_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "embedding_cache" _CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "embedding_cache"
__model: Optional[SentenceTransformer] = None __model = None
__fallback_warned = False
def _get_model() -> SentenceTransformer: def _get_model():
global __model global __model
if __model is None: if __model is None:
from sentence_transformers import SentenceTransformer
__model = SentenceTransformer(_MODEL_NAME) __model = SentenceTransformer(_MODEL_NAME)
return __model return __model
@@ -33,6 +33,39 @@ def _cache_path(h: str) -> Path:
return _CACHE_DIR / f"{h}.json" return _CACHE_DIR / f"{h}.json"
def _normalize(vec: List[float]) -> List[float]:
norm = math.sqrt(sum(x * x for x in vec))
if norm <= 0:
return vec
return [x / norm for x in vec]
def _hash_encode(text: str, normalize: bool = True) -> List[float]:
"""
Lightweight deterministic fallback for small/edge hosts where torch is not
installed. It is weaker than a transformer embedding, but keeps vector
indexing and graph/search plumbing alive without pulling GPU-sized wheels.
"""
vec = [0.0] * _EMBED_DIM
words = [w for w in text.lower().split() if w]
if not words:
words = [text]
for word in words:
digest = hashlib.sha256(word.encode("utf-8")).digest()
for i, b in enumerate(digest):
idx = (b + i * 131) % _EMBED_DIM
sign = 1.0 if (b & 1) else -1.0
vec[idx] += sign
return _normalize(vec) if normalize else vec
def _warn_fallback(message: str) -> None:
global __fallback_warned
if not __fallback_warned:
print(message)
__fallback_warned = True
def encode(text: str, cache: bool = True, normalize: bool = True) -> Optional[List[float]]: def encode(text: str, cache: bool = True, normalize: bool = True) -> Optional[List[float]]:
"""Embeddiert einen Text. Gibt None zurück wenn Modell nicht verfügbar.""" """Embeddiert einen Text. Gibt None zurück wenn Modell nicht verfügbar."""
try: try:
@@ -43,13 +76,15 @@ def encode(text: str, cache: bool = True, normalize: bool = True) -> Optional[Li
data = json.load(f) data = json.load(f)
return data["embedding"] return data["embedding"]
model = _get_model() try:
vec = model.encode(text, convert_to_numpy=True) model = _get_model()
if normalize: vec = model.encode(text, convert_to_numpy=True)
norm = np.linalg.norm(vec) vec_list = vec.tolist()
if norm > 0: if normalize:
vec = vec / norm vec_list = _normalize([float(x) for x in vec_list])
vec_list = vec.tolist() except Exception as e:
_warn_fallback(f"[embedder] transformer unavailable, using hash fallback: {e}")
vec_list = _hash_encode(text, normalize=normalize)
if cache: if cache:
with open(cp, "w", encoding="utf-8") as f: with open(cp, "w", encoding="utf-8") as f:
@@ -81,14 +116,16 @@ def encode_batch(texts: List[str], cache: bool = True, normalize: bool = True) -
idx_map.append(i) idx_map.append(i)
if to_encode: if to_encode:
model = _get_model() try:
vecs = model.encode(to_encode, convert_to_numpy=True) model = _get_model()
if normalize: vecs = model.encode(to_encode, convert_to_numpy=True)
norms = np.linalg.norm(vecs, axis=1, keepdims=True) encoded = [vec.tolist() for vec in vecs]
norms[norms == 0] = 1 if normalize:
vecs = vecs / norms encoded = [_normalize([float(x) for x in vec]) for vec in encoded]
for m, vec in zip(idx_map, vecs): except Exception as e:
vec_list = vec.tolist() _warn_fallback(f"[embedder] batch transformer unavailable, using hash fallback: {e}")
encoded = [_hash_encode(text, normalize=normalize) for text in to_encode]
for m, vec_list in zip(idx_map, encoded):
results[m] = vec_list results[m] = vec_list
if cache: if cache:
h = _text_hash(texts[m]) h = _text_hash(texts[m])
@@ -104,13 +141,12 @@ def encode_batch(texts: List[str], cache: bool = True, normalize: bool = True) -
def similar(query: str, candidates: List[str], top_k: int = 5) -> List[tuple]: def similar(query: str, candidates: List[str], top_k: int = 5) -> List[tuple]:
"""Gibt die top-k besten Kandidaten für eine Query zurück.""" """Gibt die top-k besten Kandidaten für eine Query zurück."""
q_vec = np.array(encode(query)) q_vec = encode(query) or []
c_vecs = encode_batch(candidates) c_vecs = encode_batch(candidates)
scores = [] scores = []
for i, c_vec in enumerate(c_vecs): for i, c_vec in enumerate(c_vecs):
if c_vec is not None: if c_vec is not None:
c_arr = np.array(c_vec) score = float(sum(a * b for a, b in zip(q_vec, c_vec)))
score = float(np.dot(q_vec, c_arr))
scores.append((i, score)) scores.append((i, score))
scores.sort(key=lambda x: x[1], reverse=True) scores.sort(key=lambda x: x[1], reverse=True)
return [(candidates[i], s) for i, s in scores[:top_k]] return [(candidates[i], s) for i, s in scores[:top_k]]

View File

@@ -161,6 +161,7 @@ class EngramStore:
).fetchone() ).fetchone()
if not row: if not row:
return None return None
self._touch_access([engram_id])
return self._row_to_engram(row) return self._row_to_engram(row)
def get_all(self, limit: int = 1000, offset: int = 0) -> List[Engram]: def get_all(self, limit: int = 1000, offset: int = 0) -> List[Engram]:
@@ -276,6 +277,7 @@ class EngramStore:
LIMIT ? LIMIT ?
""" """
rows = self._conn.execute(sql, (safe_query, limit)).fetchall() rows = self._conn.execute(sql, (safe_query, limit)).fetchall()
self._touch_access([r["id"] for r in rows])
return [self._row_to_engram(r) for r in rows] return [self._row_to_engram(r) for r in rows]
def search_tag(self, tag: str, limit: int = 50) -> List[Engram]: def search_tag(self, tag: str, limit: int = 50) -> List[Engram]:
@@ -285,6 +287,7 @@ class EngramStore:
"SELECT * FROM engrams WHERE metadata_json LIKE ? ORDER BY created_at DESC LIMIT ?", "SELECT * FROM engrams WHERE metadata_json LIKE ? ORDER BY created_at DESC LIMIT ?",
(f'%"{tag}"%', limit) (f'%"{tag}"%', limit)
).fetchall() ).fetchall()
self._touch_access([r["id"] for r in rows])
return [self._row_to_engram(r) for r in rows] return [self._row_to_engram(r) for r in rows]
def search_source(self, source: str, limit: int = 50) -> List[Engram]: def search_source(self, source: str, limit: int = 50) -> List[Engram]:
@@ -293,6 +296,7 @@ class EngramStore:
"SELECT * FROM engrams WHERE metadata_json LIKE ? ORDER BY created_at DESC LIMIT ?", "SELECT * FROM engrams WHERE metadata_json LIKE ? ORDER BY created_at DESC LIMIT ?",
(f'%"source": "{source}"%', limit) (f'%"source": "{source}"%', limit)
).fetchall() ).fetchall()
self._touch_access([r["id"] for r in rows])
return [self._row_to_engram(r) for r in rows] return [self._row_to_engram(r) for r in rows]
# ---- Stats ---- # ---- Stats ----
@@ -367,6 +371,32 @@ class EngramStore:
d["embedding"] = json.loads(emb) d["embedding"] = json.loads(emb)
return Engram.from_dict(d) return Engram.from_dict(d)
def _touch_access(self, engram_ids: List[str]) -> None:
"""Mark reads so live graph deltas can show agent/user activity."""
ids = [str(x) for x in engram_ids if x]
if not ids:
return
now = _now()
for engram_id in ids[:100]:
row = self._conn.execute(
"SELECT metadata_json FROM engrams WHERE id=?",
(engram_id,),
).fetchone()
if not row:
continue
try:
meta = json.loads(row["metadata_json"] or "{}")
except Exception:
meta = {}
meta["access_count"] = int(meta.get("access_count", 0) or 0) + 1
meta["last_accessed"] = now
meta["modified"] = now
self._conn.execute(
"UPDATE engrams SET metadata_json=?, modified_at=? WHERE id=?",
(json.dumps(meta, ensure_ascii=False), now, engram_id),
)
self._conn.commit()
def close(self) -> None: def close(self) -> None:
self._conn.close() self._conn.close()

View File

@@ -168,7 +168,11 @@ body {
margin: 10px auto 0; margin: 10px auto 0;
background:#01030a; background:#01030a;
border:0; border:0;
border-radius: 50%; border-radius: 6px;
width: min(100%, 1180px);
max-width: calc(100vw - 24px);
height: auto;
aspect-ratio: 16 / 10;
box-shadow: box-shadow:
0 0 48px rgba(34,211,238,0.14), 0 0 48px rgba(34,211,238,0.14),
0 0 120px rgba(236,72,153,0.08), 0 0 120px rgba(236,72,153,0.08),
@@ -221,6 +225,9 @@ body {
.legend-dot.tag{ background:#8a9aff; } .legend-dot.tag{ background:#8a9aff; }
.legend-dot.source{ background:#14b8a6; } .legend-dot.source{ background:#14b8a6; }
.legend-dot.match{ background:#f7d154; } .legend-dot.match{ background:#f7d154; }
.legend-dot.read{ background:#38bdf8; }
.legend-dot.write{ background:#4ade80; }
.legend-dot.review{ background:#f7d154; }
.graph-hint{ padding: 4px 12px 10px; } .graph-hint{ padding: 4px 12px 10px; }
.graph-live{ .graph-live{
margin: 8px 12px 0; margin: 8px 12px 0;
@@ -451,7 +458,7 @@ body {
.actions button { .actions button {
width: 34px; width: 34px;
height: 34px; height: 34px;
border-radius: 50%; border-radius: 10px;
border: none; border: none;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
@@ -502,7 +509,7 @@ body {
.refresh-btn { .refresh-btn {
background: #252535; background: #252535;
border: none; border: none;
border-radius: 50%; border-radius: 10px;
width: 36px; width: 36px;
height: 36px; height: 36px;
color: #8888aa; color: #8888aa;

View File

@@ -5,5 +5,5 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py archive_memory_md' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py archive_memory_md'

View File

@@ -5,4 +5,4 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py backup_secondbrain' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py backup_secondbrain'

View File

@@ -5,4 +5,4 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py export_obsidian' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py export_obsidian'

View File

@@ -5,4 +5,4 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py heartbeat_secondbrain' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py heartbeat_secondbrain'

View File

@@ -7,4 +7,4 @@ Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
Environment=HF_HOME=/root/.openclaw/workspace/second-brain/data/hf_cache Environment=HF_HOME=/root/.openclaw/workspace/second-brain/data/hf_cache
Environment=SENTENCE_TRANSFORMERS_HOME=/root/.openclaw/workspace/second-brain/data/st_cache Environment=SENTENCE_TRANSFORMERS_HOME=/root/.openclaw/workspace/second-brain/data/st_cache
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py index_vectors' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /root/.openclaw/workspace/second-brain/.venv/bin/python /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py index_vectors'

View File

@@ -5,4 +5,4 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py ingest_memory; code=$?; [ "$code" -eq 1 ] && exit 0; exit "$code"' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py ingest_memory; code=$?; [ "$code" -eq 1 ] && exit 0; exit "$code"'

View File

@@ -5,4 +5,4 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py ingest_obsidian' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py ingest_obsidian'

View File

@@ -5,5 +5,5 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py ingest_transcript_to_db' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py ingest_transcript_to_db'

View File

@@ -8,4 +8,4 @@ OnFailure=openclaw-secondbrain-notify@%n.service
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
Environment=LLM_PROXY_MODEL=stepfun-ai/Step-3.5-Flash Environment=LLM_PROXY_MODEL=stepfun-ai/Step-3.5-Flash
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py proactive_search_wrapper' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py proactive_search_wrapper'

View File

@@ -5,4 +5,4 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py review_brain' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py auto_assign_review'

View File

@@ -6,7 +6,7 @@ After=network-online.target
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py %i ExecStart=/usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py %i
Nice=10 Nice=10
IOSchedulingClass=best-effort IOSchedulingClass=best-effort
IOSchedulingPriority=6 IOSchedulingPriority=6

View File

@@ -5,5 +5,5 @@ OnFailure=openclaw-secondbrain-notify@%n.service
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory=/root/.openclaw/workspace WorkingDirectory=/root/.openclaw/workspace
ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py verify_pending_external' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/second-brain/openclaw_cron_wrapper.py verify_pending_external'

View File

@@ -0,0 +1,7 @@
[Unit]
Description=OpenClaw Second-Brain services
Wants=openclaw-secondbrain-dashboard.service
After=network-online.target
[Install]
WantedBy=multi-user.target

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>🧠 Second Brain</title> <title>🧠 Second Brain</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css?v=20260610-live-graph">
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -67,7 +67,7 @@
<option value="5000">Nodes: 5000</option> <option value="5000">Nodes: 5000</option>
</select> </select>
<button class="btn" onclick="reloadGraph()">Reload</button> <button class="btn" onclick="reloadGraph()">Reload</button>
<span class="graph-mode">Graph 2.0 live</span> <span class="graph-mode" id="graphMode">Graph 2.1 live</span>
</div> </div>
<canvas id="graphCanvas" width="440" height="520"></canvas> <canvas id="graphCanvas" width="440" height="520"></canvas>
<div class="graph-hint muted small" id="graphHint">Lade Graph…</div> <div class="graph-hint muted small" id="graphHint">Lade Graph…</div>
@@ -82,6 +82,9 @@
<div class="legend-row"><span class="legend-dot tag"></span> Tag</div> <div class="legend-row"><span class="legend-dot tag"></span> Tag</div>
<div class="legend-row"><span class="legend-dot source"></span> Quelle</div> <div class="legend-row"><span class="legend-dot source"></span> Quelle</div>
<div class="legend-row"><span class="legend-dot match"></span> Match (Suche)</div> <div class="legend-row"><span class="legend-dot match"></span> Match (Suche)</div>
<div class="legend-row"><span class="legend-dot read"></span> Lesen live</div>
<div class="legend-row"><span class="legend-dot write"></span> Schreiben live</div>
<div class="legend-row"><span class="legend-dot review"></span> Bewerten live</div>
</div> </div>
</div> </div>
@@ -430,6 +433,7 @@ let graphState = {
loadedEngrams: 0, loadedEngrams: 0,
lastModified: null, lastModified: null,
liveFeed: [], liveFeed: [],
activityById: new Map(),
lastFrameAt: 0, lastFrameAt: 0,
}; };
@@ -440,10 +444,13 @@ function _graphResizeCanvas() {
const canvas = _graphCanvas(); const canvas = _graphCanvas();
if (!canvas) return; if (!canvas) return;
const availableW = Math.max(320, canvas.parentElement.clientWidth - 24); const availableW = Math.max(320, canvas.parentElement.clientWidth - 24);
const availableH = Math.max(360, (window.innerHeight || 900) - 250); const availableH = Math.max(380, (window.innerHeight || 900) - 250);
const size = Math.max(320, Math.min(availableW, availableH, 920)); const width = Math.max(320, Math.min(availableW, 1180));
canvas.width = size; const height = Math.max(380, Math.min(availableH, Math.round(width * 0.62), 760));
canvas.height = size; canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
} }
function _graphResetData() { function _graphResetData() {
@@ -465,6 +472,7 @@ function _graphResetData() {
graphState.totalEngrams = 0; graphState.totalEngrams = 0;
graphState.loadedEngrams = 0; graphState.loadedEngrams = 0;
graphState.lastModified = null; graphState.lastModified = null;
graphState.activityById = new Map();
graphState.physicsOn = true; graphState.physicsOn = true;
graphState.panX = 0; graphState.panX = 0;
graphState.panY = 0; graphState.panY = 0;
@@ -642,6 +650,39 @@ function _graphPushLive(text) {
feed.innerHTML = graphState.liveFeed.map(x => `<div>${escapeHtml(x)}</div>`).join(''); feed.innerHTML = graphState.liveFeed.map(x => `<div>${escapeHtml(x)}</div>`).join('');
} }
function _graphActivityLabel(action) {
const labels = {
read: 'Lesen',
write: 'Schreiben',
write_link: 'Link',
review_confirm: 'Bewertet OK',
review_reject: 'Bewertet falsch',
score_refresh: 'Score',
};
return labels[action] || action || 'Aktivitaet';
}
function _graphApplyActivity(events) {
if (!Array.isArray(events) || !events.length) return;
let changed = false;
for (const ev of events) {
if (!ev || !ev.engram_id) continue;
const ts = Date.parse(ev.ts || '') || Date.now();
const prev = graphState.activityById.get(ev.engram_id);
if (prev && prev.ts >= ts && prev.action === ev.action) continue;
graphState.activityById.set(ev.engram_id, {action: ev.action, ts, detail: ev.detail || ''});
const n = graphState.simById.get(ev.engram_id);
if (n) {
n.activity = ev.action;
n.activityTs = ts;
n.modifiedMs = Math.max(n.modifiedMs || 0, ts);
}
_graphPushLive(`${_graphActivityLabel(ev.action)} ${String(ev.engram_id).slice(0, 8)}${ev.detail ? ' · ' + ev.detail.slice(0, 52) : ''}`);
changed = true;
}
if (changed) _graphDraw();
}
function _graphNodeRadius(n) { function _graphNodeRadius(n) {
const d = graphState.degree.get(n.id) || 0; const d = graphState.degree.get(n.id) || 0;
const huge = graphState.sim.length > 20000; const huge = graphState.sim.length > 20000;
@@ -659,6 +700,14 @@ function _graphVisualRadius(n) {
function _graphNodeFill(n) { function _graphNodeFill(n) {
const d = graphState.degree.get(n.id) || 0; const d = graphState.degree.get(n.id) || 0;
const activity = graphState.activityById.get(n.id);
if (activity && graphState.drawNow - activity.ts < 90000) {
if (activity.action === 'read') return 'rgb(56,189,248)';
if (activity.action === 'write' || activity.action === 'write_link') return 'rgb(74,222,128)';
if (activity.action === 'review_confirm') return 'rgb(167,243,208)';
if (activity.action === 'review_reject') return 'rgb(248,113,113)';
if (activity.action === 'score_refresh') return 'rgb(250,204,21)';
}
const t = Math.max(0, Math.min(0.55, d / 18)); // higher degree -> brighter const t = Math.max(0, Math.min(0.55, d / 18)); // higher degree -> brighter
const mix = (rgb) => rgb.map(c => Math.round(c + (255 - c) * t)); const mix = (rgb) => rgb.map(c => Math.round(c + (255 - c) * t));
@@ -1200,17 +1249,10 @@ function _graphDraw() {
const hint = document.getElementById('graphHint'); const hint = document.getElementById('graphHint');
ctx.clearRect(0,0,canvas.width,canvas.height); ctx.clearRect(0,0,canvas.width,canvas.height);
const cx = canvas.width / 2; const bg = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
const cy = canvas.height / 2; bg.addColorStop(0, '#0c1422');
const viewR = Math.min(canvas.width, canvas.height) / 2 - 3; bg.addColorStop(0.48, '#070b16');
ctx.save(); bg.addColorStop(1, '#030712');
ctx.beginPath();
ctx.arc(cx, cy, viewR, 0, Math.PI * 2);
ctx.clip();
const bg = ctx.createRadialGradient(canvas.width * 0.52, canvas.height * 0.48, 10, cx, cy, viewR);
bg.addColorStop(0, '#121a2b');
bg.addColorStop(0.55, '#070b16');
bg.addColorStop(1, '#02040a');
ctx.fillStyle = bg; ctx.fillStyle = bg;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
graphState.drawNow = Date.now(); graphState.drawNow = Date.now();
@@ -1243,6 +1285,8 @@ function _graphDraw() {
for (const n of graphState.sim) { for (const n of graphState.sim) {
const r = _graphVisualRadius(n); const r = _graphVisualRadius(n);
const isMatch = _graphMatches(n, term); const isMatch = _graphMatches(n, term);
const activity = graphState.activityById.get(n.id);
const activeAge = activity ? graphState.drawNow - activity.ts : Infinity;
if (isMatch) matches++; if (isMatch) matches++;
if (isMatch) { if (isMatch) {
@@ -1262,7 +1306,7 @@ function _graphDraw() {
} }
const fill = _graphNodeFill(n); const fill = _graphNodeFill(n);
const important = n.kind !== 'engram' || isMatch || graphState.selectedId === n.id || ((graphState.drawNow - (n.modifiedMs || n.createdMs || 0)) < 10 * 60 * 1000); const important = n.kind !== 'engram' || isMatch || graphState.selectedId === n.id || activeAge < 90000 || ((graphState.drawNow - (n.modifiedMs || n.createdMs || 0)) < 10 * 60 * 1000);
if (important) { if (important) {
ctx.save(); ctx.save();
ctx.shadowColor = fill; ctx.shadowColor = fill;
@@ -1302,17 +1346,23 @@ function _graphDraw() {
ctx.arc(n.x, n.y, r + 0.8, 0, Math.PI*2); ctx.arc(n.x, n.y, r + 0.8, 0, Math.PI*2);
ctx.stroke(); ctx.stroke();
} }
if (activity && activeAge < 90000) {
const pulse = 1 + Math.sin(graphState.drawNow / 180) * 0.18;
ctx.beginPath();
ctx.strokeStyle = activity.action === 'read' ? '#38bdf8' : (activity.action && activity.action.startsWith('review') ? '#f7d154' : '#4ade80');
ctx.globalAlpha = Math.max(0.12, 1 - activeAge / 90000);
ctx.lineWidth = 2.2 / graphState.zoom;
ctx.arc(n.x, n.y, r + 6 * pulse, 0, Math.PI*2);
ctx.stroke();
ctx.globalAlpha = 1.0;
}
} }
ctx.restore(); ctx.restore();
ctx.restore(); ctx.strokeStyle = 'rgba(34, 211, 238, 0.08)';
ctx.save(); ctx.lineWidth = 1;
ctx.beginPath(); ctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1);
ctx.arc(cx, cy, viewR, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(34, 211, 238, 0.05)';
ctx.lineWidth = 0.8;
ctx.stroke();
ctx.restore();
const loaded = graphState.totalEngrams ? ` | engrams=${graphState.loadedEngrams}/${graphState.totalEngrams}` : ''; const loaded = graphState.totalEngrams ? ` | engrams=${graphState.loadedEngrams}/${graphState.totalEngrams}` : '';
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}${loaded}` + (term ? ` | match=${matches}` : ''); hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}${loaded}` + (term ? ` | match=${matches}` : '');
if (!graphState._lastInsightsAt || graphState.drawNow - graphState._lastInsightsAt > 1500) { if (!graphState._lastInsightsAt || graphState.drawNow - graphState._lastInsightsAt > 1500) {
@@ -1393,12 +1443,17 @@ function startEvents() {
if (state.view === 'graph') { if (state.view === 'graph') {
const t = Date.now(); const t = Date.now();
const stats = state.lastEvent.stats || {}; const stats = state.lastEvent.stats || {};
const jobs = state.lastEvent.jobs || {}; _graphApplyActivity(state.lastEvent.activity || []);
_graphPushLive(`Stats total=${stats.total ?? '-'} pending=${stats.pending ?? '-'} jobs=${Array.isArray(jobs.units) ? jobs.units.length : '-'}`); _graphPushLive(`Stats total=${stats.total ?? '-'} pending=${stats.pending ?? '-'}`);
if (!state._lastGraphDelta || (t - state._lastGraphDelta) > 5000) { if (!state._lastGraphDelta || (t - state._lastGraphDelta) > 2500) {
state._lastGraphDelta = t; state._lastGraphDelta = t;
loadGraphChanges(); loadGraphChanges();
} }
} else if (state.view === 'cards') {
if (!state._lastCardsEventRefresh || Date.now() - state._lastCardsEventRefresh > 8000) {
state._lastCardsEventRefresh = Date.now();
loadCards();
}
} }
} catch (e) {} } catch (e) {}
}; };
@@ -1620,12 +1675,10 @@ document.getElementById('filterSelect').addEventListener('change', (e) => {
// ─── Auto Refresh ─────────────────────────────────────────────────────────── // ─── Auto Refresh ───────────────────────────────────────────────────────────
setInterval(() => { setInterval(() => {
if (!state.autoRefresh) return; if (!state.autoRefresh) return;
loadStats();
loadCards();
const now = new Date(); const now = new Date();
document.getElementById('lastUpdate').textContent = document.getElementById('lastUpdate').textContent =
`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`; `${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
}, 5000); }, 15000);
// ─── Init ─────────────────────────────────────────────────────────────────── // ─── Init ───────────────────────────────────────────────────────────────────
loadStats(); loadStats();