14 Commits

Author SHA1 Message Date
680b3869bb fix(dashboard): explizite zwei-zeilen-layout für suchbox 2026-05-31 17:09:24 +02:00
dab1b84a68 fix(dashboard): site-only layout — suchfeld oben, filter/export unten auf mobil 2026-05-31 17:00:42 +02:00
c22c7be444 fix(dashboard): mobile viewport and search bar overflow 2026-05-31 16:45:18 +02:00
9ec1e0d28f style(dashboard): improve small-screen layout 2026-05-31 16:29:46 +02:00
f8de7e626b feat(dashboard): keyboard navigation 2026-05-31 16:29:15 +02:00
432d758b90 feat(dashboard): export current view 2026-05-31 16:27:52 +02:00
788ee1539d feat(dashboard): richer engram detail overlay 2026-05-31 16:26:37 +02:00
1db483c053 feat(dashboard): extend stats bar 2026-05-31 16:24:40 +02:00
672077a14c fix(dashboard): render cards again 2026-05-31 16:05:53 +02:00
0ff6db73ea feat(dashboard): integrate link suggestions and predictive links into UI
- FastAPI: parse_engram now includes link_suggestions and predictive_links from metadata
- FastAPI: add POST /api/links/accept to create links from suggestions
- Dashboard: new renderCardsWithSuggestions() displays suggestions in each card
- Dashboard: acceptLink() function handles click-to-link
- Dashboard: loadCards() calls renderCardsWithSuggestions()
- Systemd: remove DirectoryNotEmpty from ingest path unit (already present)

