5 Commits

10 changed files with 5970 additions and 90 deletions

View File

@@ -4,6 +4,11 @@ This is the operational quick-reference for the shipped systemd timers/services
Repository root (on host): `/root/.openclaw/workspace/second-brain` Repository root (on host): `/root/.openclaw/workspace/second-brain`
Integration + orchestration docs live in the workspace repo:
- `/root/.openclaw/workspace/docs/SECOND_BRAIN.md`
- `/root/.openclaw/workspace/docs/GITEA.md`
## Systemd units (cron jobs) ## Systemd units (cron jobs)
Unit files are shipped in `systemd/` (this repo). Install them into `/etc/systemd/system/` (symlink or copy), then reload: Unit files are shipped in `systemd/` (this repo). Install them into `/etc/systemd/system/` (symlink or copy), then reload:

View File

@@ -390,13 +390,19 @@ def api_insights(limit: int = Query(8, ge=1, le=50)):
forgotten: list[dict] = [] forgotten: list[dict] = []
for r in rows: for r in rows:
try:
meta = json.loads(r["metadata_json"] or "{}") meta = json.loads(r["metadata_json"] or "{}")
except Exception:
meta = {}
src = meta.get("source", "unknown") src = meta.get("source", "unknown")
source_counts[src] = source_counts.get(src, 0) + 1 source_counts[src] = source_counts.get(src, 0) + 1
for t in (meta.get("tags") or []): for t in (meta.get("tags") or []):
if isinstance(t, str): if isinstance(t, str):
tag_counts[t] = tag_counts.get(t, 0) + 1 tag_counts[t] = tag_counts.get(t, 0) + 1
try:
host = _host_from_meta(r["metadata_json"]) host = _host_from_meta(r["metadata_json"])
except Exception:
host = None
if host: if host:
host_counts[host] = host_counts.get(host, 0) + 1 host_counts[host] = host_counts.get(host, 0) + 1
@@ -430,7 +436,8 @@ def api_insights(limit: int = Query(8, ge=1, le=50)):
conn.close() conn.close()
return { return {
"total": total, "total": total,
"confirmed": confirmed, "confirmed": confirmed_true,
"rejected": confirmed_false,
"pending": pending, "pending": pending,
"top_tags": top_k(tag_counts), "top_tags": top_k(tag_counts),
"top_sources": top_k(source_counts), "top_sources": top_k(source_counts),
@@ -440,7 +447,10 @@ def api_insights(limit: int = Query(8, ge=1, le=50)):
} }
@app.get("/api/graph") @app.get("/api/graph")
def api_graph(limit_nodes: int = Query(200, ge=50, le=1000)): def api_graph(
limit_nodes: int = Query(0, ge=0, le=50000),
limit_edges: int = Query(0, ge=0, le=200000),
):
""" """
Returns a lightweight graph view: Returns a lightweight graph view:
- Nodes: engrams + tag:<tag> + host:<hostname> - Nodes: engrams + tag:<tag> + host:<hostname>
@@ -448,8 +458,34 @@ def api_graph(limit_nodes: int = Query(200, ge=50, le=1000)):
""" """
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
rows = c.execute("SELECT id, metadata_json FROM engrams ORDER BY created_at DESC LIMIT 1000").fetchall() if limit_nodes > 0:
link_rows = c.execute("SELECT from_id, to_id FROM engrams_links ORDER BY rowid DESC LIMIT 2000").fetchall() # Fetch a bigger window than the final node cap so trim can keep hubs + neighbors.
engram_fetch = min(50000, max(1000, int(limit_nodes * 3)))
else:
engram_fetch = None
if limit_edges > 0:
link_fetch = limit_edges
elif limit_nodes > 0:
link_fetch = min(200000, max(2000, int(limit_nodes * 10)))
else:
link_fetch = None
if engram_fetch is None:
rows = c.execute("SELECT id, metadata_json, correctness_json, created_at, modified_at FROM engrams ORDER BY created_at DESC").fetchall()
else:
rows = c.execute(
"SELECT id, metadata_json, correctness_json, created_at, modified_at FROM engrams ORDER BY created_at DESC LIMIT ?",
(engram_fetch,),
).fetchall()
if link_fetch is None:
link_rows = c.execute("SELECT from_id, to_id FROM engrams_links ORDER BY rowid DESC").fetchall()
else:
link_rows = c.execute(
"SELECT from_id, to_id FROM engrams_links ORDER BY rowid DESC LIMIT ?",
(link_fetch,),
).fetchall()
conn.close() conn.close()
nodes: dict[str, dict] = {} nodes: dict[str, dict] = {}
@@ -465,24 +501,60 @@ def api_graph(limit_nodes: int = Query(200, ge=50, le=1000)):
for r in rows: for r in rows:
eid = r["id"] eid = r["id"]
add_node(eid, "engram", label=eid[:8]) try:
meta = json.loads(r["metadata_json"] or "{}")
except Exception:
meta = {}
try:
corr = json.loads(r["correctness_json"] or "{}")
except Exception:
corr = {}
verdict = corr.get("verdict")
if not isinstance(verdict, str) or not verdict:
if corr.get("confirmed", False):
verdict = "confirmed_true"
elif int(corr.get("rejections", 0) or 0) > 0:
verdict = "confirmed_false"
else:
verdict = "unknown"
add_node(
eid,
"engram",
label=eid[:8],
weight=float(meta.get("access_count", 0) or 0),
)
nodes[eid].update(
{
"source": meta.get("source", "unknown"),
"confidence": float(meta.get("confidence", 0.0) or 0.0),
"created": meta.get("created", r["created_at"]),
"modified": meta.get("modified", r["modified_at"]),
"last_accessed": meta.get("last_accessed"),
"verdict": verdict,
"confirmed": bool(corr.get("confirmed", False)),
"rejections": int(corr.get("rejections", 0) or 0),
"grounding": meta.get("grounding", 0),
"predict_locked": bool(meta.get("predict_locked", False)),
}
)
for t in _safe_json_extract_tags(r["metadata_json"]): for t in _safe_json_extract_tags(r["metadata_json"]):
tid = f"tag:{t}" tid = f"tag:{t}"
add_node(tid, "tag", label=t) add_node(tid, "tag", label=t)
edges.append({"from": eid, "to": tid, "kind": "has_tag"}) edges.append({"from": eid, "to": tid, "kind": "has_tag", "weight": 0.35})
host = _host_from_meta(r["metadata_json"]) host = _host_from_meta(r["metadata_json"])
if host: if host:
hid = f"host:{host}" hid = f"host:{host}"
add_node(hid, "host", label=host) add_node(hid, "host", label=host)
edges.append({"from": eid, "to": hid, "kind": "grounded_at"}) edges.append({"from": eid, "to": hid, "kind": "grounded_at", "weight": 0.25})
for fr, to in link_rows: for fr, to in link_rows:
add_node(fr, "engram") add_node(fr, "engram")
add_node(to, "engram") add_node(to, "engram")
edges.append({"from": fr, "to": to, "kind": "link"}) edges.append({"from": fr, "to": to, "kind": "link", "weight": 1.0})
# Trim nodes to keep payload bounded (prefer engrams and connected tags/hosts) # Trim nodes to keep payload bounded (prefer engrams and connected tags/hosts)
if len(nodes) > limit_nodes: if limit_nodes > 0 and len(nodes) > limit_nodes:
# Keep a balanced subset: many engrams plus the most-connected non-engrams. # Keep a balanced subset: many engrams plus the most-connected non-engrams.
kept: dict[str, dict] = {} kept: dict[str, dict] = {}
engram_budget = int(limit_nodes * 0.7) engram_budget = int(limit_nodes * 0.7)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
import argparse
import json
import hashlib
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from src.engram import Engram, Grounding
from src.store import EngramStore
def _now_utc_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _hash16(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
def _iter_jsonl(path: Path) -> Iterable[Dict[str, Any]]:
with path.open("r", encoding="utf-8") as f:
for line_no, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except Exception:
raise SystemExit(f"Invalid JSON at {path}:{line_no}")
if not isinstance(obj, dict):
continue
yield obj
def _marker_to_content(marker_obj: Dict[str, Any]) -> Tuple[str, List[Dict[str, Any]]]:
marker = str(marker_obj.get("marker", "")).strip()
details = str(marker_obj.get("details", "")).strip()
checks = marker_obj.get("checks") or []
sources = marker_obj.get("sources") or []
if not marker:
raise ValueError("missing marker")
evidence: List[Dict[str, Any]] = []
for src in sources:
if not isinstance(src, dict):
continue
url = (src.get("url") or "").strip()
title = (src.get("title") or "").strip()
if not url:
continue
evidence.append({"url": url, "title": title})
lines: List[str] = []
lines.append(f"WEBDEV_MARKER: {marker}")
if details:
lines.append("")
lines.append(f"Details: {details}")
if isinstance(checks, list) and checks:
lines.append("")
lines.append("Checks:")
for c in checks[:8]:
c = str(c).strip()
if c:
lines.append(f"- {c}")
if evidence:
lines.append("")
lines.append("Sources:")
for ev in evidence[:12]:
title = (ev.get("title") or "").strip()
url = (ev.get("url") or "").strip()
if title:
lines.append(f"- {title}: {url}")
else:
lines.append(f"- {url}")
return "\n".join(lines).strip(), evidence
def _tags_for(marker_obj: Dict[str, Any]) -> List[str]:
tags = ["web_design", "web_development", "mobile"]
area = str(marker_obj.get("area", "")).strip()
if area:
tags.append(area)
return tags
def import_markers(
db_path: Path,
jsonl_paths: List[Path],
source: str,
verdict: str,
agent_id: str,
dry_run: bool,
) -> Dict[str, int]:
store = EngramStore(str(db_path))
stats = {"seen": 0, "imported": 0, "skipped_dup": 0, "skipped_invalid": 0}
seen_hashes: set[str] = set()
# Preload existing hashes (fast-ish; avoids duplicate spam).
existing_hashes: set[str] = set()
try:
cur = store._conn.execute("SELECT metadata_json FROM engrams") # noqa: SLF001
for row in cur.fetchall():
try:
meta = json.loads(row["metadata_json"])
h = meta.get("hash")
if isinstance(h, str) and h:
existing_hashes.add(h)
except Exception:
continue
except Exception:
# If this fails (schema mismatch), proceed without preload.
existing_hashes = set()
for path in jsonl_paths:
for marker_obj in _iter_jsonl(path):
if (marker_obj.get("kind") or "") != "web_design_marker":
continue
stats["seen"] += 1
try:
content, evidence = _marker_to_content(marker_obj)
except Exception:
stats["skipped_invalid"] += 1
continue
h = _hash16(content)
if h in seen_hashes or h in existing_hashes:
stats["skipped_dup"] += 1
continue
seen_hashes.add(h)
eg = Engram.create(
content=content,
source=source,
confidence=0.75,
tags=_tags_for(marker_obj),
session_id=None,
agent_id=agent_id or str(marker_obj.get("agent_id") or ""),
grounding=Grounding.SOURCED,
)
# Overwrite hash to exactly match our content representation.
eg.metadata["hash"] = h
eg.metadata["modified"] = _now_utc_iso()
eg.metadata["created"] = marker_obj.get("created_at") or eg.metadata["created"]
eg.correctness.set_verdict(
by=agent_id or "importer",
verdict=verdict,
note=f"Imported from {path.name}",
evidence=evidence,
)
if not dry_run:
store.save(eg)
stats["imported"] += 1
return stats
def main() -> None:
p = argparse.ArgumentParser(description="Import web_design_marker JSONL files into brain.sqlite")
p.add_argument("--db", default="second-brain/data/brain.sqlite", help="Path to brain.sqlite")
p.add_argument("--glob", default="/tmp/web_design_markers_*.jsonl", help="Glob for marker JSONL files")
p.add_argument("--source", default="web_research", help="Engram source")
p.add_argument("--verdict", default="probable_true", help="Correctness verdict")
p.add_argument("--agent-id", default="web_research_import", help="Agent id to record")
p.add_argument("--dry-run", action="store_true", help="Parse/dedupe but do not write to DB")
args = p.parse_args()
db_path = Path(args.db)
jsonl_paths = sorted(Path("/").glob(args.glob.lstrip("/"))) if args.glob.startswith("/") else sorted(Path(".").glob(args.glob))
if not jsonl_paths:
raise SystemExit(f"No files match glob: {args.glob}")
stats = import_markers(
db_path=db_path,
jsonl_paths=jsonl_paths,
source=args.source,
verdict=args.verdict,
agent_id=args.agent_id,
dry_run=bool(args.dry_run),
)
print(json.dumps({"db": str(db_path), "files": [str(p) for p in jsonl_paths], "stats": stats}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Process pending second brain engrams.
- For unconfirmed, unrejected engrams: evaluate confidence
- If confidence > 0.8: confirm
- If confidence < 0.3: reject
- Otherwise: mark for review (leave as is)
- Check for stale topics and archive if needed
- Produce summary report
"""
import sys
import json
from datetime import datetime, timezone
from pathlib import Path
# Add src to path and set PYTHONPATH for proper module resolution
base_dir = Path(__file__).parent.parent
sys.path.insert(0, str(base_dir / "src"))
# Import using absolute module paths
from src.store import EngramStore
from src.engram import Engram, Grounding
DB_PATH = Path(__file__).parent.parent / "data" / "brain.sqlite"
def is_stale(engram: Engram, days_threshold: int = 90) -> bool:
"""Check if an engram is stale (old and rarely accessed)."""
created = engram.metadata.get("created", "")
access_count = engram.metadata.get("access_count", 0)
last_accessed = engram.metadata.get("last_accessed", created)
try:
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
last_accessed_dt = datetime.fromisoformat(last_accessed.replace("Z", "+00:00"))
age_days = (datetime.now(timezone.utc) - created_dt).total_seconds() / 86400
days_since_access = (datetime.now(timezone.utc) - last_accessed_dt).total_seconds() / 86400
# Stale if: old (>90 days) AND rarely accessed (<3 times) AND not accessed recently (>60 days)
if age_days > days_threshold and access_count < 3 and days_since_access > 60:
return True
except Exception:
pass
return False
def process_pending_engrams():
"""Main processing function."""
store = EngramStore(str(DB_PATH))
# Get all engrams
all_engrams = store.get_all(limit=10000)
print(f"Total engrams in database: {len(all_engrams)}")
# Filter pending (unconfirmed and unrejected)
# Unconfirmed: not confirmed_true, not confirmed_false
pending = []
for eg in all_engrams:
verdict = eg.correctness.verdict
if verdict not in ("confirmed_true", "confirmed_false"):
pending.append(eg)
print(f"Pending engrams (unconfirmed/unrejected): {len(pending)}")
actions = {
"confirmed": 0,
"rejected": 0,
"left_for_review": 0,
"archived_stale": 0,
"errors": 0
}
details = []
for eg in pending:
try:
confidence = eg.compute_confidence()
engram_id = str(eg.id)
content_preview = eg.content[:80] + ("..." if len(eg.content) > 80 else "")
# Check if stale and should be archived
if is_stale(eg):
# For stale engrams, we'll mark them in metadata for archiving
# Instead of deleting, we'll add an "archived" tag and lower their priority
tags = eg.metadata.get("tags", [])
if "archived" not in tags:
tags.append("archived")
eg.metadata["tags"] = tags
eg.metadata["archived_at"] = datetime.now(timezone.utc).isoformat()
store.save(eg)
actions["archived_stale"] += 1
details.append(f"📦 Archived stale: [{engram_id[:8]}] {content_preview} (conf: {confidence:.2f})")
# Even if stale, we still evaluate confidence for reporting
# But we don't confirm/reject stale ones automatically unless confidence is extreme
# Actually, the task says to check for stale topics and archive if needed. We've done that.
# We still need to apply confidence thresholds to non-stale or all pending?
# Let's continue to evaluate all pending, including stale, but maybe skip confirm/reject for stale?
# The task: "For each pending engram... evaluate... If >0.8 confirm, <0.3 reject, otherwise mark for review"
# It doesn't say to skip stale ones. So we'll still apply thresholds.
# But we already archived it. We can still confirm/reject it if confidence is extreme.
# Let's continue.
# Apply confidence thresholds
if confidence > 0.8:
eg.correctness.confirm(by="auto_processor", note=f"Auto-confirmed: confidence {confidence:.2f}")
store.save(eg)
actions["confirmed"] += 1
details.append(f"✅ Confirmed: [{engram_id[:8]}] {content_preview} (conf: {confidence:.2f})")
elif confidence < 0.3:
eg.correctness.reject(by="auto_processor", note=f"Auto-rejected: confidence {confidence:.2f}")
store.save(eg)
actions["rejected"] += 1
details.append(f"❌ Rejected: [{engram_id[:8]}] {content_preview} (conf: {confidence:.2f})")
else:
actions["left_for_review"] += 1
details.append(f"⏳ Review later: [{engram_id[:8]}] {content_preview} (conf: {confidence:.2f})")
except Exception as e:
actions["errors"] += 1
details.append(f"⚠️ Error processing engram: {str(e)}")
# Generate summary report
report_lines = []
report_lines.append("=" * 60)
report_lines.append("PENDING ENGRAMS PROCESSING REPORT")
report_lines.append("=" * 60)
report_lines.append(f"Timestamp: {datetime.now(timezone.utc).isoformat()}")
report_lines.append(f"Total engrams: {len(all_engrams)}")
report_lines.append(f"Pending engrams processed: {len(pending)}")
report_lines.append("")
report_lines.append("ACTIONS TAKEN:")
report_lines.append(f" ✅ Auto-confirmed (confidence > 0.8): {actions['confirmed']}")
report_lines.append(f" ❌ Auto-rejected (confidence < 0.3): {actions['rejected']}")
report_lines.append(f" ⏳ Left for review (0.3 ≤ confidence ≤ 0.8): {actions['left_for_review']}")
report_lines.append(f" 📦 Archived stale topics: {actions['archived_stale']}")
report_lines.append(f" ⚠️ Errors: {actions['errors']}")
report_lines.append("")
report_lines.append("DETAILS:")
report_lines.extend(details)
report_lines.append("")
report_lines.append("=" * 60)
report = "\n".join(report_lines)
# Print to stdout
print("\n" + report)
# Save report to file
report_dir = Path(__file__).parent.parent / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
report_file = report_dir / f"pending_engrams_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
report_file.write_text(report, encoding="utf-8")
print(f"\n📄 Report saved to: {report_file}")
store.close()
return actions
if __name__ == "__main__":
result = process_pending_engrams()
print("\nProcessing complete.")

View File

@@ -134,11 +134,19 @@ def _node_size(access_count: int) -> float:
def generate_graph_html(store: EngramStore, output_path: str) -> str: def generate_graph_html(store: EngramStore, output_path: str) -> str:
"""Generiert interaktive HTML-Graph-Visualisierung.""" """Generiert interaktive HTML-Graph-Visualisierung."""
engrams = store.get_all() # store.get_all() defaults to 1000; paginate so the graph can include all nodes.
engrams = []
offset = 0
while True:
batch = store.get_all(limit=2000, offset=offset)
if not batch:
break
engrams.extend(batch)
offset += len(batch)
nodes = [] nodes = []
edges = [] edges = []
node_ids = set() node_ids = set(str(e.id) for e in engrams)
for eg in engrams: for eg in engrams:
eid = str(eg.id) eid = str(eg.id)
@@ -156,22 +164,19 @@ def generate_graph_html(store: EngramStore, output_path: str) -> str:
"size": size, "size": size,
"confidence": conf, "confidence": conf,
"confirmed": eg.correctness.confirmed, "confirmed": eg.correctness.confirmed,
"verdict": getattr(eg.correctness, "verdict", "unknown"),
"source": eg.metadata.get("source", "?"), "source": eg.metadata.get("source", "?"),
"tags": tags, "tags": tags,
} }
}) })
node_ids.add(eid)
# Add edges after all nodes are known (otherwise early nodes miss links).
for eg in engrams:
eid = str(eg.id)
for lid in eg.links: for lid in eg.links:
lid_s = str(lid) lid_s = str(lid)
if lid_s in node_ids: if lid_s in node_ids:
edges.append({ edges.append({"data": {"id": f"{eid}_{lid_s}", "source": eid, "target": lid_s}})
"data": {
"id": f"{eid}_{lid_s}",
"source": eid,
"target": lid_s,
}
})
elements = {"nodes": nodes, "edges": edges} elements = {"nodes": nodes, "edges": edges}
html = _HTML_TEMPLATE.format(elements_json=json.dumps(elements, ensure_ascii=False)) html = _HTML_TEMPLATE.format(elements_json=json.dumps(elements, ensure_ascii=False))

View File

@@ -12,6 +12,7 @@ import os
import sys import sys
import json import json
import traceback import traceback
import re
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@@ -48,6 +49,45 @@ except Exception:
# --- Konfiguration --- # --- Konfiguration ---
BRAIN_DB = Path(__file__).parent.parent / "data" / "brain.sqlite" BRAIN_DB = Path(__file__).parent.parent / "data" / "brain.sqlite"
_UUID_RE = re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", re.I)
_SHORT_ID_RE = re.compile(r"\b[0-9a-f]{8}\b", re.I)
def _detect_feedback(content: str) -> dict | None:
"""
Heuristik: erkennt kurze Korrektur-/Feedback-Nachrichten in Chats.
Returns:
{"kind":"confirm"|"reject"|"stop", "target": <id or short-id or None>, "raw": <normalized>}
"""
if not isinstance(content, str):
return None
raw = content.strip()
if not raw:
return None
norm = raw.lower().strip()
target = None
m = _UUID_RE.search(raw)
if m:
target = m.group(0)
else:
m2 = _SHORT_ID_RE.search(raw)
if m2:
target = m2.group(0)
if norm in {"stop", "stopp", "halt"}:
return {"kind": "stop", "target": target, "raw": norm}
if norm in {"nein", "no", "falsch", "wrong"}:
return {"kind": "reject", "target": target, "raw": norm}
if norm in {"ja", "yes", "richtig", "korrekt", "stimmt"}:
return {"kind": "confirm", "target": target, "raw": norm}
if norm.startswith(("korrigiert", "korrektur", "correction")):
if "richtig" in norm or "korrekt" in norm:
return {"kind": "confirm", "target": target, "raw": norm}
return {"kind": "reject", "target": target, "raw": norm}
return None
def get_brain() -> EngramStore: def get_brain() -> EngramStore:
"""Gibt initialisierten Brain-Store.""" """Gibt initialisierten Brain-Store."""
@@ -77,14 +117,43 @@ def save_session_learned(
) )
""" """
store = get_brain() store = get_brain()
tags = tags or []
fb = _detect_feedback(content) if source == "session" else None
fb_target_id: str | None = None
if fb:
tags = list(dict.fromkeys(tags + ["feedback", fb["kind"]]))
confidence = min(confidence, 0.2)
grounding = Grounding.ASSUMPTION
if session_id:
recent = store.get_latest_by_session_id(session_id, limit=10, exclude_tags=["feedback"])
if recent:
fb_target_id = str(recent[0].id)
eg = Engram.create( eg = Engram.create(
content=content, content=content,
source=source, source=source,
tags=tags or [], tags=tags,
session_id=session_id, session_id=session_id,
confidence=confidence, confidence=confidence,
grounding=grounding, grounding=grounding,
) )
if fb and fb_target_id:
target = store.get(fb_target_id)
if target:
try:
# Link both ways for graphing/traceability
eg.links.append(target.id)
if eg.id not in target.links:
target.links.append(eg.id)
# Lock target so auto-review does not keep "re-deciding" after a correction signal.
target.metadata["predict_locked"] = True
target.metadata["predict_locked_reason"] = f"feedback:{fb['raw']}"
target.metadata["predict_locked_at"] = datetime.now(timezone.utc).isoformat()
store.save(target)
except Exception:
pass
store.save(eg) store.save(eg)
return eg return eg

View File

@@ -136,6 +136,32 @@ class EngramStore:
).fetchall() ).fetchall()
return [self._row_to_engram(r) for r in rows] return [self._row_to_engram(r) for r in rows]
def get_latest_by_session_id(
self,
session_id: str,
*,
limit: int = 5,
exclude_tags: Optional[List[str]] = None,
) -> List[Engram]:
"""
Lädt die neuesten Engramme für eine OpenClaw-Session-ID.
Hinweis: session_id liegt im `metadata_json`; wir nutzen eine robuste
LIKE-Suche, damit auch Legacy-Records gefunden werden.
"""
if not session_id:
return []
rows = self._conn.execute(
"SELECT * FROM engrams WHERE metadata_json LIKE ? ORDER BY created_at DESC LIMIT ?",
(f'%"session_id": "{session_id}"%', limit),
).fetchall()
engrams = [self._row_to_engram(r) for r in rows]
if exclude_tags:
ex = set(t for t in exclude_tags if isinstance(t, str))
if ex:
engrams = [e for e in engrams if not (ex & set(e.metadata.get("tags", []) or []))]
return engrams
def delete(self, engram_id: str) -> bool: def delete(self, engram_id: str) -> bool:
"""Löscht ein Engramm und alle Verknüpfungen.""" """Löscht ein Engramm und alle Verknüpfungen."""
rowid = self._conn.execute( rowid = self._conn.execute(

View File

@@ -156,7 +156,45 @@ body {
background:#12121a; background:#12121a;
border:1px solid #252533; border:1px solid #252533;
border-radius: 14px; border-radius: 14px;
touch-action: none;
} }
.graph-controls{
display:flex;
gap:8px;
padding: 10px 12px 0;
align-items:center;
flex-wrap: wrap;
}
.graph-controls .btn{
background:#1e1e28;
border:1px solid #2a2a3a;
border-radius: 10px;
padding: 8px 10px;
color:#cfd3ff;
font-weight:700;
font-size:0.82rem;
}
.graph-controls .btn.primary{
border-color:#6c8af5;
box-shadow:0 0 0 1px rgba(108,138,245,0.18) inset;
}
.graph-legend{
margin: 8px 12px 0;
padding: 10px 12px;
background:#1a1a24;
border:1px solid #252533;
border-radius: 14px;
color:#b9b9c9;
font-size:0.8rem;
line-height:1.4;
}
.legend-row{ display:flex; align-items:center; gap:8px; margin-top:6px; }
.legend-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; }
.legend-dot.engram{ background:#6c8af5; }
.legend-dot.tag{ background:#8a9aff; }
.legend-dot.match{ background:#f7d154; }
.graph-hint{ padding: 4px 12px 10px; }
#searchInput { #searchInput {
flex: 1; flex: 1;
background: #1e1e28; background: #1e1e28;

View File

@@ -46,8 +46,31 @@
<!-- Graph --> <!-- Graph -->
<div class="graph" id="graph" style="display:none;"> <div class="graph" id="graph" style="display:none;">
<div class="graph-controls">
<button class="btn primary" id="btnGraphPhysics" onclick="toggleGraphPhysics()">Physics: off</button>
<button class="btn" onclick="resetGraphView()">Reset view</button>
<button class="btn" onclick="fitGraphView()">Fit</button>
<label class="muted small" style="display:flex;align-items:center;gap:8px">
<span>Physics</span>
<input id="physicsStrength" type="range" min="0" max="100" value="60" oninput="setPhysicsStrength(this.value)" style="width:140px">
<span id="physicsStrengthVal">60</span>
</label>
<select class="btn" id="graphLimit" onchange="reloadGraph()" title="Wie viele Knoten laden? 0=all">
<option value="0">Nodes: all</option>
<option value="200">Nodes: 200</option>
<option value="1000">Nodes: 1000</option>
<option value="5000">Nodes: 5000</option>
</select>
<button class="btn" onclick="reloadGraph()">Reload</button>
</div>
<canvas id="graphCanvas" width="440" height="520"></canvas> <canvas id="graphCanvas" width="440" height="520"></canvas>
<div class="muted small" id="graphHint">Lade Graph…</div> <div class="graph-hint muted small" id="graphHint">Lade Graph…</div>
<div class="graph-legend">
<div><strong>Graph</strong>: Zoom per Pinch (2 Finger), Pan per Drag (1 Finger). Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.</div>
<div class="legend-row"><span class="legend-dot engram"></span> Engram</div>
<div class="legend-row"><span class="legend-dot tag"></span> Tag</div>
<div class="legend-row"><span class="legend-dot match"></span> Match (Suche)</div>
</div>
</div> </div>
<!-- Status --> <!-- Status -->
@@ -90,7 +113,18 @@ let state = {
// ─── Fetch ────────────────────────────────────────────────────────────────── // ─── Fetch ──────────────────────────────────────────────────────────────────
async function api(path, opts = {}) { async function api(path, opts = {}) {
const r = await fetch(path, opts); const r = await fetch(path, opts);
if (!r.ok) throw new Error((await r.json()).error || r.statusText); if (!r.ok) {
let msg = r.statusText;
try {
const j = await r.json();
msg = j.error || j.detail || msg;
if (Array.isArray(msg)) msg = JSON.stringify(msg);
if (typeof msg !== 'string') msg = JSON.stringify(msg);
} catch (e) {
// ignore JSON parse errors
}
throw new Error(msg);
}
return r.json(); return r.json();
} }
@@ -143,14 +177,23 @@ async function loadCards() {
} }
async function loadStatus() { async function loadStatus() {
const [cfg, db, jobs, ins, stor] = await Promise.all([ const reqs = await Promise.allSettled([
api('/api/config'), api('/api/config'),
api('/api/db_info'), api('/api/db_info'),
api('/api/jobs'), api('/api/jobs'),
api('/api/insights?limit=8'), api('/api/insights?limit=8'),
api('/api/storage_stats'), api('/api/storage_stats'),
api('/api/pending?limit=20&offset=0'),
]); ]);
const pend = await api('/api/pending?limit=20&offset=0'); const pick = (i, fallback) => (reqs[i].status === 'fulfilled' ? reqs[i].value : fallback);
const err = (i) => (reqs[i].status === 'rejected' ? (reqs[i].reason && reqs[i].reason.message ? reqs[i].reason.message : String(reqs[i].reason)) : null);
const cfg = pick(0, { workspace: '-', db_path: '-' });
const db = pick(1, { db_path: '-', mtime: null });
const jobs = pick(2, { items: [], error: err(2) });
const ins = pick(3, { pending: '-', top_tags: [], top_hosts: [], error: err(3) });
const stor = pick(4, { sql: { total_engrams: '-', confirmed: '-', pending: '-', by_source: {} }, vector: { chroma_size_bytes: 0, embedding_cache_files: 0 }, obsidian: { configured: false } });
const pend = pick(5, { items: [], error: err(5) });
const el = document.getElementById('status'); const el = document.getElementById('status');
const jobsHtml = (jobs.items || []).map(j => ` const jobsHtml = (jobs.items || []).map(j => `
@@ -198,99 +241,559 @@ async function loadStatus() {
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-title">Jobs</div> <div class="panel-title">Jobs</div>
${jobsHtml || '<div class="muted">Keine Daten</div>'} ${jobs.error ? `<div class="muted">Fehler: ${escapeHtml(jobs.error)}</div>` : (jobsHtml || '<div class="muted">Keine Daten</div>')}
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-title">Insights</div> <div class="panel-title">Insights</div>
${ins.error ? `<div class="muted">Fehler: ${escapeHtml(ins.error)}</div>` : ''}
<div class="kv-row"><div class="kv-key">pending</div><div class="kv-val">${ins.pending}</div></div> <div class="kv-row"><div class="kv-key">pending</div><div class="kv-val">${ins.pending}</div></div>
<div class="kv-row"><div class="kv-key">top tags</div><div class="kv-val">${topTags || '-'}</div></div> <div class="kv-row"><div class="kv-key">top tags</div><div class="kv-val">${topTags || '-'}</div></div>
<div class="kv-row"><div class="kv-key">top hosts</div><div class="kv-val">${topHosts || '-'}</div></div> <div class="kv-row"><div class="kv-key">top hosts</div><div class="kv-val">${topHosts || '-'}</div></div>
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-title">Pending Queue (latest)</div> <div class="panel-title">Pending Queue (latest)</div>
${pendHtml || '<div class="muted">Keine Pendings</div>'} ${pend.error ? `<div class="muted">Fehler: ${escapeHtml(pend.error)}</div>` : (pendHtml || '<div class="muted">Keine Pendings</div>')}
</div> </div>
`; `;
} }
async function loadGraph() { async function loadGraph() {
const g = await api('/api/graph?limit_nodes=200'); const sel = document.getElementById('graphLimit');
const fromSel = sel ? parseInt(sel.value || '0', 10) : NaN;
const fromStore = parseInt(localStorage.getItem('graphLimit') || '0', 10);
const q = (!Number.isNaN(fromSel)) ? fromSel : (Number.isNaN(fromStore) ? 0 : fromStore);
if (sel) sel.value = String(q);
if (sel) localStorage.setItem('graphLimit', String(q));
const hint = document.getElementById('graphHint');
if (hint) hint.textContent = 'Lade Graph…';
try {
const g = await api(`/api/graph?limit_nodes=${q}`);
renderGraph(g.nodes || [], g.edges || []); renderGraph(g.nodes || [], g.edges || []);
} catch (e) {
if (hint) hint.textContent = `Graph-Fehler: ${e && e.message ? e.message : String(e)}`;
const canvas = _graphCanvas();
const ctx = _graphCtx();
if (canvas && ctx) ctx.clearRect(0,0,canvas.width,canvas.height);
}
}
function reloadGraph() { loadGraph(); }
// ─── Graph Renderer (Canvas) ────────────────────────────────────────────────
let graphState = {
nodes: [],
edges: [],
sim: [],
links: [],
nodeById: new Map(),
simById: new Map(),
degree: new Map(),
physicsOn: false,
draggingId: null,
selectedId: null,
panning: false,
lastX: 0,
lastY: 0,
panX: 0,
panY: 0,
zoom: 1,
raf: null,
search: '',
pointers: new Map(),
pinchStartDist: null,
pinchStartZoom: null,
pinchStartPan: null,
down: null, // {pointerId, cx, cy, t}
physicsStrength: parseInt(localStorage.getItem('physicsStrength') || '60', 10),
};
function _graphCanvas() { return document.getElementById('graphCanvas'); }
function _graphCtx() { return _graphCanvas().getContext('2d'); }
function _graphNodeRadius(n) {
const d = graphState.degree.get(n.id) || 0;
const base = n.kind === 'tag' ? 4 : (n.kind === 'host' ? 5 : 7);
const w = (n.weight || 0);
const bonus = Math.min(6, Math.sqrt(Math.max(0, w)) * 0.8);
return Math.max(3, Math.min(18, base + Math.sqrt(d) + bonus));
}
function _graphNodeFill(n) {
const d = graphState.degree.get(n.id) || 0;
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));
if (n.kind === 'tag') {
const [r,g,b] = mix([167, 139, 250]);
return `rgb(${r},${g},${b})`;
}
if (n.kind === 'host') {
const [r,g,b] = mix([245, 158, 11]);
return `rgb(${r},${g},${b})`;
}
const verdict = (n.verdict || '').toString();
let base = [96, 165, 250]; // pending/unknown = blue
if (verdict === 'confirmed_true') base = [34, 197, 94]; // green
else if (verdict === 'confirmed_false') base = [239, 68, 68]; // red
else if (verdict === 'probable_true') base = [52, 211, 153]; // teal
else if (verdict === 'probable_false') base = [251, 191, 36]; // amber
// Recency brightening (brand new pops)
const now = Date.now();
const created = Date.parse(n.created || '') || 0;
const ageMin = created ? (now - created) / 60000 : 999999;
const rec = Math.max(0, Math.min(0.35, (30 - ageMin) / 30 * 0.35));
const bump = (c) => Math.round(c + (255 - c) * rec);
const [r,g,b] = mix(base).map(bump);
return `rgb(${r},${g},${b})`;
}
function _graphMatches(n, term) {
const t = (term || '').trim().toLowerCase();
if (!t) return false;
const id = (n.id || '').toLowerCase();
const label = (n.label || '').toLowerCase();
return id.includes(t) || label.includes(t);
}
function _graphWorldFromScreen(cx, cy) {
return {
x: (cx - graphState.panX) / graphState.zoom,
y: (cy - graphState.panY) / graphState.zoom,
};
}
function _graphHitTest(wx, wy) {
for (let i = graphState.sim.length - 1; i >= 0; i--) {
const n = graphState.sim[i];
const r = _graphNodeRadius(n) + 2;
const dx = wx - n.x;
const dy = wy - n.y;
if ((dx*dx + dy*dy) <= r*r) return n;
}
return null;
}
function _graphInitInteractions() {
const canvas = _graphCanvas();
if (canvas._graphBound) return;
canvas._graphBound = true;
const toCanvasXY = (ev) => {
const rect = canvas.getBoundingClientRect();
return { cx: ev.clientX - rect.left, cy: ev.clientY - rect.top };
};
const pinchMidpoint = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
return { cx: (pts[0].cx + pts[1].cx) / 2, cy: (pts[0].cy + pts[1].cy) / 2 };
};
const pinchDistance = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
const dx = pts[0].cx - pts[1].cx;
const dy = pts[0].cy - pts[1].cy;
return Math.sqrt(dx*dx + dy*dy);
};
canvas.addEventListener('pointerdown', (ev) => {
canvas.setPointerCapture(ev.pointerId);
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
graphState.down = { pointerId: ev.pointerId, cx: pos.cx, cy: pos.cy, t: Date.now() };
// When 2 pointers: start pinch
if (graphState.pointers.size === 2) {
graphState.panning = false;
graphState.draggingId = null;
graphState.down = null;
graphState.pinchStartDist = pinchDistance();
graphState.pinchStartZoom = graphState.zoom;
graphState.pinchStartPan = { panX: graphState.panX, panY: graphState.panY };
_graphDraw();
return;
}
// Single pointer: pan or drag node
const { cx, cy } = pos;
const w = _graphWorldFromScreen(cx, cy);
const hit = _graphHitTest(w.x, w.y);
graphState.lastX = cx; graphState.lastY = cy;
if (hit) {
graphState.draggingId = hit.id;
graphState.selectedId = hit.id;
} else {
graphState.panning = true;
graphState.selectedId = null;
}
_graphDraw();
});
canvas.addEventListener('pointermove', (ev) => {
if (!graphState.pointers.has(ev.pointerId)) return;
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
// Pinch zoom (2 pointers)
if (graphState.pointers.size >= 2 && graphState.pinchStartDist) {
const dist = pinchDistance();
const mid = pinchMidpoint();
if (!dist || !mid) return;
const zoomOld = graphState.zoom;
const wx = (mid.cx - graphState.panX) / zoomOld;
const wy = (mid.cy - graphState.panY) / zoomOld;
const factor = dist / graphState.pinchStartDist;
const next = Math.max(0.35, Math.min(3.0, graphState.pinchStartZoom * factor));
graphState.zoom = next;
graphState.panX = mid.cx - wx * graphState.zoom;
graphState.panY = mid.cy - wy * graphState.zoom;
_graphDraw();
return;
}
// Single pointer
const { cx, cy } = pos;
const dx = cx - graphState.lastX;
const dy = cy - graphState.lastY;
graphState.lastX = cx; graphState.lastY = cy;
if (graphState.draggingId) {
const w = _graphWorldFromScreen(cx, cy);
const n = graphState.simById.get(graphState.draggingId);
if (n) { n.x = w.x; n.y = w.y; n.vx = 0; n.vy = 0; }
_graphDraw();
return;
}
if (graphState.panning) {
graphState.panX += dx;
graphState.panY += dy;
_graphDraw();
}
});
const endPointer = (ev) => {
if (graphState.pointers.has(ev.pointerId)) graphState.pointers.delete(ev.pointerId);
try { canvas.releasePointerCapture(ev.pointerId); } catch {}
if (graphState.pointers.size < 2) {
graphState.pinchStartDist = null;
graphState.pinchStartZoom = null;
graphState.pinchStartPan = null;
}
const pos = toCanvasXY(ev);
const down = graphState.down && graphState.down.pointerId === ev.pointerId ? graphState.down : null;
graphState.down = null;
const moved = down ? Math.hypot(pos.cx - down.cx, pos.cy - down.cy) : 999;
const isTap = !!down && moved <= 8 && (Date.now() - down.t) <= 500;
const w = _graphWorldFromScreen(pos.cx, pos.cy);
const hit = _graphHitTest(w.x, w.y);
graphState.draggingId = null;
graphState.panning = false;
if (isTap && hit) {
graphState.selectedId = hit.id;
_graphDraw();
if (hit.kind === 'engram') {
showDetail(hit.id);
} else if (hit.kind === 'tag') {
const label = (hit.label || '').replace(/^tag:/, '');
const inp = document.getElementById('searchInput');
inp.value = label;
inp.dispatchEvent(new Event('input'));
}
return;
}
_graphDraw();
};
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
} }
function renderGraph(nodes, edges) { function renderGraph(nodes, edges) {
const canvas = document.getElementById('graphCanvas'); const canvas = _graphCanvas();
const hint = document.getElementById('graphHint'); const hint = document.getElementById('graphHint');
const ctx = canvas.getContext('2d'); const ctx = _graphCtx();
// sync physics slider
const slider = document.getElementById('physicsStrength');
const sliderVal = document.getElementById('physicsStrengthVal');
const s = Math.max(0, Math.min(100, parseInt(graphState.physicsStrength || 60, 10)));
graphState.physicsStrength = s;
if (slider) slider.value = String(s);
if (sliderVal) sliderVal.textContent = String(s);
// Fit canvas to container width (mobile)
const w = canvas.parentElement.clientWidth - 24; const w = canvas.parentElement.clientWidth - 24;
canvas.width = Math.max(320, Math.min(520, w)); canvas.width = Math.max(320, w);
canvas.height = 520; canvas.height = Math.max(520, Math.min(900, (window.innerHeight || 900) - 260));
if (!nodes.length || !edges.length) { graphState.nodes = nodes || [];
hint.textContent = 'Graph: keine Kanten (noch keine Links/Tags/Hosts im Sample).'; graphState.edges = edges || [];
graphState.nodeById = new Map(graphState.nodes.map(n => [n.id, n]));
graphState.degree = new Map();
for (const e of graphState.edges) {
graphState.degree.set(e.from, (graphState.degree.get(e.from) || 0) + 1);
graphState.degree.set(e.to, (graphState.degree.get(e.to) || 0) + 1);
}
if (!graphState.nodes.length) {
hint.textContent = 'Graph: keine Nodes.';
ctx.clearRect(0,0,canvas.width,canvas.height); ctx.clearRect(0,0,canvas.width,canvas.height);
return; return;
} }
hint.textContent = `nodes=${nodes.length} edges=${edges.length}`; hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}`;
const nodeById = new Map(nodes.map(n => [n.id, n])); // Build adjacency for deterministic layout (O(n+e))
const sim = nodes.map(n => ({ const adj = new Map();
const addAdj = (a, b) => {
if (!adj.has(a)) adj.set(a, []);
adj.get(a).push(b);
};
for (const e of graphState.edges) {
addAdj(e.from, e.to);
addAdj(e.to, e.from);
}
const degree = graphState.degree;
const visited = new Set();
const comps = [];
for (const n of graphState.nodes) {
if (visited.has(n.id)) continue;
const q = [n.id];
visited.add(n.id);
const comp = [];
while (q.length) {
const cur = q.pop();
comp.push(cur);
const neigh = adj.get(cur) || [];
for (const nb of neigh) {
if (!visited.has(nb)) {
visited.add(nb);
q.push(nb);
}
}
}
comps.push(comp);
}
comps.sort((a, b) => b.length - a.length);
// component centers in a loose grid/spiral
const centers = [];
const gap = 240;
const cols = Math.max(1, Math.floor(canvas.width / gap));
for (let i = 0; i < comps.length; i++) {
const cx = (i % cols) * gap - ((cols - 1) * gap) / 2;
const cy = Math.floor(i / cols) * gap - (Math.floor((comps.length - 1) / cols) * gap) / 2;
centers.push({ cx, cy });
}
const pos = new Map();
const R = 46;
for (let ci = 0; ci < comps.length; ci++) {
const comp = comps[ci];
const center = centers[ci] || { cx: 0, cy: 0 };
// hub: highest degree engram preferred
let hub = comp[0];
let hubScore = -1;
for (const id of comp) {
const n = graphState.nodeById.get(id) || {};
const score = (degree.get(id) || 0) + (n.kind === 'engram' ? 3 : 0);
if (score > hubScore) { hubScore = score; hub = id; }
}
// BFS layers from hub
const layer = new Map();
layer.set(hub, 0);
const qq = [hub];
while (qq.length) {
const cur = qq.shift();
const l = layer.get(cur) || 0;
const neigh = adj.get(cur) || [];
for (const nb of neigh) {
if (!layer.has(nb)) {
layer.set(nb, l + 1);
qq.push(nb);
}
}
}
const buckets = new Map();
for (const id of comp) {
const l = layer.get(id);
const ll = (typeof l === 'number') ? l : 99;
if (!buckets.has(ll)) buckets.set(ll, []);
buckets.get(ll).push(id);
}
// Place hubs first, then rings
pos.set(hub, { x: center.cx, y: center.cy });
const maxLayer = Math.max(...Array.from(buckets.keys()));
for (let l = 1; l <= maxLayer; l++) {
const ids = buckets.get(l) || [];
if (!ids.length) continue;
// Stable order: engrams first, then by degree desc
ids.sort((a, b) => {
const na = graphState.nodeById.get(a) || {};
const nb = graphState.nodeById.get(b) || {};
const ka = na.kind === 'engram' ? 0 : (na.kind === 'tag' ? 1 : 2);
const kb = nb.kind === 'engram' ? 0 : (nb.kind === 'tag' ? 1 : 2);
if (ka !== kb) return ka - kb;
return (degree.get(b) || 0) - (degree.get(a) || 0);
});
const rad = l * R;
for (let i = 0; i < ids.length; i++) {
const ang = (i / ids.length) * Math.PI * 2;
pos.set(ids[i], {
x: center.cx + Math.cos(ang) * rad,
y: center.cy + Math.sin(ang) * rad,
});
}
}
}
graphState.sim = graphState.nodes.map(n => {
const p = pos.get(n.id) || { x: (Math.random() - 0.5) * canvas.width, y: (Math.random() - 0.5) * canvas.height };
return {
id: n.id, id: n.id,
kind: n.kind, kind: n.kind,
label: n.label || n.id, label: n.label || n.id,
x: Math.random()*canvas.width, weight: n.weight,
y: Math.random()*canvas.height, verdict: n.verdict,
confidence: n.confidence,
created: n.created,
modified: n.modified,
last_accessed: n.last_accessed,
predict_locked: n.predict_locked,
x: p.x,
y: p.y,
vx: 0, vy: 0, vx: 0, vy: 0,
})); };
const simById = new Map(sim.map(n => [n.id, n])); });
graphState.simById = new Map(graphState.sim.map(n => [n.id, n]));
const links = edges graphState.links = graphState.edges
.map(e => ({a: simById.get(e.from), b: simById.get(e.to), kind: e.kind})) .map(e => ({a: graphState.simById.get(e.from), b: graphState.simById.get(e.to), kind: e.kind, weight: e.weight || 1.0}))
.filter(l => l.a && l.b); .filter(l => l.a && l.b);
// Simple force layout (few iterations) graphState.panX = canvas.width / 2;
for (let iter=0; iter<180; iter++) { graphState.panY = canvas.height / 2;
// repulsion graphState.zoom = 1;
graphState.search = state.search || '';
graphState.selectedId = null;
_graphInitInteractions();
// Physics is expensive; pre-relax a little but keep it optional for big graphs.
const relaxIters = graphState.sim.length <= 700 ? 70 : 12;
for (let i = 0; i < relaxIters; i++) _graphStepPhysics(0.85);
_graphDraw();
}
function _graphStepPhysics(alpha = 1.0) {
const canvas = _graphCanvas();
const repulsion = (graphState.sim.length > 700) ? 120 : 180;
const damping = 0.86;
const target = 80;
const springK = 0.018;
const sim = graphState.sim;
if (sim.length > 700) {
// Fast approximate repulsion via spatial hashing (checks only local neighbor cells).
const cell = 120;
const grid = new Map();
const key = (cx, cy) => `${cx},${cy}`;
for (let i = 0; i < sim.length; i++) {
const n = sim[i];
const cx = Math.floor(n.x / cell);
const cy = Math.floor(n.y / cell);
const k = key(cx, cy);
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
for (let i = 0; i < sim.length; i++) {
const a = sim[i];
const cx = Math.floor(a.x / cell);
const cy = Math.floor(a.y / cell);
for (let ox = -1; ox <= 1; ox++) {
for (let oy = -1; oy <= 1; oy++) {
const idxs = grid.get(key(cx + ox, cy + oy));
if (!idxs) continue;
for (const j of idxs) {
if (j === i) continue;
const b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.12;
const f = (repulsion / d2) * alpha * 0.55;
a.vx += dx*f; a.vy += dy*f;
}
}
}
}
} else {
for (let i = 0; i < sim.length; i++) { for (let i = 0; i < sim.length; i++) {
for (let j = i + 1; j < sim.length; j++) { for (let j = i + 1; j < sim.length; j++) {
const a = sim[i], b = sim[j]; const a = sim[i], b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y; const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.01; const d2 = dx*dx + dy*dy + 0.02;
const f = 120 / d2; const f = (repulsion / d2) * alpha;
a.vx += dx*f; a.vy += dy*f; a.vx += dx*f; a.vy += dy*f;
b.vx -= dx*f; b.vy -= dy*f; b.vx -= dx*f; b.vy -= dy*f;
} }
} }
// springs }
for (const l of links) { for (const l of graphState.links) {
const a = l.a, b = l.b; const a = l.a, b = l.b;
const dx = b.x - a.x, dy = b.y - a.y; const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1; const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const target = 60; const f = (dist - target) * springK * alpha;
const k = 0.02;
const f = (dist - target) * k;
const fx = (dx/dist) * f, fy = (dy/dist) * f; const fx = (dx/dist) * f, fy = (dy/dist) * f;
a.vx += fx; a.vy += fy; a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy; b.vx -= fx; b.vy -= fy;
} }
// integrate + bounds
for (const n of sim) { for (const n of sim) {
n.vx *= 0.85; n.vy *= 0.85; n.vx *= damping; n.vy *= damping;
n.x += n.vx; n.y += n.vy; n.x += n.vx; n.y += n.vy;
n.x = Math.max(10, Math.min(canvas.width-10, n.x)); const pad = 10;
n.y = Math.max(10, Math.min(canvas.height-10, n.y)); const bx = canvas.width / graphState.zoom;
const by = canvas.height / graphState.zoom;
n.x = Math.max(-bx/2 + pad, Math.min(bx/2 - pad, n.x));
n.y = Math.max(-by/2 + pad, Math.min(by/2 - pad, n.y));
} }
} }
function _graphEdgeColor(kind) {
const k = (kind || '').toString().toLowerCase();
if (k.includes('tag')) return '#7c3aed';
if (k.includes('host')) return '#f59e0b';
if (k.includes('ref')) return '#10b981';
return '#3a3a55';
}
function _graphDraw() {
const canvas = _graphCanvas();
const ctx = _graphCtx();
const hint = document.getElementById('graphHint');
ctx.clearRect(0,0,canvas.width,canvas.height); ctx.clearRect(0,0,canvas.width,canvas.height);
// edges ctx.save();
ctx.globalAlpha = 0.5; ctx.translate(graphState.panX, graphState.panY);
ctx.strokeStyle = '#3a3a55'; ctx.scale(graphState.zoom, graphState.zoom);
ctx.lineWidth = 1;
for (const l of links) { for (const l of graphState.links) {
const term = (graphState.search || '').trim();
const isMatchEdge = term && (_graphMatches(l.a, term) || _graphMatches(l.b, term));
const w = Math.max(0.2, Math.min(3.0, (l.weight || 1.0)));
ctx.lineWidth = (0.6 + w) / graphState.zoom;
ctx.globalAlpha = isMatchEdge ? 0.85 : (0.25 + Math.min(0.35, w * 0.18));
ctx.strokeStyle = isMatchEdge ? '#f7d154' : _graphEdgeColor(l.kind);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(l.a.x, l.a.y); ctx.moveTo(l.a.x, l.a.y);
ctx.lineTo(l.b.x, l.b.y); ctx.lineTo(l.b.x, l.b.y);
@@ -298,21 +801,126 @@ function renderGraph(nodes, edges) {
} }
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
// nodes const term = (graphState.search || '').trim();
for (const n of sim) { let matches = 0;
let r = 5; for (const n of graphState.sim) {
let fill = '#6c8af5'; const r = _graphNodeRadius(n);
if (n.kind === 'tag') { fill = '#8a9aff'; r = 4; } const isMatch = _graphMatches(n, term);
if (n.kind === 'host') { fill = '#f5b46c'; r = 4; } if (isMatch) matches++;
if (n.kind === 'engram') { fill = '#6c8af5'; r = 5; }
if (isMatch) {
ctx.beginPath();
ctx.strokeStyle = '#f7d154';
ctx.lineWidth = 3 / graphState.zoom;
ctx.arc(n.x, n.y, r + 3, 0, Math.PI*2);
ctx.stroke();
}
if (graphState.selectedId === n.id) {
ctx.beginPath();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2 / graphState.zoom;
ctx.arc(n.x, n.y, r + 2, 0, Math.PI*2);
ctx.stroke();
}
ctx.beginPath(); ctx.beginPath();
ctx.fillStyle = fill; ctx.fillStyle = _graphNodeFill(n);
ctx.arc(n.x, n.y, r, 0, Math.PI*2); ctx.arc(n.x, n.y, r, 0, Math.PI*2);
ctx.fill(); ctx.fill();
// status/recency/lock border
let stroke = null;
const v = (n.verdict || '').toString();
if (v === 'confirmed_true') stroke = '#bbf7d0';
else if (v === 'confirmed_false') stroke = '#fecaca';
else if (v) stroke = '#c7d2fe';
const now = Date.now();
const created = Date.parse(n.created || '') || 0;
const modified = Date.parse(n.modified || '') || 0;
const isNew = created && (now - created) < (30 * 60 * 1000);
const isHot = modified && (now - modified) < (10 * 60 * 1000);
if (isNew) stroke = '#f7d154';
if (isHot) stroke = '#ffffff';
if (n.predict_locked) stroke = '#a78bfa';
if (stroke) {
ctx.beginPath();
ctx.strokeStyle = stroke;
ctx.lineWidth = (n.predict_locked ? 3 : 1.5) / graphState.zoom;
ctx.arc(n.x, n.y, r + 0.8, 0, Math.PI*2);
ctx.stroke();
} }
} }
ctx.restore();
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}` + (term ? ` | match=${matches}` : '');
}
function _graphLoop() {
if (!graphState.physicsOn) return;
const speed = 0.25 + (Math.max(0, Math.min(100, graphState.physicsStrength || 60)) / 100) * 0.95;
_graphStepPhysics(speed);
_graphDraw();
graphState.raf = requestAnimationFrame(_graphLoop);
}
function setPhysicsStrength(v) {
const n = Math.max(0, Math.min(100, parseInt(v || '0', 10)));
graphState.physicsStrength = n;
localStorage.setItem('physicsStrength', String(n));
const el = document.getElementById('physicsStrengthVal');
if (el) el.textContent = String(n);
}
function toggleGraphPhysics() {
graphState.physicsOn = !graphState.physicsOn;
const b = document.getElementById('btnGraphPhysics');
const fast = (graphState.sim || []).length > 700;
b.textContent = `Physics: ${graphState.physicsOn ? ('on' + (fast ? ' (fast)' : '')) : 'off'}`;
b.classList.toggle('primary', graphState.physicsOn);
if (graphState.physicsOn) {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.raf = requestAnimationFrame(_graphLoop);
} else {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.raf = null;
_graphDraw();
}
}
function resetGraphView() {
const canvas = _graphCanvas();
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
graphState.zoom = 1;
_graphDraw();
}
function fitGraphView() {
const canvas = _graphCanvas();
if (!graphState.sim.length) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of graphState.sim) {
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y);
}
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const zx = (canvas.width - 40) / w;
const zy = (canvas.height - 40) / h;
graphState.zoom = Math.max(0.35, Math.min(2.5, Math.min(zx, zy)));
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
_graphDraw();
}
function graphApplySearch(term) {
graphState.search = term || '';
_graphDraw();
}
// Real-time updates via SSE // Real-time updates via SSE
function startEvents() { function startEvents() {
try { try {
@@ -402,6 +1010,8 @@ async function confirm(id, ev) {
body: new URLSearchParams({reason}) body: new URLSearchParams({reason})
}); });
await loadCards(); await loadStats(); await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
} }
async function reject(id, ev) { async function reject(id, ev) {
@@ -413,6 +1023,8 @@ async function reject(id, ev) {
body: new URLSearchParams({reason}) body: new URLSearchParams({reason})
}); });
await loadCards(); await loadStats(); await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
} }
async function refresh(id, ev) { async function refresh(id, ev) {
@@ -433,6 +1045,8 @@ async function createEngram() {
document.getElementById('newContent').value = ''; document.getElementById('newContent').value = '';
document.getElementById('newTags').value = ''; document.getElementById('newTags').value = '';
await loadCards(); await loadStats(); await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
} }
async function showDetail(id) { async function showDetail(id) {
@@ -478,6 +1092,7 @@ document.getElementById('searchInput').addEventListener('input', (e) => {
state.search = e.target.value; state.search = e.target.value;
state.offset = 0; state.offset = 0;
loadCards(); loadCards();
if (state.view === 'graph') graphApplySearch(state.search);
}); });
document.getElementById('filterSelect').addEventListener('change', (e) => { document.getElementById('filterSelect').addEventListener('change', (e) => {