diff --git a/cron_tasks/import_context_buffer.py b/cron_tasks/import_context_buffer.py
index c3aeef5..32e97f5 100644
--- a/cron_tasks/import_context_buffer.py
+++ b/cron_tasks/import_context_buffer.py
@@ -15,34 +15,35 @@ from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
WORKSPACE = Path("/root/.openclaw/workspace")
-HANDLER = WORKSPACE / "context-buffer" / "handler.py"
+CURRENT_DIR = WORKSPACE / "context-buffer" / "current"
def run():
- # Hole alle Topics mit status done/completed via handler
+ # Lese context-buffer index.json direkt
+ index_path = WORKSPACE / "context-buffer" / "index.json"
try:
- result = subprocess.run(
- ["python3", str(HANDLER), "search", "--status", "done"],
- capture_output=True, text=True, timeout=30
- )
- if result.returncode != 0:
- raise Exception(f"Handler error: {result.stderr}")
- topics = json.loads(result.stdout)
+ 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
- # Alternative: auch 'completed' suchen
- try:
- result2 = subprocess.run(
- ["python3", str(HANDLER), "search", "--status", "completed"],
- capture_output=True, text=True, timeout=30
- )
- if result2.returncode == 0:
- topics_completed = json.loads(result2.stdout)
- topics.extend(topics_completed)
- except Exception:
- pass
-
if not topics:
print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False))
return
diff --git a/fastapi_app.py b/fastapi_app.py
index 275efc5..a09ed0c 100644
--- a/fastapi_app.py
+++ b/fastapi_app.py
@@ -63,7 +63,7 @@ def parse_engram(row: sqlite3.Row) -> dict:
verdict = "confirmed_false"
else:
verdict = "unknown"
- return {
+ result = {
"id": row["id"],
"content": row["content"],
"confidence": meta.get("confidence", 0.0),
@@ -81,6 +81,12 @@ def parse_engram(row: sqlite3.Row) -> dict:
"access_count": meta.get("access_count", 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:
@@ -902,6 +908,31 @@ def api_refresh(engram_id: str):
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")
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]}"
diff --git a/static/style.css b/static/style.css
index 157f383..8ae430d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -14,6 +14,7 @@ body {
margin: 0 auto;
min-height: 100vh;
background: #141419;
+ width: 100%;
}
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */
@@ -75,10 +76,22 @@ body {
/* ─── Search ──────────────────────────────────────────────────────────────── */
.search-box {
display: flex;
+ flex-direction: column;
gap: 8px;
padding: 10px 12px;
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 */
@@ -196,7 +209,9 @@ body {
.legend-dot.match{ background:#f7d154; }
.graph-hint{ padding: 4px 12px 10px; }
#searchInput {
+ width: 100%;
flex: 1;
+ min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
@@ -207,6 +222,8 @@ body {
}
#searchInput:focus { border-color: #6c8af5; }
#filterSelect {
+ flex: 1;
+ min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
@@ -216,6 +233,32 @@ body {
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 {
padding: 0 12px 8px;
@@ -266,6 +309,10 @@ body {
transition: transform 0.15s ease, border-color 0.2s ease;
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.confirmed { border-left: 4px solid #3a7d3a; }
.card.rejected { border-left: 4px solid #8a3a3a; }
@@ -455,3 +502,13 @@ body {
@media (pointer: coarse) {
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; }
+}
diff --git a/systemd/openclaw-secondbrain-ingest-memory.path b/systemd/openclaw-secondbrain-ingest-memory.path
index 5679b37..d61a188 100644
--- a/systemd/openclaw-secondbrain-ingest-memory.path
+++ b/systemd/openclaw-secondbrain-ingest-memory.path
@@ -4,7 +4,6 @@ PartOf=openclaw-secondbrain.target
[Path]
PathModified=/root/.openclaw/workspace/memory
-DirectoryNotEmpty=true
[Install]
WantedBy=multi-user.target
\ No newline at end of file
diff --git a/templates/dashboard.html b/templates/dashboard.html
index daea77d..221466c 100644
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -2,7 +2,7 @@
-
+
🧠 Second Brain
@@ -12,8 +12,10 @@
-Total
-OK
+ -Rej
-Pending
-Err
+ -Avg
@@ -24,14 +26,23 @@
-
-
+
+
+
+
+
+
+
+
@@ -108,6 +119,7 @@ let state = {
autoRefresh: true,
view: 'cards',
lastEvent: null,
+ selectedId: null,
};
// ─── Fetch ──────────────────────────────────────────────────────────────────
@@ -132,8 +144,10 @@ async function loadStats() {
const s = await api('/api/stats');
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
+ document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
+ document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
}
function updateStatsFromEvent(ev) {
@@ -141,8 +155,10 @@ function updateStatsFromEvent(ev) {
const s = ev.stats;
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
+ if (document.getElementById('statRejected')) document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending;
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) {
@@ -161,19 +177,74 @@ function setView(view) {
}
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.filter === 'confirmed') url += '&confirmed=1';
if (state.filter === 'pending') url += '&confirmed=0';
if (state.filter === 'rejected') url += '&verdict=confirmed_false';
if (state.filter === 'errors') url += '&tag=error';
+ return url;
+}
- const data = await api(url);
- state.items = data.items;
- renderCards();
- 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;
+async function exportCurrent() {
+ const fmt = (document.getElementById('exportFormat')?.value || 'jsonl').toLowerCase();
+ const limit = 100;
+ const max = 5000;
+ let offset = 0;
+ 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() {
@@ -1001,9 +1072,11 @@ function escapeHtml(t) {
}
// ─── Actions ────────────────────────────────────────────────────────────────
-async function confirm(id, ev) {
+async function confirm(id, ev, ctx = 'card') {
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`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -1014,9 +1087,11 @@ async function confirm(id, ev) {
if (state.view === 'status') loadStatus();
}
-async function reject(id, ev) {
+async function reject(id, ev, ctx = 'card') {
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`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -1052,18 +1127,62 @@ async function createEngram() {
async function showDetail(id) {
const item = await api(`/api/engrams/${id}`);
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 => `
+
+ ${s.engram_id.substring(0,8)}
+ ${escapeHtml(s.preview || s.content_preview || '')}
+
+
+ `).join('') : '
Keine Vorschläge';
body.innerHTML = `
-
Engramm ${item.id.substring(0,8)}
-
Confidence: ${Math.round(item.confidence*100)}%
-
Confirmed: ${item.confirmed ? '✅' : '❌'}
-
Tags: ${item.tags.map(t => ''+t+'').join(' ')}
-
Content:
-
${escapeHtml(item.content)}
-
History:
-
- ${(item.review_history || []).map(h => `- ${fmtDate(h.at)} — ${h.action} (${h.note})
`).join('')}
-
-
Links: ${item.links?.join(', ') || 'none'}
+
Engramm ${item.id.substring(0,8)}
+
verdict
${renderVerdictPill(item)} ${Math.round(item.confidence*100)}%
+
source
${escapeHtml(item.source || '-')}
+
created
${fmtDate(item.created)}
+
modified
${fmtDate(item.modified)}
+
access
${item.access_count ?? 0} • grounding ${item.grounding ?? 0}
+
tags
${(item.tags || []).map(t => ''+escapeHtml(t)+'').join(' ') || '-'}
+
+
Content
+
${escapeHtml(item.content || '')}
+
+
+
Vorschläge
+
${suggHtml}
+
+
+
+
Links
+
+ ${links.length ? links.map(l => `${l.substring(0,8)}`).join(' ') : 'none'}
+
+
+
+ ${Array.isArray(item.evidence) && item.evidence.length ? `
+
+
Evidence
+
+ ${item.evidence.map(e => `- ${escapeHtml(typeof e === 'string' ? e : JSON.stringify(e))}
`).join('')}
+
+
` : ''}
+
+
+
History
+
+ ${(item.review_history || []).map(h => `- ${fmtDate(h.at)} — ${escapeHtml(h.action)} ${h.note ? ('(' + escapeHtml(h.note) + ')') : ''}
`).join('') || '- -
'}
+
+
+
+
`;
document.getElementById('detailModal').classList.add('open');
}
@@ -1072,6 +1191,15 @@ function closeModal() {
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 ─────────────────────────────────────────────────────────────
function nextPage() {
state.offset += state.limit;
@@ -1114,6 +1242,92 @@ setInterval(() => {
// ─── Init ───────────────────────────────────────────────────────────────────
loadStats();
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 => `
+
+ ${s.engram_id.substring(0,8)}
+ ${escapeHtml(s.preview || s.content_preview || '')}
+
+
+ `).join('') : '
Keine Vorschläge';
+ return `
+
+
+
+ ${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
+
+
+ Vorschläge: ${suggHtml}
+
+
+
+ `;
+ }).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();
+}