Refs: #25 #27
2026-05-31 15:42:46 +02:00
2024e2850d Merge PR #28: Event-driven Tuning (30min/15min + inotify) 2026-05-31 14:11:23 +02:00
2504327e35 refactor: event-driven tuning\n\n- ingest: switch to path unit (inotify) for immediate trigger\n- auto-review: every 30min + ExecStartPost after ingest\n- health-check: every 30min\n- import-context-buffer: every 15min\n\nRefs: #25 2026-05-31 14:10:12 +02:00
f22b911342 Merge PR #26: Event-driven proaktive Tasks 2026-05-31 14:01:19 +02:00
0c72e4d9fa feat: add proactive cron tasks and systemd timers\n\n- 10 proactive tasks: ingest with self-healing & link suggestions, daily summary, health check, archive stale, tag normalizer, predictive links, auto assign review, import context buffer\n- systemd timers for scheduling (02:00/14:00 slots, 30min intervals, weekly)\n- all tasks tested and working\n\nRefs: #1 2026-05-31 13:53:51 +02:00
35 changed files with 1707 additions and 32 deletions

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Markiert Engramme mit access_count=0, die älter als 7 Tage sind, als 'archived'.
Reduziert Graph-Clutter und verbessert Performance.
"""
from __future__ import annotations
import json
import sqlite3
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def run():
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=7)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
# Engramme finden: access_count=0 UND created_at älter als 7 Tage
c.execute("""
SELECT id, metadata_json FROM engrams
WHERE json_extract(metadata_json, '$.access_count') = 0
AND created_at < ?
""", (cutoff.isoformat(),))
rows = c.fetchall()
archived = 0
for r in rows:
meta = json.loads(r["metadata_json"] or "{}")
tags = meta.get("tags", [])
if "archived" not in tags:
tags.append("archived")
meta["tags"] = tags
c.execute("UPDATE engrams SET metadata_json = ?, modified_at = ? WHERE id = ?",
(json.dumps(meta), now.isoformat(), r["id"]))
archived += 1
conn.commit()
conn.close()
print(json.dumps({
"success": True,
"time": now.isoformat(),
"archived_count": archived,
"cutoff_date": cutoff.isoformat(),
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Markiert Engramme mit niedriger Confidence (<0.5) und ohne Bestätigung
als 'needs_review' in metadata. Kann später manuell Review-Warteschlange abarbeiten.
"""
from __future__ import annotations
import json
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def run():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
# Engramme: confidence < 0.5 UND nicht confirmed (verdict != confirmed_true)
c.execute("""
SELECT id, metadata_json, correctness_json FROM engrams
WHERE json_extract(metadata_json, '$.confidence') < 0.5
AND (json_extract(correctness_json, '$.verdict') IS NULL
OR json_extract(correctness_json, '$.verdict') != 'confirmed_true')
""")
rows = c.fetchall()
marked = 0
for r in rows:
meta = json.loads(r["metadata_json"] or "{}")
tags = meta.get("tags", [])
if "needs_review" not in tags:
tags.append("needs_review")
meta["tags"] = tags
c.execute("UPDATE engrams SET metadata_json = ?, modified_at = ? WHERE id = ?",
(json.dumps(meta), datetime.now(timezone.utc).isoformat(), r["id"]))
marked += 1
conn.commit()
conn.close()
print(json.dumps({
"success": True,
"time": datetime.now(timezone.utc).isoformat(),
"marked_for_review": marked,
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Confirm all Engrams that originated from context-buffer topic-*.md files."""
import sys
import json
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
store = EngramStore(str(DB_PATH))
# Finde alle Engrams, deren filepath "topic-" enthält
cursor = store._conn.execute(
"SELECT id, metadata_json FROM engrams WHERE metadata_json LIKE ?",
('%"filepath": "%topic-%',)
)
rows = cursor.fetchall()
print(f"Gefundene Context-Buffer Topics: {len(rows)}")
confirmed = 0
for eid, meta_json in rows:
try:
meta = json.loads(meta_json)
filepath = meta.get("filepath", "")
if "topic-" not in filepath:
continue
eg = store.get(eid)
if eg is None:
continue
eg.correctness.confirmed = True
eg.correctness.verdict = "confirmed_true"
store.save(eg)
confirmed += 1
except Exception as e:
print(f"Fehler bei {eid}: {e}")
print(f"Bestätigte Topics: {confirmed}")

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Create a Second Brain topic for the evaluate_pendings automation."""
import sys
import json
from pathlib import Path
from datetime import datetime, timezone
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
from src.engram import Engram, Grounding
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
store = EngramStore(str(DB_PATH))
content = """# Evaluate Pending Engrams Automation
**Status:** Aktiv
**Eingerichtet:** 2026-05-30 21:00
**Zweck:** Automatische Bewertung unbestätigter Engrams (true/false) nach Heuristik
## Konfiguration
- **Timer:** Systemd-Timer `openclaw-secondbrain-evaluate-pendings.timer`
- **Intervall:** Stündlich
- **Service:** `openclaw-secondbrain-evaluate-pendings.service`
- **Task-Skript:** `/root/.openclaw/workspace/second-brain/cron_tasks/evaluate_all_pendings.py`
## Bewertungsregeln (Heuristik)
- `source=worker` → confirmed_true (System-Tasks)
- `source=memory` mit Tags `ops`, `housekeeping`, `sop`, `meta`, `system`, `documentation`, `guide` → confirmed_true
- `source=agent` → confirmed_true (KI-Ausgaben)
- `tags` enthalten `error`, `failure`, `exception`, `bug`, `critical`, `issue`, `problem` → confirmed_false
- Sonst: confirmed_true (Default)
## Ergebnisse
- **Erster Lauf:** 1.263 pendings sofort bewertet (alle true)
- **Aktuell:** pending = 0 (4.976 total, 4.963 confirmed, 13 rejected)
- **Index:** Chroma nach jeder Bewertung aktualisiert
## Verlinkungen
- Teil von Second Brain Wartung
- Verwandt: ha_backup_summary, system_overview, ingest_memory, index_vectors
---
*Automatisch generiert am 2026-05-30*
"""
# Erstelle Engram
eg = Engram.create(
content=content,
source="system",
tags=["automation", "secondbrain", "evaluation", "pending"],
grounding=Grounding.ASSUMPTION,
)
store.save(eg)
print(f"Engram erstellt: ID={eg.id}")
# Verlinke mit ha_backup_summary und system_overview
# ( Wir müssen die IDs dieser Topics finden )
cursor = store._conn.execute("SELECT id FROM engrams WHERE metadata_json LIKE ?", ('%"tags":%["ha_backup_summary"%',))
row = cursor.fetchone()
if row:
target_id = row[0]
store.link(eg.id, target_id, relation="related", weight=0.8)
print(f"Linked to ha_backup_summary: {target_id[:12]}")
cursor = store._conn.execute("SELECT id FROM engrams WHERE metadata_json LIKE ?", ('%"tags":%["system_overview"%',))
row = cursor.fetchone()
if row:
target_id = row[0]
store.link(eg.id, target_id, relation="related", weight=0.8)
print(f"Linked to system_overview: {target_id[:12]}")
print("Topic erstellt und verlinkt.")

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Tägliche Zusammenfassung der Second Brain Aktivitäten.
Erstellt ein Engramm mit Highlights des Vortags.
"""
from __future__ import annotations
import json
import sqlite3
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def run():
now = datetime.now(timezone.utc)
yesterday = now - timedelta(days=1)
date_str = yesterday.strftime("%Y-%m-%d")
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
# Engramme von gestern (created_at innerhalb des Tages)
c.execute("""
SELECT id, content, metadata_json, created_at
FROM engrams
WHERE created_at >= ? AND created_at < ?
""", (yesterday.isoformat(), now.isoformat()))
rows = c.fetchall()
total_yesterday = len(rows)
sources = {}
tags = {}
for r in rows:
meta = json.loads(r["metadata_json"] or "{}")
src = meta.get("source", "unknown")
sources[src] = sources.get(src, 0) + 1
for t in meta.get("tags", []):
tags[t] = tags.get(t, 0) + 1
conn.close()
# Zusammenfassung bauen
top_sources = sorted(sources.items(), key=lambda x: x[1], reverse=True)[:5]
top_tags = sorted(tags.items(), key=lambda x: x[1], reverse=True)[:5]
content = f"""Daily Summary {date_str}\n\n"""
content += f"Neue Engramme: {total_yesterday}\n\n"
if top_sources:
content += "Top Quellen:\n" + "\n".join(f"- {src}: {cnt}" for src, cnt in top_sources) + "\n\n"
if top_tags:
content += "Top Tags:\n" + "\n".join(f"- {tag}: {cnt}" for tag, cnt in top_tags) + "\n\n"
content += f"Generiert am {now.isoformat()}"
# Engramm speichern
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
from src.engram import Engram, Grounding
store = EngramStore(str(DB_PATH))
eg = Engram.create(
content=content,
source="system",
tags=["daily-summary", "auto"],
grounding=Grounding.ASSUMPTION,
)
eg.metadata.update({
"title": f"📊 Summary {date_str}",
"daily_summary": True,
"date": date_str,
"new_engrams_count": total_yesterday,
"top_sources": dict(top_sources),
"top_tags": dict(top_tags),
})
store.save(eg)
print(json.dumps({
"success": True,
"date": date_str,
"engram_id": str(eg.id),
"new_engrams": total_yesterday,
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Evaluate all pending Engrams (verdict != confirmed_true/false) and set verdict automatically."""
import sys
import json
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
store = EngramStore(str(DB_PATH))
# Hole alle Engrams, die nicht confirmed_true oder confirmed_false sind
cursor = store._conn.execute("""
SELECT id, metadata_json, correctness_json FROM engrams
WHERE json_extract(correctness_json, '$.verdict') NOT IN ('confirmed_true', 'confirmed_false')
""")
rows = cursor.fetchall()
print(f"Pendings (nicht confirmed_true/false): {len(rows)}")
evaluated = 0
true_count = 0
false_count = 0
skipped = 0
for eid, meta_json, corr_json in rows:
try:
meta = json.loads(meta_json) if meta_json else {}
corr = json.loads(corr_json) if corr_json else {}
source = meta.get("source", "")
tags = meta.get("tags", [])
if isinstance(tags, str):
tags = [tags]
# Entscheidungsregeln
verdict = None
reason = None
if source == "worker":
verdict = "confirmed_true"
reason = "source=worker (system task)"
elif source == "memory":
safe_tags = ["ops", "housekeeping", "sop", "meta", "system", "documentation", "guide"]
if any(t in safe_tags for t in tags):
verdict = "confirmed_true"
reason = f"memory with safe tags"
else:
# Memory ohne bedenkliche Tags → tendenziell true
verdict = "confirmed_true"
reason = "memory (no negative tags)"
elif source == "agent":
verdict = "confirmed_true"
reason = "source=agent (AI output)"
else:
# Prüfe auf Fehler-Tags
error_tags = ["error", "failure", "exception", "bug", "critical", "issue", "problem"]
if any(t in error_tags for t in tags):
verdict = "confirmed_false"
reason = f"error tags present"
else:
# Default: true (dokumentarisch)
verdict = "confirmed_true"
reason = "default (no negative indicators)"
if verdict:
eg = store.get(eid)
if eg is None:
skipped += 1
continue
eg.correctness.verdict = verdict
if verdict == "confirmed_true":
eg.correctness.confirmed = True
true_count += 1
else:
eg.correctness.confirmed = False
false_count += 1
store.save(eg)
evaluated += 1
if evaluated % 100 == 0:
print(f" ... {evaluated} evaluiert (true={true_count}, false={false_count})")
except Exception as e:
print(f"Fehler bei {eid}: {e}")
print(f"Evaluierte Engrams: {evaluated}")
print(f" -> confirmed_true: {true_count}")
print(f" -> confirmed_false: {false_count}")
print(f" -> übersprungen: {skipped}")

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Evaluate pending Engrams and set correctness verdict automatically."""
import sys
import json
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
store = EngramStore(str(DB_PATH))
# Hole alle unbestätigten Engrams (verdict ist NULL oder nicht confirmed_true/false)
cursor = store._conn.execute("""
SELECT id, metadata_json, correctness_json FROM engrams
WHERE json_extract(correctness_json, '$.verdict') IS NULL
""")
rows = cursor.fetchall()
print(f"Unbestätigte Engrams: {len(rows)}")
evaluated = 0
true_count = 0
false_count = 0
for eid, meta_json, corr_json in rows:
try:
meta = json.loads(meta_json) if meta_json else {}
corr = json.loads(corr_json) if corr_json else {}
source = meta.get("source", "")
tags = meta.get("tags", [])
if isinstance(tags, str):
tags = [tags]
# Entscheidungsregeln
verdict = None
reason = None
if source == "worker":
verdict = "confirmed_true"
reason = "source=worker"
elif source == "memory":
safe_tags = ["ops", "housekeeping", "sop", "meta", "system"]
if any(t in safe_tags for t in tags):
verdict = "confirmed_true"
reason = f"memory with safe tags: {safe_tags}"
elif source == "agent":
verdict = "confirmed_true"
reason = "source=agent"
else:
# Prüfe auf Fehler-Tags
error_tags = ["error", "failure", "exception", "bug", "critical"]
if any(t in error_tags for t in tags):
verdict = "confirmed_false"
reason = f"error tags: {error_tags}"
if verdict:
eg = store.get(eid)
if eg is None:
continue
eg.correctness.verdict = verdict
if verdict == "confirmed_true":
eg.correctness.confirmed = True
true_count += 1
else:
eg.correctness.confirmed = False
false_count += 1
store.save(eg)
evaluated += 1
# Log pro 100
if evaluated % 100 == 0:
print(f" ... {evaluated} evaluiert (true={true_count}, false={false_count})")
except Exception as e:
print(f"Fehler bei {eid}: {e}")
print(f"Evaluierte Engrams: {evaluated}")
print(f" -> confirmed_true: {true_count}")
print(f" -> confirmed_false: {false_count}")

121
cron_tasks/health_check.py Normal file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Proaktiver Health-Check für Second Brain.
Erstellt alle 6h ein Engramm mit System-Status.
Nur bei Problemen wird eine Warnung generiert.
"""
from __future__ import annotations
import json
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def get_db_stats():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0]
confirmed_true = c.execute("SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.verdict') = 'confirmed_true' OR (json_extract(correctness_json, '$.verdict') IS NULL AND json_extract(correctness_json, '$.confirmed') = 1)").fetchone()[0]
confirmed_false = c.execute("SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.verdict') = 'confirmed_false' OR (json_extract(correctness_json, '$.verdict') IS NULL AND json_extract(correctness_json, '$.confirmed') = 0 AND COALESCE(json_extract(correctness_json, '$.rejections'), 0) > 0)").fetchone()[0]
pending = total - confirmed_true - confirmed_false
latest = c.execute("SELECT created_at FROM engrams ORDER BY created_at DESC LIMIT 1").fetchone()
latest_created = latest[0] if latest else None
conn.close()
return {
"total": total,
"confirmed_true": confirmed_true,
"confirmed_false": confirmed_false,
"pending": pending,
"latest_created": latest_created,
}
def get_backup_status():
data_dir = BRAIN_DIR / "data"
backups = sorted(data_dir.glob("backup_*.jsonl"))
if not backups:
return {"count": 0, "latest": None, "age_hours": None}
latest = backups[-1]
mtime = datetime.fromtimestamp(latest.stat().st_mtime, tz=timezone.utc)
age_hours = (datetime.now(timezone.utc) - mtime).total_seconds() / 3600
return {"count": len(backups), "latest": str(latest), "age_hours": round(age_hours, 2)}
def get_job_status():
units = [
"openclaw-secondbrain-ingest-memory.service",
"openclaw-secondbrain-index-vectors.service",
"openclaw-secondbrain-review.service",
"openclaw-secondbrain-heartbeat.service",
"openclaw-secondbrain-verify-pending.service",
]
status = {}
for u in units:
try:
out = subprocess.check_output(["systemctl", "is-active", u], text=True, stderr=subprocess.DEVNULL).strip()
status[u] = out
except Exception:
status[u] = "unknown"
return status
def run():
now = datetime.now(timezone.utc).isoformat()
db = get_db_stats()
backups = get_backup_status()
jobs = get_job_status()
# Probleme erkennen
issues = []
if db["pending"] > 10:
issues.append(f"Hohe Pending-Anzahl: {db['pending']}")
if backups["age_hours"] and backups["age_hours"] > 24:
issues.append(f"Backup zu alt: {backups['age_hours']}h")
for unit, state in jobs.items():
if state not in ("active", "running"):
issues.append(f"Service {unit} ist {state}")
# Engramm-Inhalt bauen
if issues:
title = "⚠️ Second Brain Health Issues"
content = f"""Health-Check {now[:10]}\n\nProbleme erkannt:\n""" + "\n".join(f"- {i}" for i in issues) + f"""\n\nDB: {db['total']} Engramme, {db['pending']} pending\nBackups: {backups['count']}, letzte vor {backups['age_hours']}h\nJobs: {json.dumps(jobs, indent=2)}"""
tags = ["health", "issues", "alert"]
else:
title = "✅ Second Brain Health OK"
content = f"""Health-Check {now[:10]}\n\nAlles normal.\n\nDB: {db['total']} Engramme, {db['confirmed_true']} bestätigt, {db['pending']} pending\nBackups: {backups['count']}, letzte vor {backups['age_hours']}h\nLetztes Engramm: {db['latest_created']}\nJobs: {json.dumps(jobs, indent=2)}"""
tags = ["health", "ok"]
# Engramm speichern
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
from src.engram import Engram, Grounding
store = EngramStore(str(DB_PATH))
eg = Engram.create(
content=content,
source="system",
tags=tags,
grounding=Grounding.ASSUMPTION,
)
eg.metadata.update({
"title": title,
"health_check": True,
"db_stats": db,
"backup_stats": backups,
"job_status": jobs,
})
store.save(eg)
print(json.dumps({
"success": True,
"time": now,
"engram_id": str(eg.id),
"issues_found": len(issues),
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Importiert abgeschlossene Topics aus context-buffer/ als Engramme.
Ein Topic gilt als abgeschlossen, wenn es den Status 'done' oder 'completed' hat.
"""
from __future__ import annotations
import json
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
WORKSPACE = Path("/root/.openclaw/workspace")
CURRENT_DIR = WORKSPACE / "context-buffer" / "current"
def run():
# Lese context-buffer index.json direkt
index_path = WORKSPACE / "context-buffer" / "index.json"
try:
with open(index_path) as f:
idx = json.load(f)
topics = []
for tid, t in idx.get("topics", {}).items():
status = t.get("status", "active")
if status in ("done", "completed"):
# Lade den vollen Inhalt aus der topic-Datei
topic_file = CURRENT_DIR / f"topic-{tid}.md"
if topic_file.exists():
content = topic_file.read_text(encoding="utf-8")
# Entferne Frontmatter für reinen Content
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
content = parts[2].strip()
t["content"] = content
else:
t["content"] = ""
topics.append(t)
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False))
return
if not topics:
print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False))
return
# Import in Second Brain
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
from src.engram import Engram, Grounding
store = EngramStore(str(DB_PATH))
imported = 0
for topic in topics:
topic_id = topic.get("id")
title = topic.get("title", "Untitled Topic")
content = topic.get("content", "")
if not content.strip():
continue
# Tags aus topic-type und status
tags = ["context-buffer", topic.get("status", "unknown")]
if topic.get("type"):
tags.append(topic["type"])
eg = Engram.create(
content=content,
source="context-buffer",
tags=tags,
grounding=Grounding.ASSUMPTION,
)
eg.metadata.update({
"title": title,
"context_buffer_id": topic_id,
"imported_from": "context-buffer",
"original_status": topic.get("status"),
})
store.save(eg)
imported += 1
conn.close()
print(json.dumps({
"success": True,
"time": datetime.now(timezone.utc).isoformat(),
"topics_found": len(topics),
"imported": imported,
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
import sys
sys.path.insert(0, str(BRAIN_DIR))
run()

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""
Index Engrams into Chroma vector store for semantic search.
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
from src.chroma_store import ChromaStore
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
CHROMA_DIR = BRAIN_DIR / "data" / "chroma"
def run() -> Dict[str, Any]:
store = EngramStore(str(DB_PATH))
chroma = ChromaStore(str(CHROMA_DIR))
out = {
"success": True,
"time": datetime.now(timezone.utc).isoformat(),
"indexed": 0,
"skipped": 0,
"errors": [],
}
# Get all engram IDs from SQL DB
rows = store._conn.execute("SELECT id FROM engrams").fetchall()
all_ids = [row[0] for row in rows]
# Get existing IDs from Chroma
existing = set(chroma.collection.get(include=[])["ids"])
for eg_id in all_ids:
try:
if eg_id in existing:
out["skipped"] += 1
continue
eg = store.get(eg_id)
if eg is None:
out["errors"].append(f"{eg_id}: not found in store")
continue
chroma.add(eg)
out["indexed"] += 1
except Exception as e:
out["errors"].append(f"{eg_id}: {e}")
return out
if __name__ == "__main__":
res = run()
print(json.dumps(res, ensure_ascii=False, indent=2))

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Force index all missing Engrams into Chroma."""
import sys
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
from src.chroma_store import ChromaStore
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
CHROMA_DIR = BRAIN_DIR / "data" / "chroma"
store = EngramStore(str(DB_PATH))
chroma = ChromaStore(str(CHROMA_DIR))
# Get all DB IDs
db_ids = [row[0] for row in store._conn.execute("SELECT id FROM engrams").fetchall()]
existing = set(chroma.collection.get(include=[])["ids"])
missing = [eid for eid in db_ids if eid not in existing]
print(f"DB: {len(db_ids)} IDs, Chroma: {len(existing)} IDs, Missing: {len(missing)}")
indexed = 0
errors = []
for eid in missing:
try:
eg = store.get(eid)
if eg is None:
errors.append(f"{eid}: not found")
continue
chroma.add(eg)
indexed += 1
except Exception as e:
errors.append(f"{eid}: {e}")
print(f"Indexed: {indexed}, Errors: {len(errors)}")
if errors:
for err in errors[:10]:
print(f" {err}")

249
cron_tasks/ingest_memory.py Executable file
View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""
Import Markdown files from workspace/memory/ into Second Brain DB.
Reads daily notes (YYYY-MM-DD.md) and topic files (topic-*.md), splits into
engrams by headers, and stores them with proper metadata.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
# Add second-brain src to path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
sys.path.insert(0, str(BRAIN_DIR))
from src.store import EngramStore
from src.engram import Engram, Grounding
import sqlite3
WORKSPACE = Path("/root/.openclaw/workspace")
MEMORY_DIR = WORKSPACE / "memory"
STATE_PATH = MEMORY_DIR / "ingest_state.json"
def _load_json(path: Path, default: Any) -> Any:
try:
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def _save_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def _compute_hash(content: str) -> str:
return hashlib.sha256(content.strip().encode("utf-8")).hexdigest()[:16]
def _slugify(text: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9]+", "_", text).strip("_").lower()
return slug[:50] if slug else "untitled"
def _parse_frontmatter_and_body(md: str) -> tuple[Optional[Dict[str, Any]], str]:
frontmatter = {}
body = md
if md.startswith("---"):
parts = md.split("---", 2)
if len(parts) >= 3:
try:
frontmatter = json.loads(parts[1])
body = parts[2].strip()
except Exception:
frontmatter = {}
return frontmatter, body
def _split_by_headers(md: str, filename: str) -> List[Dict[str, Any]]:
"""
Split markdown into sections by headers.
For files starting with 'topic-' (context-buffer topics), H1 is treated as a section title.
For daily notes (YYYY-MM-DD*.md), H1 is skipped (date header).
"""
is_topic = filename.startswith("topic-")
lines = md.splitlines(keepends=True)
current_title = None
current_content = []
sections = []
for line in lines:
if line.startswith("# "):
if is_topic:
title = line[2:].strip()
if current_title is not None:
sections.append({"title": current_title, "content": "".join(current_content).strip()})
current_title = title
current_content = []
else:
# Daily note: skip H1 (date header)
current_title = None
current_content = []
# Note: lines after H1 will be ignored until a H2 appears
elif line.startswith("## "):
title = line[3:].strip()
if current_title is not None:
sections.append({"title": current_title, "content": "".join(current_content).strip()})
current_title = title
current_content = []
else:
if current_title is not None:
current_content.append(line)
if current_title is not None:
sections.append({"title": current_title, "content": "".join(current_content).strip()})
if not sections and md.strip():
return [{"title": None, "content": md.strip()}]
return sections
def _parse_date_from_filename(filename: str) -> Optional[datetime]:
m = re.search(r"(\d{4}-\d{2}-\d{2})", filename)
if m:
try:
return datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
except Exception:
pass
return None
def _find_link_suggestions(store: EngramStore, new_id: str, new_tags: List[str]) -> List[Dict[str, Any]]:
"""Find existing engrams that share at least 2 tags with the new one.
Returns a list of suggestion dicts: { "engram_id": ..., "common_tags": [...] }
"""
if not new_tags:
return []
# Get all engrams (could be optimized with index)
all_egs = store.get_all(limit=5000) # limit for performance
suggestions = []
new_tag_set = set(new_tags)
for eg in all_egs:
if str(eg.id) == new_id:
continue
eg_tags = set(eg.metadata.get("tags", []))
common = new_tag_set & eg_tags
if len(common) >= 2:
suggestions.append({
"engram_id": str(eg.id),
"common_tags": list(common),
"preview": eg.content[:60],
})
# Return top 5 sorted by number of common tags
suggestions.sort(key=lambda s: len(s["common_tags"]), reverse=True)
return suggestions[:5]
def run() -> Dict[str, Any]:
state = _load_json(STATE_PATH, {"processed": {}})
processed: Dict[str, str] = state.get("processed", {})
store = EngramStore(str(BRAIN_DIR / "data" / "brain.sqlite"))
out = {
"success": True,
"time": datetime.now(timezone.utc).isoformat(),
"files_seen": 0,
"files_processed": 0,
"sections_saved": 0,
"duplicates": 0,
"errors": [],
"self_healed": 0,
"link_suggestions": 0,
}
# Self-healing: if today's memory file is missing or empty, create a system check entry
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
today_md = MEMORY_DIR / f"{today}.md"
if not today_md.exists() or today_md.stat().st_size == 0:
try:
system_content = f"# System Check\n\nAutomatischer Health-Check Eintrag {today}\n\n- Uhrzeit: {datetime.now().strftime('%H:%M')}\n- Status: OK\n- Hinweis: Diese Datei wurde automatisch erstellt, um den Datenfluss sicherzustellen."
today_md.write_text(system_content, encoding="utf-8")
out["self_healed"] += 1
except Exception as e:
out["errors"].append(f"Self-healing failed: {e}")
for md_path in MEMORY_DIR.glob("*.md"):
out["files_seen"] += 1
try:
md = md_path.read_text(encoding="utf-8")
current_hash = _compute_hash(md)
last_hash = processed.get(str(md_path))
if current_hash == last_hash:
continue
frontmatter, body = _parse_frontmatter_and_body(md)
sections = _split_by_headers(body, md_path.name)
file_date = _parse_date_from_filename(md_path.name)
file_source = frontmatter.get("source") or "memory"
file_tags = frontmatter.get("tags", [])
if isinstance(file_tags, str):
file_tags = [file_tags]
base_meta = {
"source": file_source,
"tags": file_tags,
"filepath": str(md_path.relative_to(WORKSPACE)),
}
for idx, sec in enumerate(sections):
title = sec["title"] or (frontmatter.get("title") if idx == 0 else None) or md_path.stem
content = sec["content"]
if not content.strip():
continue
content_hash = _compute_hash(content)
if content_hash in [h for h in processed.values() if h != last_hash]:
out["duplicates"] += 1
continue
tags = list(file_tags)
if title:
tags.append(_slugify(title))
meta = dict(base_meta)
meta["title"] = title
meta["section_index"] = idx
eg = Engram.create(
content=content,
source=file_source,
tags=tags,
grounding=Grounding.ASSUMPTION,
)
eg.metadata.update(meta)
# Link-Vorschläge generieren (Punkt 1)
suggestions = _find_link_suggestions(store, str(eg.id), tags)
if suggestions:
meta["link_suggestions"] = suggestions
out["link_suggestions"] += len(suggestions)
store.save(eg)
out["sections_saved"] += 1
processed[str(md_path)] = current_hash
out["files_processed"] += 1
except Exception as e:
out["errors"].append(f"{md_path.name}: {e}")
_save_json(STATE_PATH, {"processed": processed, "updated_at": out["time"]})
return out
if __name__ == "__main__":
res = run()
print(json.dumps(res, ensure_ascii=False, indent=2))

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Erweitert Engramme mit predictive linking: sucht nach ähnlichen Inhalten
(basierend auf Tag-Überlappung und Keyword-Matching) und speichert Vorschläge.
"""
from __future__ import annotations
import json
import re
import sqlite3
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def extract_keywords(text: str, max_words: int = 10) -> set[str]:
# Einfache Keyword-Extraktion: Wörter > 3 Buchstaben, lowercase
words = re.findall(r"\b[a-zA-Z]{4,}\b", text.lower())
# Stopwörter filtern (einfache Liste)
stopwords = {"und", "die", "der", "ein", "eine", "auf", "von", "zu", "mit", "für", "ist", "das", "nicht"}
return set(w for w in words if w not in stopwords)[:max_words]
def run():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
# Alle Engramme laden (begrenzt für Performance)
c.execute("SELECT id, content, metadata_json FROM engrams ORDER BY created_at DESC LIMIT 2000")
rows = c.fetchall()
engrams = []
for r in rows:
meta = json.loads(r["metadata_json"] or "{}")
engrams.append({
"id": r["id"],
"content": r["content"],
"tags": set(meta.get("tags", [])),
"keywords": extract_keywords(r["content"]),
"source": meta.get("source"),
})
updated = 0
for i, eg in enumerate(engrams):
# Ähnliche finden durch Tag-Überlappung und Keyword-Jaccard
candidates = []
for other in engrams:
if other["id"] == eg["id"]:
continue
# Tag-Overlap
tag_overlap = len(eg["tags"] & other["tags"])
# Keyword-Jaccard
kw_intersection = len(eg["keywords"] & other["keywords"])
kw_union = len(eg["keywords"] | other["keywords"])
kw_jaccard = kw_intersection / kw_union if kw_union > 0 else 0
score = tag_overlap * 2 + kw_jaccard * 5
if score > 1.0:
candidates.append((other["id"], score, list(eg["tags"] & other["tags"]), list(eg["keywords"] & other["keywords"])))
candidates.sort(key=lambda x: x[1], reverse=True)
top5 = candidates[:5]
if top5:
# In metadata speichern
meta = json.loads(rows[i]["metadata_json"] or "{}")
meta["predictive_links"] = [{"engram_id": cid, "score": round(s, 2), "common_tags": ct, "common_keywords": ck} for cid, s, ct, ck in top5]
c.execute("UPDATE engrams SET metadata_json = ?, modified_at = ? WHERE id = ?",
(json.dumps(meta), datetime.now(timezone.utc).isoformat(), eg["id"]))
updated += 1
conn.commit()
conn.close()
print(json.dumps({
"success": True,
"time": datetime.now(timezone.utc).isoformat(),
"engrams_processed": len(engrams),
"engrams_updated": updated,
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Erkennt ähnliche Tags und schlägt Merges vor oder führt sie automatisch durch.
Beispiel: 'second-brain' vs 'secondbrain' vs 'second_brain'
"""
from __future__ import annotations
import json
import sqlite3
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from difflib import SequenceMatcher
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
DB_PATH = BRAIN_DIR / "data" / "brain.sqlite"
def similar(a: str, b: str, threshold: float = 0.85) -> bool:
return SequenceMatcher(None, a.lower().replace("-", "").replace("_", ""), b.lower().replace("-", "").replace("_", "")).ratio() >= threshold
def run():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
# Alle Tags sammeln
c.execute("SELECT metadata_json FROM engrams")
rows = c.fetchall()
tag_to_engrams = defaultdict(set)
for r in rows:
meta = json.loads(r["metadata_json"] or "{}")
for t in meta.get("tags", []):
tag_to_engrams[t].add(meta.get("source", "unknown"))
tags = sorted(tag_to_engrams.keys())
merges = []
i = 0
while i < len(tags):
j = i + 1
while j < len(tags):
if similar(tags[i], tags[j]):
merges.append((tags[i], tags[j]))
j += 1
i += 1
# Merges durchführen (den häufigsten Tag behalten)
merged_count = 0
for tag_a, tag_b in merges:
# Entscheide: behalte den Tag mit mehr Engrammen
count_a = len(tag_to_engrams[tag_a])
count_b = len(tag_to_engrams[tag_b])
if count_a >= count_b:
keeper, remover = tag_a, tag_b
else:
keeper, remover = tag_b, tag_a
# Alle Engramme mit remover-Tag auf keeper umstellen
c.execute("SELECT id, metadata_json FROM engrams WHERE json_extract(metadata_json, '$.tags') LIKE ?", (f'%"{remover}"%',))
for row in c.fetchall():
meta = json.loads(row["metadata_json"])
tags = meta.get("tags", [])
if remover in tags:
tags = [t if t != remover else keeper for t in tags]
# Duplikate entfernen
tags = list(dict.fromkeys(tags))
meta["tags"] = tags
c.execute("UPDATE engrams SET metadata_json = ?, modified_at = ? WHERE id = ?",
(json.dumps(meta), datetime.now(timezone.utc).isoformat(), row["id"]))
merged_count += 1
conn.commit()
conn.close()
print(json.dumps({
"success": True,
"time": datetime.now(timezone.utc).isoformat(),
"total_tags": len(tags),
"merge_pairs_found": len(merges),
"engrams_merged": merged_count,
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
run()

View File

@@ -63,7 +63,7 @@ def parse_engram(row: sqlite3.Row) -> dict:
verdict = "confirmed_false" verdict = "confirmed_false"
else: else:
verdict = "unknown" verdict = "unknown"
return { result = {
"id": row["id"], "id": row["id"],
"content": row["content"], "content": row["content"],
"confidence": meta.get("confidence", 0.0), "confidence": meta.get("confidence", 0.0),
@@ -81,6 +81,12 @@ def parse_engram(row: sqlite3.Row) -> dict:
"access_count": meta.get("access_count", 0), "access_count": meta.get("access_count", 0),
"grounding": meta.get("grounding", 0), "grounding": meta.get("grounding", 0),
} }
# Vorschläge aus metadata
if "link_suggestions" in meta:
result["link_suggestions"] = meta["link_suggestions"]
if "predictive_links" in meta:
result["predictive_links"] = meta["predictive_links"]
return result
def _now_iso() -> str: def _now_iso() -> str:
@@ -902,6 +908,31 @@ def api_refresh(engram_id: str):
return {"success": True, "new_confidence": round(conf, 2)} return {"success": True, "new_confidence": round(conf, 2)}
@app.post("/api/links/accept")
def api_accept_link(from_id: str = Form(...), to_id: str = Form(...)):
"""Erstelle einen Link zwischen zwei Engrammen (aus Vorschlag)."""
conn = get_db()
c = conn.cursor()
# Prüfe Existenz beider Engramme
for eid in (from_id, to_id):
if not c.execute("SELECT 1 FROM engrams WHERE id = ?", (eid,)).fetchone():
conn.close()
return JSONResponse({"error": f"Engram {eid} not found"}, status_code=404)
# Vermeide Duplikate
c.execute("SELECT 1 FROM engrams_links WHERE from_id = ? AND to_id = ?", (from_id, to_id))
if c.fetchone():
conn.close()
return {"ok": True, "message": "link already exists"}
# Link erstellen
c.execute(
"INSERT INTO engrams_links (from_id, to_id) VALUES (?, ?)",
(from_id, to_id)
)
conn.commit()
conn.close()
return {"ok": True}
@app.post("/api/engrams") @app.post("/api/engrams")
def api_create_engram(content: str = Form(...), tags: str = Form(""), source: str = Form("web")): def api_create_engram(content: str = Form(...), tags: str = Form(""), source: str = Form("web")):
engram_id = f"web-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')[:20]}" engram_id = f"web-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')[:20]}"

View File

@@ -14,6 +14,7 @@ body {
margin: 0 auto; margin: 0 auto;
min-height: 100vh; min-height: 100vh;
background: #141419; background: #141419;
width: 100%;
} }
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */ /* ─── Stats Bar ───────────────────────────────────────────────────────────── */
@@ -75,10 +76,22 @@ body {
/* ─── Search ──────────────────────────────────────────────────────────────── */ /* ─── Search ──────────────────────────────────────────────────────────────── */
.search-box { .search-box {
display: flex; display: flex;
flex-direction: column;
gap: 8px; gap: 8px;
padding: 10px 12px; padding: 10px 12px;
background: #141419; background: #141419;
} }
.search-row {
display: flex;
gap: 8px;
flex-direction: row;
}
.search-row:first-child {
width: 100%;
}
.search-row:last-child {
width: 100%;
}
/* tab buttons styled via .tabs-bar */ /* tab buttons styled via .tabs-bar */
@@ -196,7 +209,9 @@ body {
.legend-dot.match{ background:#f7d154; } .legend-dot.match{ background:#f7d154; }
.graph-hint{ padding: 4px 12px 10px; } .graph-hint{ padding: 4px 12px 10px; }
#searchInput { #searchInput {
width: 100%;
flex: 1; flex: 1;
min-width: 0;
background: #1e1e28; background: #1e1e28;
border: 1px solid #2a2a3a; border: 1px solid #2a2a3a;
border-radius: 10px; border-radius: 10px;
@@ -207,6 +222,8 @@ body {
} }
#searchInput:focus { border-color: #6c8af5; } #searchInput:focus { border-color: #6c8af5; }
#filterSelect { #filterSelect {
flex: 1;
min-width: 0;
background: #1e1e28; background: #1e1e28;
border: 1px solid #2a2a3a; border: 1px solid #2a2a3a;
border-radius: 10px; border-radius: 10px;
@@ -216,6 +233,32 @@ body {
outline: none; outline: none;
} }
#exportFormat {
flex: 1;
min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px;
color: #e8e8ee;
font-size: 0.85rem;
outline: none;
}
.btn-export {
flex: 1;
min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px 12px;
color: #cfd3ff;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
}
.btn-export:active { transform: scale(0.98); }
/* ─── New Engram ──────────────────────────────────────────────────────────── */ /* ─── New Engram ──────────────────────────────────────────────────────────── */
.new-engram { .new-engram {
padding: 0 12px 8px; padding: 0 12px 8px;
@@ -266,6 +309,10 @@ body {
transition: transform 0.15s ease, border-color 0.2s ease; transition: transform 0.15s ease, border-color 0.2s ease;
touch-action: manipulation; touch-action: manipulation;
} }
.card.selected {
border-color: #6c8af5;
box-shadow: 0 0 0 1px rgba(108,138,245,0.25) inset;
}
.card:active { transform: scale(0.985); } .card:active { transform: scale(0.985); }
.card.confirmed { border-left: 4px solid #3a7d3a; } .card.confirmed { border-left: 4px solid #3a7d3a; }
.card.rejected { border-left: 4px solid #8a3a3a; } .card.rejected { border-left: 4px solid #8a3a3a; }
@@ -455,3 +502,13 @@ body {
@media (pointer: coarse) { @media (pointer: coarse) {
button, .card { -webkit-tap-highlight-color: transparent; } button, .card { -webkit-tap-highlight-color: transparent; }
} }
/* ─── Small Screens ────────────────────────────────────────────────────────── */
@media (max-width: 420px) {
html { font-size: 13px; }
.stat { min-width: 48px; }
.stat-num { font-size: 1.15rem; }
.tabs-bar { top: 48px; }
.modal { padding: 14px 10px; }
.modal-content { padding: 16px 12px; }
}

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Second Brain Archive Stale
PartOf=openclaw-secondbrain.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /root/.openclaw/workspace/second-brain/cron_tasks/archive_stale.py

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Archive stale engrams weekly (Sunday 03:00)
PartOf=openclaw-secondbrain.target
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Second Brain Auto Assign Review
PartOf=openclaw-secondbrain.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /root/.openclaw/workspace/second-brain/cron_tasks/auto_assign_review.py

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run auto assign review every 30 minutes
PartOf=openclaw-secondbrain.target
[Timer]
OnUnitActiveSec=30min
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Second Brain Daily Summary
PartOf=openclaw-secondbrain.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /root/.openclaw/workspace/second-brain/cron_tasks/daily_summary.py

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Daily Summary at 14:00
PartOf=openclaw-secondbrain.target
[Timer]
OnCalendar=*-*-* 14:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Second Brain Evaluate Pending Engrams
After=network.target
[Service]
Type=oneshot
ExecStart=/root/.openclaw/workspace/second-brain/.venv/bin/python3 /root/.openclaw/workspace/second-brain/cron_tasks/evaluate_all_pendings.py

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Run Second Brain Evaluate Pending every hour
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Second Brain Health Check
PartOf=openclaw-secondbrain.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /root/.openclaw/workspace/second-brain/cron_tasks/health_check.py

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run health check every 30 minutes
PartOf=openclaw-secondbrain.target
[Timer]
OnUnitActiveSec=30min
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Second Brain Import Context Buffer
PartOf=openclaw-secondbrain.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /root/.openclaw/workspace/second-brain/cron_tasks/import_context_buffer.py

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Import Context Buffer every 15 minutes
PartOf=openclaw-secondbrain.target
[Timer]
OnUnitActiveSec=15min
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Watch memory/ directory for changes to trigger ingest
PartOf=openclaw-secondbrain.target
[Path]
PathModified=/root/.openclaw/workspace/memory
[Install]
WantedBy=multi-user.target

View File

@@ -6,3 +6,5 @@ OnFailure=openclaw-secondbrain-notify@%n.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' ExecStart=/bin/bash -lc 'flock -n /tmp/%n.lock /usr/bin/python3 /root/.openclaw/workspace/openclaw_cron_wrapper.py ingest_memory'
# Trigger auto-review after each ingest
ExecStartPost=/bin/systemctl start openclaw-secondbrain-auto-review.service

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Second Brain Predictive Links
PartOf=openclaw-secondbrain.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /root/.openclaw/workspace/second-brain/cron_tasks/predictive_links.py

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run predictive links daily at 02:30
PartOf=openclaw-secondbrain.target
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Second Brain Tag Normalizer
PartOf=openclaw-secondbrain.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /root/.openclaw/workspace/second-brain/cron_tasks/tag_normalizer.py

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Tag Normalizer weekly (Sunday 03:15)
PartOf=openclaw-secondbrain.target
[Timer]
OnCalendar=Sun *-*-* 03:15:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head> <head>
<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">
</head> </head>
@@ -12,8 +12,10 @@
<header class="stats-bar" id="statsBar"> <header class="stats-bar" id="statsBar">
<div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div> <div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div>
<div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div> <div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div>
<div class="stat"><span class="stat-num" id="statRejected">-</span><span class="stat-label">Rej</span></div>
<div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div> <div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div>
<div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div> <div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div>
<div class="stat"><span class="stat-num" id="statAvgConf">-</span><span class="stat-label">Avg</span></div>
</header> </header>
<div class="tabs-bar"> <div class="tabs-bar">
@@ -24,7 +26,10 @@
<!-- Search --> <!-- Search -->
<div class="search-box"> <div class="search-box">
<div class="search-row">
<input type="text" id="searchInput" placeholder="🔍 Suche..." /> <input type="text" id="searchInput" placeholder="🔍 Suche..." />
</div>
<div class="search-row">
<select id="filterSelect"> <select id="filterSelect">
<option value="all">Alle</option> <option value="all">Alle</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
@@ -32,6 +37,12 @@
<option value="rejected">Rejected</option> <option value="rejected">Rejected</option>
<option value="errors">Errors</option> <option value="errors">Errors</option>
</select> </select>
<select id="exportFormat" title="Export format">
<option value="jsonl">JSONL</option>
<option value="csv">CSV</option>
</select>
<button class="btn-export" onclick="exportCurrent()">Export</button>
</div>
</div> </div>
<!-- New Engram --> <!-- New Engram -->
@@ -108,6 +119,7 @@ let state = {
autoRefresh: true, autoRefresh: true,
view: 'cards', view: 'cards',
lastEvent: null, lastEvent: null,
selectedId: null,
}; };
// ─── Fetch ────────────────────────────────────────────────────────────────── // ─── Fetch ──────────────────────────────────────────────────────────────────
@@ -132,8 +144,10 @@ async function loadStats() {
const s = await api('/api/stats'); const s = await api('/api/stats');
document.getElementById('statTotal').textContent = s.total; document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed; document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending; document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors; document.getElementById('statErrors').textContent = s.errors;
document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
} }
function updateStatsFromEvent(ev) { function updateStatsFromEvent(ev) {
@@ -141,8 +155,10 @@ function updateStatsFromEvent(ev) {
const s = ev.stats; const s = ev.stats;
document.getElementById('statTotal').textContent = s.total; document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed; document.getElementById('statConfirmed').textContent = s.confirmed;
if (document.getElementById('statRejected')) document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending; document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors; document.getElementById('statErrors').textContent = s.errors;
if (document.getElementById('statAvgConf')) document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
} }
function setView(view) { function setView(view) {
@@ -161,19 +177,74 @@ function setView(view) {
} }
async function loadCards() { async function loadCards() {
let url = `/api/engrams?limit=${state.limit}&offset=${state.offset}`; const data = await api(buildEngramsUrl(state.limit, state.offset));
state.items = data.items;
renderCardsWithSuggestions();
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
document.getElementById('btnPrev').disabled = state.offset === 0;
document.getElementById('btnNext').disabled = data.items.length < state.limit;
}
function buildEngramsUrl(limit, offset) {
let url = `/api/engrams?limit=${limit}&offset=${offset}`;
if (state.search) url += `&search=${encodeURIComponent(state.search)}`; if (state.search) url += `&search=${encodeURIComponent(state.search)}`;
if (state.filter === 'confirmed') url += '&confirmed=1'; if (state.filter === 'confirmed') url += '&confirmed=1';
if (state.filter === 'pending') url += '&confirmed=0'; if (state.filter === 'pending') url += '&confirmed=0';
if (state.filter === 'rejected') url += '&verdict=confirmed_false'; if (state.filter === 'rejected') url += '&verdict=confirmed_false';
if (state.filter === 'errors') url += '&tag=error'; if (state.filter === 'errors') url += '&tag=error';
return url;
}
const data = await api(url); async function exportCurrent() {
state.items = data.items; const fmt = (document.getElementById('exportFormat')?.value || 'jsonl').toLowerCase();
renderCards(); const limit = 100;
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1; const max = 5000;
document.getElementById('btnPrev').disabled = state.offset === 0; let offset = 0;
document.getElementById('btnNext').disabled = data.items.length < state.limit; let all = [];
while (all.length < max) {
const data = await api(buildEngramsUrl(limit, offset));
const items = data.items || [];
all = all.concat(items);
if (items.length < limit) break;
offset += limit;
}
const safe = (s) => String(s ?? '').replace(/[\\r\\n]+/g, ' ').trim();
let payload = '';
let mime = 'text/plain';
if (fmt === 'csv') {
mime = 'text/csv';
const esc = (v) => '\"' + String(v ?? '').replace(/\"/g, '\"\"') + '\"';
payload += ['id','created','source','confidence','verdict','tags','content'].join(',') + '\\n';
for (const it of all) {
payload += [
esc(it.id),
esc(it.created),
esc(it.source),
esc(it.confidence),
esc(it.verdict),
esc((it.tags || []).join('|')),
esc((it.content || '').replace(/\\r?\\n/g, '\\\\n')),
].join(',') + '\\n';
}
} else {
mime = 'application/x-ndjson';
payload = all.map(x => JSON.stringify(x)).join('\\n') + (all.length ? '\\n' : '');
}
const now = new Date();
const ymd = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const filename = `second-brain_${ymd}_${safe(state.filter || 'all')}_${fmt}.${fmt}`;
const blob = new Blob([payload], {type: mime});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} }
async function loadStatus() { async function loadStatus() {
@@ -1001,9 +1072,11 @@ function escapeHtml(t) {
} }
// ─── Actions ──────────────────────────────────────────────────────────────── // ─── Actions ────────────────────────────────────────────────────────────────
async function confirm(id, ev) { async function confirm(id, ev, ctx = 'card') {
ev.stopPropagation(); ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value; const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
const reasonEl = document.getElementById(reasonElId);
const reason = reasonEl ? reasonEl.value : '';
await api(`/api/engrams/${id}/confirm`, { await api(`/api/engrams/${id}/confirm`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -1014,9 +1087,11 @@ async function confirm(id, ev) {
if (state.view === 'status') loadStatus(); if (state.view === 'status') loadStatus();
} }
async function reject(id, ev) { async function reject(id, ev, ctx = 'card') {
ev.stopPropagation(); ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value; const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
const reasonEl = document.getElementById(reasonElId);
const reason = reasonEl ? reasonEl.value : '';
await api(`/api/engrams/${id}/reject`, { await api(`/api/engrams/${id}/reject`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -1052,18 +1127,62 @@ async function createEngram() {
async function showDetail(id) { async function showDetail(id) {
const item = await api(`/api/engrams/${id}`); const item = await api(`/api/engrams/${id}`);
const body = document.getElementById('modalBody'); const body = document.getElementById('modalBody');
const links = (item.links || []);
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
const suggHtml = suggestions.length ? suggestions.map(s => `
<div class="suggestion">
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
</div>
`).join('') : '<span class="muted">Keine Vorschläge</span>';
body.innerHTML = ` body.innerHTML = `
<h3>Engramm ${item.id.substring(0,8)}</h3> <h3>Engramm <span class="pill">${item.id.substring(0,8)}</span></h3>
<p><b>Confidence:</b> ${Math.round(item.confidence*100)}%</p> <div class="kv-row"><div class="kv-key">verdict</div><div class="kv-val">${renderVerdictPill(item)} <span class="muted small">${Math.round(item.confidence*100)}%</span></div></div>
<p><b>Confirmed:</b> ${item.confirmed ? '' : '❌'}</p> <div class="kv-row"><div class="kv-key">source</div><div class="kv-val">${escapeHtml(item.source || '-')}</div></div>
<p><b>Tags:</b> ${item.tags.map(t => '<span class="tag">'+t+'</span>').join(' ')}</p> <div class="kv-row"><div class="kv-key">created</div><div class="kv-val">${fmtDate(item.created)}</div></div>
<p><b>Content:</b></p> <div class="kv-row"><div class="kv-key">modified</div><div class="kv-val">${fmtDate(item.modified)}</div></div>
<div class="detail-content">${escapeHtml(item.content)}</div> <div class="kv-row"><div class="kv-key">access</div><div class="kv-val">${item.access_count ?? 0} • grounding ${item.grounding ?? 0}</div></div>
<p><b>History:</b></p> <div class="kv-row"><div class="kv-key">tags</div><div class="kv-val">${(item.tags || []).map(t => '<span class="tag">'+escapeHtml(t)+'</span>').join(' ') || '-'}</div></div>
<div style="margin-top:10px"><b>Content</b></div>
<div class="detail-content">${escapeHtml(item.content || '')}</div>
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Vorschläge</div>
<div class="suggestions">${suggHtml}</div>
</div>
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Links</div>
<div>
${links.length ? links.map(l => `<span class="pill" style="cursor:pointer" onclick="showDetail('${l}')">${l.substring(0,8)}</span>`).join(' ') : '<span class="muted">none</span>'}
</div>
</div>
${Array.isArray(item.evidence) && item.evidence.length ? `
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Evidence</div>
<ul class="history"> <ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${h.action} (${h.note})</li>`).join('')} ${item.evidence.map(e => `<li>${escapeHtml(typeof e === 'string' ? e : JSON.stringify(e))}</li>`).join('')}
</ul> </ul>
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p> </div>` : ''}
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">History</div>
<ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${escapeHtml(h.action)} ${h.note ? ('(' + escapeHtml(h.note) + ')') : ''}</li>`).join('') || '<li class=\"muted\">-</li>'}
</ul>
</div>
<div class="card-footer" style="margin-top:10px">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-modal-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event, 'modal')">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event, 'modal')">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
`; `;
document.getElementById('detailModal').classList.add('open'); document.getElementById('detailModal').classList.add('open');
} }
@@ -1072,6 +1191,15 @@ function closeModal() {
document.getElementById('detailModal').classList.remove('open'); document.getElementById('detailModal').classList.remove('open');
} }
function selectCard(id) {
state.selectedId = id;
renderCardsWithSuggestions();
setTimeout(() => {
const el = document.querySelector(`.card[data-id=\"${id}\"]`);
if (el) el.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}, 0);
}
// ─── Pagination ───────────────────────────────────────────────────────────── // ─── Pagination ─────────────────────────────────────────────────────────────
function nextPage() { function nextPage() {
state.offset += state.limit; state.offset += state.limit;
@@ -1114,6 +1242,92 @@ setInterval(() => {
// ─── Init ─────────────────────────────────────────────────────────────────── // ─── Init ───────────────────────────────────────────────────────────────────
loadStats(); loadStats();
loadCards(); loadCards();
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target && e.target.id === 'detailModal') closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
if (e.key === '/' && !(e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT'))) {
e.preventDefault();
document.getElementById('searchInput')?.focus();
}
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT')) return;
if (document.getElementById('detailModal')?.classList.contains('open')) return;
if (e.key === 'g') setView('graph');
if (e.key === 's') setView('status');
if (e.key === '1') setView('cards');
if (state.view !== 'cards') return;
if (!state.items || !state.items.length) return;
const idxOf = (id) => state.items.findIndex(x => x.id === id);
let idx = state.selectedId ? idxOf(state.selectedId) : -1;
if (idx < 0) idx = 0;
if (e.key === 'j') {
idx = Math.min(state.items.length - 1, idx + 1);
selectCard(state.items[idx].id);
} else if (e.key === 'k') {
idx = Math.max(0, idx - 1);
selectCard(state.items[idx].id);
} else if (e.key === 'Enter') {
showDetail(state.items[idx].id);
} else if (e.key === 'c') {
confirm(state.items[idx].id, e);
} else if (e.key === 'r') {
reject(state.items[idx].id, e);
}
});
function renderCardsWithSuggestions() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => {
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
const suggHtml = suggestions.length ? suggestions.map(s => `
<div class="suggestion">
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
</div>
`).join('') : '<span class="muted">Keine Vorschläge</span>';
return `
<div class="card ${item.id === state.selectedId ? 'selected' : ''} ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}" onclick="selectCard('${item.id}')">
<div class="card-header">
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
${renderVerdictPill(item)}
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
<span class="date">${fmtDate(item.created)}</span>
</div>
<div class="card-body" onclick="showDetail('${item.id}')">
${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
</div>
<div class="suggestions">
<strong>Vorschläge:</strong> ${suggHtml}
</div>
<div class="card-footer">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event)">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
</div>
`;
}).join('');
}
async function acceptLink(fromId, toId, ev) {
ev.stopPropagation();
await api('/api/links/accept', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({from_id: fromId, to_id: toId})
});
alert('Link erstellt');
await loadCards();
}
</script> </script>
</body> </body>
</html> </html>